Compare commits
3 Commits
develop-ch
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
9556a30747 | |
|
|
ac6d641f17 | |
|
|
871a52ddb0 |
|
|
@ -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
|
||||||
23
.env.example
23
.env.example
|
|
@ -13,3 +13,26 @@ AI_TEMPERATURE=0.2
|
||||||
|
|
||||||
# ---- 服务 ----
|
# ---- 服务 ----
|
||||||
PORT=8770
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
173
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# 软件开发部 · 关键任务追踪看板
|
# DMS管理平台
|
||||||
|
|
||||||
基于《软件开发部管理工作执行表.xlsx》构建的部门关键任务追踪系统。
|
基于《软件开发部管理工作执行表.xlsx》构建的 DMS 管理平台。
|
||||||
|
|
||||||
侧栏是**四个业务模块 + 三个支撑页**:
|
侧栏是**四个业务模块 + 三个支撑页**:
|
||||||
|
|
||||||
|
|
@ -8,12 +8,12 @@
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **① 工作看板** | 每天进来的第一屏 | 三类工作本周期谁更新了、谁没动,点一条从一览看到详情 | 只读汇总(`/api/weekly` + `/api/focus`) |
|
| **① 工作看板** | 每天进来的第一屏 | 三类工作本周期谁更新了、谁没动,点一条从一览看到详情 | 只读汇总(`/api/weekly` + `/api/focus`) |
|
||||||
| **② 项目管理** | NEX云桌面、AI Agent、智飞无人机平台、内部运营… | 长期重点项目的迭代执行、子任务进展、风险阻塞 | `projects` / `tasks` / `updates` |
|
| **② 项目管理** | NEX云桌面、AI Agent、智飞无人机平台、内部运营… | 长期重点项目的迭代执行、子任务进展、风险阻塞 | `projects` / `tasks` / `updates` |
|
||||||
| **③ 定开项目** | 各省市客户定开子项目 | 商务阶段流转(签单→开发→交付→验收→计收)+ 按进度确认收入 | `custom_projects` / `custom_updates` |
|
| **③ 定制项目** | 各省市客户定制子项目 | 商务阶段流转(签单→开发→交付→验收→计收)+ 按进度确认收入 | `custom_projects` / `custom_updates` |
|
||||||
| **④ 每周重点工作** | 不值得单独建项目档案的短期活儿 | 一条就是一件事,面板里录,归在**录入周**这一周,跟到完成 | `weekly_focus` |
|
| **④ 每周重点工作** | 不值得单独建项目档案的短期活儿 | 一条就是一件事,面板里录,归在**录入周**这一周,跟到完成 | `weekly_focus` |
|
||||||
|
|
||||||
支撑页三个:**人力资源**(`staff`,全系统「负责人」的唯一来源)、**更新录入**(执行 / 交付记录的唯一写入口:粘贴原文 → AI 整理 → 确认写入)、**设置**(周期管理、AI 配置)。
|
支撑页三个:**人力资源**(`staff`,全系统「负责人」的唯一来源)、**更新录入**(执行 / 交付记录的唯一写入口:粘贴原文 → AI 整理 → 确认写入)、**设置**(周期管理、AI 配置)。
|
||||||
|
|
||||||
②③ 是**两套彼此独立的项目体系**(数据表也完全分开),共用一个**人力资源库**:项目与定开项目的「负责人」都从花名册里选,人员进出只维护一处。④ 的条目不是项目,不挂子任务与执行记录。① 只做汇总与跳转,自己不落数据。
|
②③ 是**两套彼此独立的项目体系**(数据表也完全分开),共用一个**人力资源库**:项目与定制项目的「负责人」都从花名册里选,人员进出只维护一处。④ 的条目不是项目,不挂子任务与执行记录。① 只做汇总与跳转,自己不落数据。
|
||||||
|
|
||||||
## 周期管理
|
## 周期管理
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
| 入口 | 操作 |
|
| 入口 | 操作 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 顶部周期下拉 | 选「+ 新建周期…」 |
|
| 顶部周期下拉 | 选「+ 新建周期…」 |
|
||||||
| 更新录入页(常规 / 定开都有) | 周期下拉选「+ 新建周期…」 |
|
| 更新录入页(常规 / 定制都有) | 周期下拉选「+ 新建周期…」 |
|
||||||
| 设置页 → 周期管理 | 「+ 新建周期」按钮 |
|
| 设置页 → 周期管理 | 「+ 新建周期」按钮 |
|
||||||
|
|
||||||
输入格式 `9月第1周`(也可写 `2027年1月第1周` 显式指定年份),年份按以下规则自动推断:
|
输入格式 `9月第1周`(也可写 `2027年1月第1周` 显式指定年份),年份按以下规则自动推断:
|
||||||
|
|
@ -60,7 +60,7 @@ cd /Users/jiliu/WorkSpace/unis_manager
|
||||||
|
|
||||||
打开 <http://127.0.0.1:8770>
|
打开 <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`(默认是开着的) |
|
| 关闭热加载 | `DEV=0 ./run.sh`(默认是开着的) |
|
||||||
| 不自动开浏览器 | `NO_OPEN=1 ./run.sh` |
|
| 不自动开浏览器 | `NO_OPEN=1 ./run.sh` |
|
||||||
| 提示"端口已被占用" | 原窗口 `Ctrl+C`,或 `lsof -ti:8770 \| xargs kill` 后重跑 |
|
| 提示"端口已被占用" | 原窗口 `Ctrl+C`,或 `lsof -ti:8770 \| xargs kill` 后重跑 |
|
||||||
|
| 用 Docker 部署(不装 Python) | `./deploy.sh up`,见下面「Docker 部署」 |
|
||||||
|
|
||||||
> ⚠️ 不要用 `sudo` 启动:数据库文件会变成 root 属主,后续写入会失败。
|
> ⚠️ 不要用 `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`
|
> 重新导入:`python scripts/import_excel.py <xlsx> --reset` 之后再跑一次 `python scripts/migrate_custom.py`
|
||||||
> (迁移脚本会删除常规表里的「定制开发」容器,重复执行前请先 reset)
|
> (迁移脚本会删除常规表里的「定制开发」容器,重复执行前请先 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. 工作看板
|
### 1. 工作看板
|
||||||
|
|
@ -102,15 +197,15 @@ python -m uvicorn app.main:app --port 8770
|
||||||
|
|
||||||
| 区域 | 内容 |
|
| 区域 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 顶部 KPI | 重点项目(含本期已更新数)/ 重点项目未更新 / **本周重点工作**(本周到期的重点工作条数,脚注「逾期 N 条 · 已完成 N 条」)/ 执行记录 / 定开合同额(含项目数与本期计收)/ 风险阻塞 |
|
| 顶部 KPI | 重点项目(含本期已更新数)/ 重点项目未更新 / **本周重点工作**(本周到期的重点工作条数,脚注「逾期 N 条 · 已完成 N 条」)/ 执行记录 / 定制合同额(含项目数与本期计收)/ 风险阻塞 |
|
||||||
| **左栏** | **三组**清单,顺序固定:① **本周工作重点**(列表,逾期的置顶)② **重点项目**(列表:`is_key` 的项目,另含本期补了执行记录的非重点项目,条目前用 ★ 区分)③ **定开项目**——**只给一个入口**「本周定开项目动态」,左栏不铺清单(100 多个项目会淹没看板),点开才在右栏列本周有动态的。组头显示条数与「几个已更新 / 几条逾期 / 本周动态几个」;每条带负责人、状态或阶段、本期摘要、风险条数,本期有记录的点亮标记 |
|
| **左栏** | **三组**清单,顺序固定:① **本周工作重点**(列表,逾期的置顶)② **重点项目**(列表:`is_key` 的项目,另含本期补了执行记录的非重点项目,条目前用 ★ 区分)③ **定制项目**——**只给一个入口**「本周定制项目动态」,左栏不铺清单(100 多个项目会淹没看板),点开才在右栏列本周有动态的。组头显示条数与「几个已更新 / 几条逾期 / 本周动态几个」;每条带负责人、状态或阶段、本期摘要、风险条数,本期有记录的点亮标记 |
|
||||||
| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 8 期趋势;**定开项目入口 → 本周动态总览**(顶部标签:本周新增 / 本周验收 / 有交付动态 / 合同额合计 / 本期计收,下面分「本周新增」「本周验收」「本周有变化」三段,每条带阶段、负责人、金额、本期计收与摘要),点某一条再下钻到该项目 → 交付环节五段条 / 财务跟踪四联卡 + 开票·回款行内输入 / 本期交付记录 / 更早的交付记录,改开票、回款、环节日期即时保存(改日期会自动推进商务阶段);每周重点工作 → 只读的说明与状态,要改点「修改」开抽屉 |
|
| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 8 期趋势;**定制项目入口 → 本周动态总览**(顶部标签:本周新增 / 本周验收 / 有交付动态 / 合同额合计 / 本期计收,下面分「本周新增」「本周验收」「本周有变化」三段,每条带阶段、负责人、金额、本期计收与摘要),点某一条再下钻到该项目 → 交付环节五段条 / 财务跟踪四联卡 + 开票·回款行内输入 / 本期交付记录 / 更早的交付记录,改开票、回款、环节日期即时保存(改日期会自动推进商务阶段);每周重点工作 → 只读的说明与状态,要改点「修改」开抽屉 |
|
||||||
| 工具栏 | 「负责人」筛选、「导出本周期记录」,提示里带本期项目数与定开动态数;顶栏可切周 / 月视图(月视图自动汇总该月所有周)。「列入全部在途」开关(`all_custom`)不在工具栏,挪到了右栏定开总览的头部 |
|
| 工具栏 | 「负责人」筛选、「导出本周期记录」,提示里带本期项目数与定制动态数;顶栏可切周 / 月视图(月视图自动汇总该月所有周)。「列入全部在途」开关(`all_custom`)不在工具栏,挪到了右栏定制总览的头部 |
|
||||||
| 统计概览 | 页面最底部的折叠区(原「执行面板」,已不占主导航位):周期活跃度柱状图、分类环形图、状态分布、进度 Top10、低进度预警、风险清单,数据来自 `GET /api/stats`;加载失败也不影响看板主体 |
|
| 统计概览 | 页面最底部的折叠区(原「执行面板」,已不占主导航位):周期活跃度柱状图、分类环形图、状态分布、进度 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. 项目管理
|
### 2. 项目管理
|
||||||
|
|
||||||
|
|
@ -127,12 +222,12 @@ python -m uvicorn app.main:app --port 8770
|
||||||
| 操作 | 说明 |
|
| 操作 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → AI 整理 → 确认写入),抽屉里不另做一套表单,避免两条路写出两份数据。基础信息的按钮在标题栏、附表的更新按钮在附表上方,两类按钮不在同一个位置混着放;编辑与新增档的报错信息显示在正文最上方;周期反馈上方的 按周/按月 + 本周/近 4 周/… 标成「查看范围(只读筛选)」,只翻看不改数据 |
|
| **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → 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`
|
- 执行前建议备份:`cp data/board.db data/board.db.bak`
|
||||||
|
|
||||||
### 3. 定开项目
|
### 3. 定制项目
|
||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -157,7 +252,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
|
||||||
| **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 |
|
| **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 |
|
||||||
| **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 |
|
| **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 |
|
||||||
| **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 |
|
| **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 |
|
||||||
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定开项目并带上项目与周期)留在附表标题下 |
|
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定制项目并带上项目与周期)留在附表标题下 |
|
||||||
|
|
||||||
### 4. 每周重点工作
|
### 4. 每周重点工作
|
||||||
|
|
||||||
|
|
@ -169,7 +264,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
|
||||||
| 列表 | 工作内容 / 说明(截断预览)/ 预计完成时间(下面小字标「录入 X月第N周」)/ 状态(标了延期且没完成的挂红色「延期」徽章)/ 更新日期 / 操作 **详情 · 修改 · 归档 · 删除**——和两张项目列表同一套规矩:列表只读,行内不放输入框 |
|
| 列表 | 工作内容 / 说明(截断预览)/ 预计完成时间(下面小字标「录入 X月第N周」)/ 状态(标了延期且没完成的挂红色「延期」徽章)/ 更新日期 / 操作 **详情 · 修改 · 归档 · 删除**——和两张项目列表同一套规矩:列表只读,行内不放输入框 |
|
||||||
| 筛选 | **本周 / 延期 / 已完成 / 全部** 四个堆(chip 上实时显示各堆条数)+「含归档条目」;顶栏切周即换基准周,工具栏标明「本周/延期按录入周统计」;顶部全局搜索同时匹配工作内容与说明 |
|
| 筛选 | **本周 / 延期 / 已完成 / 全部** 四个堆(chip 上实时显示各堆条数)+「含归档条目」;顶栏切周即换基准周,工具栏标明「本周/延期按录入周统计」;顶部全局搜索同时匹配工作内容与说明 |
|
||||||
| 详情抽屉 | 同样分「详情 / 修改」两档,**基础信息的按钮全在抽屉标题栏右侧的按钮条上**:详情档是「删除 · 归档 · **修改**」,修改与新增档是「取消 · **添加/保存**」,报错信息显示在正文最上方。点「修改」才出表单,**录入周只读**(改预计完成时间不会把条目搬去别的周)。重点工作条目没有附表,所以抽屉里不出现绿的更新条 |
|
| 详情抽屉 | 同样分「详情 / 修改」两档,**基础信息的按钮全在抽屉标题栏右侧的按钮条上**:详情档是「删除 · 归档 · **修改**」,修改与新增档是「取消 · **添加/保存**」,报错信息显示在正文最上方。点「修改」才出表单,**录入周只读**(改预计完成时间不会把条目搬去别的周)。重点工作条目没有附表,所以抽屉里不出现绿的更新条 |
|
||||||
| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定开项目的项目删除上 |
|
| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定制项目的项目删除上 |
|
||||||
| 导出 | 右上角「导出 Excel」→ `GET /api/export/focus`,带上当前筛选条件 |
|
| 导出 | 右上角「导出 Excel」→ `GET /api/export/focus`,带上当前筛选条件 |
|
||||||
| 联动 | 工作看板左栏**第一组**「本周工作重点」与 KPI「本周重点工作」读的就是这张表;看板右栏点条目给只读详情,改内容走抽屉 |
|
| 联动 | 工作看板左栏**第一组**「本周工作重点」与 KPI「本周重点工作」读的就是这张表;看板右栏点条目给只读详情,改内容走抽屉 |
|
||||||
|
|
||||||
|
|
@ -183,12 +278,12 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
|
||||||
| 区块 | 说明 |
|
| 区块 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) |
|
| 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) |
|
||||||
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定开项目数、定开合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
|
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定制项目数、定制合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
|
||||||
| 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 |
|
| 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 |
|
||||||
| 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `constants.STAFF_GROUPS` / `STAFF_AFFILIATIONS` |
|
| 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `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 会自动导入
|
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` |
|
||||||
| 定开项目 | 定开项目周期记录 | `GET /api/export/custom-updates` |
|
| 定制项目 | 定制项目周期记录 | `GET /api/export/custom-updates` |
|
||||||
| 项目管理 | 项目管理清单(分类、负责人、状态、标签、子任务数等) | `GET /api/export/projects` |
|
| 项目管理 | 项目管理清单(分类、负责人、状态、标签、子任务数等) | `GET /api/export/projects` |
|
||||||
| 工作看板 | 本周期(或本月)的执行记录明细 | `GET /api/export/updates` |
|
| 工作看板 | 本周期(或本月)的执行记录明细 | `GET /api/export/updates` |
|
||||||
| 每周重点工作 | 重点工作清单(工作内容 / 说明 / 预计完成时间 / 录入周 / 执行状态 / 是否延期 / 结果反馈 / 更新) | `GET /api/export/focus` |
|
| 每周重点工作 | 重点工作清单(工作内容 / 说明 / 预计完成时间 / 录入周 / 执行状态 / 是否延期 / 结果反馈 / 更新) | `GET /api/export/focus` |
|
||||||
| 人力资源库 | 花名册(含在管 / 定开项目数、合同额、待计收) | `GET /api/export/staff` |
|
| 人力资源库 | 花名册(含在管 / 定制项目数、合同额、待计收) | `GET /api/export/staff` |
|
||||||
|
|
||||||
导出的 xlsx 带表头样式、冻结首行、自动筛选,金额列按千分位格式;文件名含导出日期。
|
导出的 xlsx 带表头样式、冻结首行、自动筛选,金额列按千分位格式;文件名含导出日期。
|
||||||
|
|
||||||
## 收入确认口径
|
## 收入确认口径
|
||||||
|
|
||||||
定开项目按**完工百分比法**确认收入:
|
定制项目按**完工百分比法**确认收入:
|
||||||
|
|
||||||
| 阶段 | 确认比例 |
|
| 阶段 | 确认比例 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -280,7 +375,7 @@ python scripts/add_entry_date.py --force # 全部重算
|
||||||
- **可确认收入** = 合同额 × 上表比例
|
- **可确认收入** = 合同额 × 上表比例
|
||||||
- **待计收** = 可确认收入 − 已计收
|
- **待计收** = 可确认收入 − 已计收
|
||||||
- **待开票** = 合同额 − 已开票、**待回款** = 合同额 − 已回款(均不小于 0,随开票 / 回款金额自动更新)
|
- **待开票** = 合同额 − 已开票、**待回款** = 合同额 − 已回款(均不小于 0,随开票 / 回款金额自动更新)
|
||||||
- 开票与回款金额在项目详情抽屉、定开项目详情抽屉、工作看板右栏都能直接填
|
- 开票与回款金额在项目详情抽屉、定制项目详情抽屉、工作看板右栏都能直接填
|
||||||
- 项目上可填「手动确认比例」覆盖阶段推算(用于预付、分批收款等特殊情形)
|
- 项目上可填「手动确认比例」覆盖阶段推算(用于预付、分批收款等特殊情形)
|
||||||
- 比例定义见 `app/constants.py::CUSTOM_STAGES` 与 `custom_revenue_ratio()`,改一处即可全局生效
|
- 比例定义见 `app/constants.py::CUSTOM_STAGES` 与 `custom_revenue_ratio()`,改一处即可全局生效
|
||||||
|
|
||||||
|
|
@ -293,10 +388,10 @@ Period(周期: 年/月/周, sort_key)
|
||||||
├── Task(子任务)
|
├── Task(子任务)
|
||||||
└── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文)
|
└── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文)
|
||||||
|
|
||||||
# 定开项目(独立)
|
# 定制项目(独立)
|
||||||
CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例,
|
CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例,
|
||||||
已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档)
|
已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档)
|
||||||
# 定开项目不做多负责人:other_owners 列保留在库里但不再读写
|
# 定制项目不做多负责人:other_owners 列保留在库里但不再读写
|
||||||
└── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步)
|
└── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步)
|
||||||
|
|
||||||
# 每周重点工作(独立:条目不是项目,不挂子任务与执行记录)
|
# 每周重点工作(独立:条目不是项目,不挂子任务与执行记录)
|
||||||
|
|
@ -319,7 +414,7 @@ LedgerItem(客户项目台账) # 只剩模型与 Excel 导入解析,界面
|
||||||
两套体系各有独立提示词:
|
两套体系各有独立提示词:
|
||||||
|
|
||||||
- **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步
|
- **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步
|
||||||
- **定开项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
|
- **定制项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
|
||||||
- **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态)
|
- **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态)
|
||||||
|
|
||||||
- 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
|
- 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
|
||||||
|
|
@ -347,14 +442,18 @@ app/
|
||||||
main.py FastAPI 入口(API 挂载在 /api,静态页面在 /)
|
main.py FastAPI 入口(API 挂载在 /api,静态页面在 /)
|
||||||
config.py 配置与 .env 加载(DATABASE_URL、AI 参数)
|
config.py 配置与 .env 加载(DATABASE_URL、AI 参数)
|
||||||
db.py engine / Session(按 URL 自动适配方言)
|
db.py engine / Session(按 URL 自动适配方言)
|
||||||
models.py ORM 模型(项目 + 定开项目 + 每周重点工作 + 花名册)
|
models.py ORM 模型(项目 + 定制项目 + 每周重点工作 + 花名册)
|
||||||
constants.py 状态/阶段常量、收入确认比例、区域与阶段识别
|
constants.py 状态/阶段常量、收入确认比例、区域与阶段识别
|
||||||
ai_parser.py LLM 解析 + 本地规则兜底(通用 / 定开两套)
|
ai_parser.py LLM 解析 + 本地规则兜底(通用 / 定制两套)
|
||||||
routers/ board(周期/元数据/统计) · weekly(工作看板数据) · projects(项目管理) ·
|
routers/ board(周期/元数据/统计) · weekly(工作看板数据) · projects(项目管理) ·
|
||||||
custom(定开项目) · focus(每周重点工作) · staff(人力) · ai · export
|
custom(定制项目) · focus(每周重点工作) · staff(人力) · ai · export
|
||||||
web/ index.html · styles.css · app.js(零依赖,无构建步骤)
|
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
|
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 数据库
|
data/board.db SQLite 数据库
|
||||||
|
Dockerfile 运行镜像(依赖 + 代码,启动时自动补表/预置分类)
|
||||||
|
docker-compose.yml 部署编排(./data 持久化、HOST_PORT、健康检查)
|
||||||
|
deploy.sh 部署命令封装:up / update / restart / down / logs / status
|
||||||
|
.dockerignore 排除 data/ 与 .env,不把数据和密钥打进镜像
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 速览
|
## API 速览
|
||||||
|
|
@ -367,7 +466,7 @@ POST /api/periods 新增周期(传 label 如「9月第1周」
|
||||||
DELETE /api/periods/{id} 删除空周期(有记录则拒绝)
|
DELETE /api/periods/{id} 删除空周期(有记录则拒绝)
|
||||||
GET /api/board?period_id=|year&month 周期汇总(页面主链路不走它,统计走 /api/stats)
|
GET /api/board?period_id=|year&month 周期汇总(页面主链路不走它,统计走 /api/stats)
|
||||||
GET /api/stats?period_id=|year&month
|
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)
|
(custom_new 本期新入库 / custom_updated 本期有交付记录;行上带 is_new)
|
||||||
(左栏第一组「本周工作重点」前端另取 /api/focus 合并)
|
(左栏第一组「本周工作重点」前端另取 /api/focus 合并)
|
||||||
GET /api/projects (?archived=false|true|all)
|
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 列供归属统计,界面不再区分主次)
|
(owners/members 传人名数组,整体替换:不传=不改,传 []=清空;owners[0] 回写 owner 列供归属统计,界面不再区分主次)
|
||||||
GET /api/categories POST /api/categories PATCH /api/categories/{id} DELETE /api/categories/{id}
|
GET /api/categories POST /api/categories PATCH /api/categories/{id} DELETE /api/categories/{id}
|
||||||
|
|
||||||
# 定开项目
|
# 定制项目
|
||||||
GET /api/custom/meta 阶段字典 + 区域列表 + 交付环节(pipeline) + 卡点阈值(aging_days)
|
GET /api/custom/meta 阶段字典 + 区域列表 + 交付环节(pipeline) + 卡点阈值(aging_days)
|
||||||
GET /api/custom/projects?stage=®ion=&keyword=
|
GET /api/custom/projects?stage=®ion=&keyword=
|
||||||
POST /api/custom/projects PATCH /api/custom/projects/{id} DELETE /api/custom/projects/{id}
|
POST /api/custom/projects PATCH /api/custom/projects/{id} DELETE /api/custom/projects/{id}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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 代码块、不要解释),结构如下:
|
请严格输出 JSON(不要 Markdown 代码块、不要解释),结构如下:
|
||||||
{
|
{
|
||||||
"stage": "developing",
|
"stage": "developing",
|
||||||
|
|
@ -247,7 +247,7 @@ CUSTOM_SYSTEM_PROMPT = """你是定开项目的管理助理,负责把项目周
|
||||||
5. 不要编造原文没有的金额和数字。
|
5. 不要编造原文没有的金额和数字。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CUSTOM_USER_TEMPLATE = """定开项目:{project}
|
CUSTOM_USER_TEMPLATE = """定制项目:{project}
|
||||||
周期:{period}
|
周期:{period}
|
||||||
当前阶段:{stage}
|
当前阶段:{stage}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ LEDGER_CATEGORIES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# 定开项目(交付制,与平台产品类项目分开管理)
|
# 定制项目(交付制,与平台产品类项目分开管理)
|
||||||
# (code, 名称, 颜色, 收入确认基准比例)
|
# (code, 名称, 颜色, 收入确认基准比例)
|
||||||
# 开发中阶段:base + 0.4 * progress/100
|
# 开发中阶段:base + 0.4 * progress/100
|
||||||
# 其余阶段:直接使用 base
|
# 其余阶段:直接使用 base
|
||||||
|
|
@ -74,7 +74,7 @@ def custom_revenue_ratio(stage: str, progress: float | None, manual_ratio=None)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# 定开项目:文本 → 阶段 / 金额 识别
|
# 定制项目:文本 → 阶段 / 金额 识别
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
CUSTOM_STAGE_RULES = [
|
CUSTOM_STAGE_RULES = [
|
||||||
(("暂停", "终止", "未达成一致", "搁置"), "paused"),
|
(("暂停", "终止", "未达成一致", "搁置"), "paused"),
|
||||||
|
|
@ -341,7 +341,7 @@ DEFAULT_CATEGORY_COLORS = [
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# 定开项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
|
# 定制项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
|
||||||
# key 环节代码(与 CUSTOM_STAGES 的阶段代码对齐,入库单独用 entry)
|
# key 环节代码(与 CUSTOM_STAGES 的阶段代码对齐,入库单独用 entry)
|
||||||
# name 环节名
|
# name 环节名
|
||||||
# date_field 该环节的日期字段(CustomProject 上的属性名)
|
# date_field 该环节的日期字段(CustomProject 上的属性名)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ with SessionLocal() as _db:
|
||||||
# 每次进程启动换一个值;热加载重启后前端据此自动刷新
|
# 每次进程启动换一个值;热加载重启后前端据此自动刷新
|
||||||
BOOT_ID = uuid.uuid4().hex
|
BOOT_ID = uuid.uuid4().hex
|
||||||
|
|
||||||
app = FastAPI(title="部门关键任务追踪看板", version="1.0.0")
|
app = FastAPI(title="DMS管理平台", version="1.0.0")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -35,7 +35,7 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
# 所有 /api 接口先过 RBAC 总闸(登录 + 菜单权限),登录接口自身除外
|
# 所有 /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(board.router, prefix="", tags=["board"])
|
||||||
api.include_router(projects.router, prefix="", tags=["projects"])
|
api.include_router(projects.router, prefix="", tags=["projects"])
|
||||||
api.include_router(ai.router, prefix="", tags=["ai"])
|
api.include_router(ai.router, prefix="", tags=["ai"])
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ class TimestampMixin:
|
||||||
|
|
||||||
|
|
||||||
class Category(Base, TimestampMixin):
|
class Category(Base, TimestampMixin):
|
||||||
"""项目分类(如 平台产品 / AI / 定开项目 / 内部运营)。"""
|
"""项目分类(如 平台产品 / AI / 定制项目 / 内部运营)。"""
|
||||||
|
|
||||||
__tablename__ = "categories"
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
|
@ -376,7 +376,7 @@ class LedgerItem(Base, TimestampMixin):
|
||||||
|
|
||||||
|
|
||||||
class CustomProject(Base, TimestampMixin):
|
class CustomProject(Base, TimestampMixin):
|
||||||
"""定开项目(与通用研发项目完全分离)。
|
"""定制项目(与通用研发项目完全分离)。
|
||||||
|
|
||||||
关注:商务阶段流转(签单 → 开发 → 交付 → 验收 → 计收)与按进度确认收入。
|
关注:商务阶段流转(签单 → 开发 → 交付 → 验收 → 计收)与按进度确认收入。
|
||||||
"""
|
"""
|
||||||
|
|
@ -396,7 +396,7 @@ class CustomProject(Base, TimestampMixin):
|
||||||
revenue_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已计收金额
|
revenue_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已计收金额
|
||||||
|
|
||||||
owner: Mapped[str | None] = mapped_column(String(64), 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) # 团队成员
|
members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员
|
||||||
entry_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 入库时间(YYYY-MM-DD)
|
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) # 签单/下单时间
|
sign_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 签单/下单时间
|
||||||
|
|
@ -503,7 +503,7 @@ class CustomProject(Base, TimestampMixin):
|
||||||
|
|
||||||
|
|
||||||
class CustomUpdate(Base, TimestampMixin):
|
class CustomUpdate(Base, TimestampMixin):
|
||||||
"""定开项目的阶段流转 / 周期执行记录(独立表)。"""
|
"""定制项目的阶段流转 / 周期执行记录(独立表)。"""
|
||||||
|
|
||||||
__tablename__ = "custom_updates"
|
__tablename__ = "custom_updates"
|
||||||
|
|
||||||
|
|
@ -557,7 +557,7 @@ class CustomUpdate(Base, TimestampMixin):
|
||||||
class Staff(Base, TimestampMixin):
|
class Staff(Base, TimestampMixin):
|
||||||
"""人力资源库成员。
|
"""人力资源库成员。
|
||||||
|
|
||||||
项目管理 / 定开项目的负责人一律从这里选取;归档后不再出现在下拉里,
|
项目管理 / 定制项目的负责人一律从这里选取;归档后不再出现在下拉里,
|
||||||
历史记录中的人名保持不变。
|
历史记录中的人名保持不变。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -170,7 +170,7 @@ def create_period(payload: PeriodCreate, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
@router.delete("/periods/{pid}")
|
@router.delete("/periods/{pid}")
|
||||||
def delete_period(pid: int, db: Session = Depends(get_db)):
|
def delete_period(pid: int, db: Session = Depends(get_db)):
|
||||||
"""删除周期。仅允许删除没有任何执行记录(通用 / 定开)的空周期。"""
|
"""删除周期。仅允许删除没有任何执行记录(通用 / 定制)的空周期。"""
|
||||||
from ..models import CustomUpdate
|
from ..models import CustomUpdate
|
||||||
|
|
||||||
p = db.get(Period, pid)
|
p = db.get(Period, pid)
|
||||||
|
|
@ -476,6 +476,9 @@ def stats(
|
||||||
"update_count": len(updates),
|
"update_count": len(updates),
|
||||||
"covered_projects": len(updated_ids),
|
"covered_projects": len(updated_ids),
|
||||||
"risk_count": len(risks),
|
"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)
|
"avg_progress": round(sum(covered_prog) / len(covered_prog), 1)
|
||||||
if covered_prog
|
if covered_prog
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""定开项目 API(独立于通用项目体系)。
|
"""定制项目 API(独立于通用项目体系)。
|
||||||
|
|
||||||
关注:商务阶段流转 与 按进度确认收入。
|
关注:商务阶段流转 与 按进度确认收入。
|
||||||
"""
|
"""
|
||||||
|
|
@ -40,7 +40,7 @@ class CustomProjectIn(BaseModel):
|
||||||
progress: float = 0
|
progress: float = 0
|
||||||
manual_ratio: float | None = None
|
manual_ratio: float | None = None
|
||||||
revenue_amount: float | None = None
|
revenue_amount: float | None = None
|
||||||
owner: str | None = None # 交付负责人(定开项目为单人,多负责人只用于重点项目)
|
owner: str | None = None # 交付负责人(定制项目为单人,多负责人只用于重点项目)
|
||||||
members: list[str] | None = None # 团队成员
|
members: list[str] | None = None # 团队成员
|
||||||
entry_date: str | None = None
|
entry_date: str | None = None
|
||||||
sign_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:
|
if exclude_id is not None:
|
||||||
q = q.filter(CustomProject.id != exclude_id)
|
q = q.filter(CustomProject.id != exclude_id)
|
||||||
if q.first():
|
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)):
|
def get_custom_project(cid: int, db: Session = Depends(get_db)):
|
||||||
cp = db.get(CustomProject, cid)
|
cp = db.get(CustomProject, cid)
|
||||||
if not cp:
|
if not cp:
|
||||||
raise HTTPException(404, "定开项目不存在")
|
raise HTTPException(404, "定制项目不存在")
|
||||||
ups = (
|
ups = (
|
||||||
db.query(CustomUpdate, Period)
|
db.query(CustomUpdate, Period)
|
||||||
.join(Period, Period.id == CustomUpdate.period_id)
|
.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)):
|
def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db)):
|
||||||
cp = db.get(CustomProject, cid)
|
cp = db.get(CustomProject, cid)
|
||||||
if not cp:
|
if not cp:
|
||||||
raise HTTPException(404, "定开项目不存在")
|
raise HTTPException(404, "定制项目不存在")
|
||||||
allowed = (
|
allowed = (
|
||||||
"name", "customer", "region", "amount", "stage", "progress", "manual_ratio",
|
"name", "customer", "region", "amount", "stage", "progress", "manual_ratio",
|
||||||
"revenue_amount", "invoice_amount", "received_amount", "owner",
|
"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)):
|
def delete_custom_project(cid: int, db: Session = Depends(get_db)):
|
||||||
cp = db.get(CustomProject, cid)
|
cp = db.get(CustomProject, cid)
|
||||||
if not cp:
|
if not cp:
|
||||||
raise HTTPException(404, "定开项目不存在")
|
raise HTTPException(404, "定制项目不存在")
|
||||||
name = cp.name
|
name = cp.name
|
||||||
n_upd = len(cp.updates)
|
n_upd = len(cp.updates)
|
||||||
db.delete(cp)
|
db.delete(cp)
|
||||||
|
|
@ -371,7 +371,7 @@ def delete_custom_project(cid: int, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 按周删除
|
# ---------------------------------------------------------------- 按周删除
|
||||||
def _week_filtered(db: Session):
|
def _week_filtered(db: Session):
|
||||||
"""本周(周一~周日)有「更新时间」的定开项目。"""
|
"""本周(周一~周日)有「更新时间」的定制项目。"""
|
||||||
start, end = _week_range()
|
start, end = _week_range()
|
||||||
rows = (
|
rows = (
|
||||||
db.query(CustomProject)
|
db.query(CustomProject)
|
||||||
|
|
@ -400,7 +400,7 @@ def weekly_preview(db: Session = Depends(get_db)):
|
||||||
|
|
||||||
@router.post("/custom/purge-week")
|
@router.post("/custom/purge-week")
|
||||||
def purge_week(db: Session = Depends(get_db)):
|
def purge_week(db: Session = Depends(get_db)):
|
||||||
"""删除本周(周一~周日)更新的定开项目,连带其记录。破坏性操作,前端须二次确认。"""
|
"""删除本周(周一~周日)更新的定制项目,连带其记录。破坏性操作,前端须二次确认。"""
|
||||||
start, end, rows = _week_filtered(db)
|
start, end, rows = _week_filtered(db)
|
||||||
names = [r.name for r in rows]
|
names = [r.name for r in rows]
|
||||||
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)):
|
def create_custom_update(payload: dict, db: Session = Depends(get_db)):
|
||||||
cp = db.get(CustomProject, payload.get("custom_project_id"))
|
cp = db.get(CustomProject, payload.get("custom_project_id"))
|
||||||
if not cp:
|
if not cp:
|
||||||
raise HTTPException(404, "定开项目不存在")
|
raise HTTPException(404, "定制项目不存在")
|
||||||
u = CustomUpdate(
|
u = CustomUpdate(
|
||||||
custom_project_id=payload["custom_project_id"],
|
custom_project_id=payload["custom_project_id"],
|
||||||
period_id=payload["period_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}")
|
@router.patch("/custom/updates/{uid}")
|
||||||
def patch_custom_update(uid: int, payload: dict, db: Session = Depends(get_db)):
|
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)):
|
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
|
cp = db.get(CustomProject, payload.custom_project_id) if payload.custom_project_id else None
|
||||||
if payload.custom_project_id and not cp:
|
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
|
period = db.get(Period, payload.period_id) if payload.period_id else None
|
||||||
|
|
||||||
parsed, mode, error = ai_parser.parse_custom(
|
parsed, mode, error = ai_parser.parse_custom(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""数据导出(Excel / xlsx)。
|
"""数据导出(Excel / xlsx)。
|
||||||
|
|
||||||
提供四类导出,均可带上页面当前的筛选条件:
|
提供四类导出,均可带上页面当前的筛选条件:
|
||||||
- /export/custom 定开项目明细
|
- /export/custom 定制项目明细
|
||||||
- /export/custom-updates 定开项目周期记录
|
- /export/custom-updates 定制项目周期记录
|
||||||
- /export/projects 常规项目管理
|
- /export/projects 常规项目管理
|
||||||
- /export/updates 常规项目周期执行记录
|
- /export/updates 常规项目周期执行记录
|
||||||
- /export/staff 人力资源库(含项目负载)
|
- /export/staff 人力资源库(含项目负载)
|
||||||
|
|
@ -42,12 +42,13 @@ def _resp(content: bytes, filename: str) -> Response:
|
||||||
return Response(content=body, headers=headers)
|
return Response(content=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 定开项目
|
# ---------------------------------------------------------------- 定制项目
|
||||||
@router.get("/export/custom")
|
@router.get("/export/custom")
|
||||||
def export_custom(
|
def export_custom(
|
||||||
region: str | None = None,
|
region: str | None = None,
|
||||||
origin: str | None = None,
|
origin: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
risk: str | None = None,
|
||||||
accept_plan: str | None = None,
|
accept_plan: str | None = None,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
archived: str = Query("false", pattern="^(true|false|all)$"),
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
|
@ -64,13 +65,15 @@ def export_custom(
|
||||||
q = q.filter(CustomProject.origin == origin)
|
q = q.filter(CustomProject.origin == origin)
|
||||||
if status:
|
if status:
|
||||||
q = q.filter(CustomProject.project_status == status)
|
q = q.filter(CustomProject.project_status == status)
|
||||||
|
if risk:
|
||||||
|
q = q.filter(CustomProject.risk_level == risk)
|
||||||
if accept_plan:
|
if accept_plan:
|
||||||
q = q.filter(CustomProject.accept_plan == accept_plan)
|
q = q.filter(CustomProject.accept_plan == accept_plan)
|
||||||
if keyword:
|
if keyword:
|
||||||
q = q.filter(CustomProject.name.contains(keyword))
|
q = q.filter(CustomProject.name.contains(keyword))
|
||||||
projects = _sort_by_update(q.all())
|
projects = _sort_by_update(q.all())
|
||||||
|
|
||||||
# 列与「定开项目明细」表格一致
|
# 列与「定制项目明细」表格一致
|
||||||
headers = [
|
headers = [
|
||||||
"项目归属", "项目名称", "项目ID", "下单时间", "办事处", "行业",
|
"项目归属", "项目名称", "项目ID", "下单时间", "办事处", "行业",
|
||||||
"市场下单人天", "订单金额(元)", "市场责任人", "项目状态",
|
"市场下单人天", "订单金额(元)", "市场责任人", "项目状态",
|
||||||
|
|
@ -110,14 +113,14 @@ def export_custom(
|
||||||
)
|
)
|
||||||
|
|
||||||
content = build_xlsx(
|
content = build_xlsx(
|
||||||
"定开项目明细",
|
"定制项目明细",
|
||||||
headers,
|
headers,
|
||||||
rows,
|
rows,
|
||||||
widths=[10, 46, 18, 12, 10, 8, 13, 14, 14, 11, 11, 14, 10, 40, 18],
|
widths=[10, 46, 18, 12, 10, 8, 13, 14, 14, 11, 11, 14, 10, 40, 18],
|
||||||
title="定开项目明细",
|
title="定制项目明细",
|
||||||
money_cols=(8,), # 订单金额(元)
|
money_cols=(8,), # 订单金额(元)
|
||||||
)
|
)
|
||||||
return _resp(content, "定开项目明细")
|
return _resp(content, "定制项目明细")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export/custom-updates")
|
@router.get("/export/custom-updates")
|
||||||
|
|
@ -158,14 +161,14 @@ def export_custom_updates(
|
||||||
)
|
)
|
||||||
|
|
||||||
content = build_xlsx(
|
content = build_xlsx(
|
||||||
"定开项目记录",
|
"定制项目记录",
|
||||||
headers,
|
headers,
|
||||||
rows,
|
rows,
|
||||||
widths=[18, 42, 10, 14, 70, 10, 16, 16, 30, 30],
|
widths=[18, 42, 10, 14, 70, 10, 16, 16, 30, 30],
|
||||||
title="定开项目周期记录",
|
title="定制项目周期记录",
|
||||||
money_cols=(7, 8),
|
money_cols=(7, 8),
|
||||||
)
|
)
|
||||||
return _resp(content, "定开项目周期记录")
|
return _resp(content, "定制项目周期记录")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 常规项目
|
# ---------------------------------------------------------------- 常规项目
|
||||||
|
|
@ -296,7 +299,7 @@ def export_staff(
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
"序号", "姓名", "归属小组", "人员归属", "在管项目", "重点项目",
|
"序号", "姓名", "归属小组", "人员归属", "在管项目", "重点项目",
|
||||||
"定开项目", "定开合同额(元)", "定开待计收(元)", "最近更新", "说明", "归档",
|
"定制项目", "定制合同额(元)", "定制待计收(元)", "最近更新", "说明", "归档",
|
||||||
]
|
]
|
||||||
rows = []
|
rows = []
|
||||||
for i, m in enumerate(members, 1):
|
for i, m in enumerate(members, 1):
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
和项目管理(Project + Update)分开:不用先建项目档案,一条就是一个活儿。
|
和项目管理(Project + Update)分开:不用先建项目档案,一条就是一个活儿。
|
||||||
字段按需求——工作内容 / 说明 / 预计完成时间(日历日期)/ 执行状态 / 结果反馈 / 是否延期。
|
字段按需求——工作内容 / 说明 / 预计完成时间(日历日期)/ 执行状态 / 结果反馈 / 是否延期。
|
||||||
period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定开项目的周期归属同一个意思;
|
period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定制项目的周期归属同一个意思;
|
||||||
预计完成时间只存 due_date,不再按日期反查周期。本周录一条两周后要干的事,本周照样看得见。
|
预计完成时间只存 due_date,不再按日期反查周期。本周录一条两周后要干的事,本周照样看得见。
|
||||||
「是否延期」是人工开关(delayed):更早周次录入、标了延期又没做完的,结转进当周视图。
|
「是否延期」是人工开关(delayed):更早周次录入、标了延期又没做完的,结转进当周视图。
|
||||||
录入整理在「每周重点工作」页,汇总在「工作看板」。
|
录入整理在「每周重点工作」页,汇总在「工作看板」。
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""人力资源库:人员维护 + 人员负载。
|
"""人力资源库:人员维护 + 人员负载。
|
||||||
|
|
||||||
项目 / 定开项目的「负责人」统一取这里的姓名,因此:
|
项目 / 定制项目的「负责人」统一取这里的姓名,因此:
|
||||||
· 改名时同步刷新已有项目的负责人字段;
|
· 改名时同步刷新已有项目的负责人字段;
|
||||||
· 被项目引用的人不允许直接删除,只能归档(归档后不再出现在下拉里)。
|
· 被项目引用的人不允许直接删除,只能归档(归档后不再出现在下拉里)。
|
||||||
"""
|
"""
|
||||||
|
|
@ -51,7 +51,7 @@ ALLOWED_PATCH = ("name", "group", "affiliation", "note", "sort_order", "archived
|
||||||
|
|
||||||
|
|
||||||
def _workload(db: Session) -> dict[str, dict]:
|
def _workload(db: Session) -> dict[str, dict]:
|
||||||
"""按姓名汇总在管项目 / 定开项目负载。"""
|
"""按姓名汇总在管项目 / 定制项目负载。"""
|
||||||
load: dict[str, dict] = {}
|
load: dict[str, dict] = {}
|
||||||
|
|
||||||
def slot(name: 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:
|
if (n1 or n2) and not force:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
400,
|
400,
|
||||||
f"「{m.name}」名下还有 {n1} 个在管项目 / {n2} 个定开项目。"
|
f"「{m.name}」名下还有 {n1} 个在管项目 / {n2} 个定制项目。"
|
||||||
f"离职人员建议直接「归档」(归档后不再出现在负责人下拉里,历史记录保留);"
|
f"离职人员建议直接「归档」(归档后不再出现在负责人下拉里,历史记录保留);"
|
||||||
f"确需删除请先转交这些项目。",
|
f"确需删除请先转交这些项目。",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""周看板:重点项目 + 定开项目的单周总览。
|
"""周看板:重点项目 + 定制项目的单周总览。
|
||||||
|
|
||||||
左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐:
|
左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐:
|
||||||
· 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目
|
· 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目
|
||||||
· 定开项目 = 打了 ★ 的定开项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」)
|
· 定制项目 = 打了 ★ 的定制项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」)
|
||||||
前端左栏只给一个入口,点开后在右栏按「本周新增 / 本周有变化」分组展示。
|
前端左栏只给一个入口,点开后在右栏按「本周新增 / 本周有变化」分组展示。
|
||||||
右栏点开某一条时再按需拉 /projects/{id} 或 /custom/projects/{id} 看全量历史。
|
右栏点开某一条时再按需拉 /projects/{id} 或 /custom/projects/{id} 看全量历史。
|
||||||
工作看板左栏的「本周工作重点」不在本接口里:前端并发取 /api/focus 后合并成一组。
|
工作看板左栏的「本周工作重点」不在本接口里:前端并发取 /api/focus 后合并成一组。
|
||||||
|
|
@ -108,7 +108,7 @@ def weekly(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 定开项目
|
# ---------------------------------------------------------------- 定制项目
|
||||||
cups_by_project: dict[int, list[CustomUpdate]] = defaultdict(list)
|
cups_by_project: dict[int, list[CustomUpdate]] = defaultdict(list)
|
||||||
for u in (
|
for u in (
|
||||||
db.query(CustomUpdate).filter(CustomUpdate.period_id.in_(period_ids)).order_by(CustomUpdate.id).all()
|
db.query(CustomUpdate).filter(CustomUpdate.period_id.in_(period_ids)).order_by(CustomUpdate.id).all()
|
||||||
|
|
@ -125,7 +125,7 @@ def weekly(
|
||||||
continue
|
continue
|
||||||
entry = (cp.entry_date or "").strip()
|
entry = (cp.entry_date or "").strip()
|
||||||
is_new = bool(scope_start and scope_end and entry and scope_start <= entry <= scope_end)
|
is_new = bool(scope_start and scope_end and entry and scope_start <= entry <= scope_end)
|
||||||
# 「本周新增 / 本周验收」与「定开项目 · 本周进展」同一口径(见 web/app.js 的
|
# 「本周新增 / 本周验收」与「定制项目 · 本周进展」同一口径(见 web/app.js 的
|
||||||
# customWeeklySections):新增按「下单时间」,验收按「更新时间 + 状态=已验收」。
|
# customWeeklySections):新增按「下单时间」,验收按「更新时间 + 状态=已验收」。
|
||||||
sign = (cp.sign_date or "").strip()[:10]
|
sign = (cp.sign_date or "").strip()[:10]
|
||||||
upd = (cp.update_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())
|
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)
|
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 = {
|
kpi = {
|
||||||
"key_total": key_all,
|
"key_total": key_all,
|
||||||
"key_updated": sum(1 for r in project_rows if r["is_key"] and r["has_update"]),
|
"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),
|
"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),
|
"revenue_delta": round(sum(r["period_revenue_delta"] for r in custom_rows), 2),
|
||||||
"risk_count": risks,
|
"risk_count": risks,
|
||||||
|
"at_risk_count": at_risk,
|
||||||
|
"blocked_count": blocked,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"periods": [p.to_dict() for p in periods],
|
"periods": [p.to_dict() for p in periods],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""老库补列:create_all 只会建新表,不会给已存在的表加字段。
|
"""老库补列:create_all 只会建新表,不会给已存在的表加字段。
|
||||||
|
|
||||||
模型新增可空字段后(如定开项目的验收时间 / 开票 / 回款),启动时自动
|
模型新增可空字段后(如定制项目的验收时间 / 开票 / 回款),启动时自动
|
||||||
`ALTER TABLE ... ADD COLUMN` 补齐,不需要手写迁移脚本。
|
`ALTER TABLE ... ADD COLUMN` 补齐,不需要手写迁移脚本。
|
||||||
|
|
||||||
用法:在 `Base.metadata.create_all()` 之后调用 `ensure_columns(engine, Base.metadata)`。
|
用法:在 `Base.metadata.create_all()` 之后调用 `ensure_columns(engine, Base.metadata)`。
|
||||||
|
|
|
||||||
BIN
data/board.db
BIN
data/board.db
Binary file not shown.
|
|
@ -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
|
||||||
|
|
@ -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
4
run.sh
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 部门关键任务追踪看板 · 启动脚本
|
# DMS管理平台 · 启动脚本
|
||||||
# 用法: ./run.sh 正常启动(默认开热更新)
|
# 用法: ./run.sh 正常启动(默认开热更新)
|
||||||
# PORT=8771 ./run.sh 换端口启动
|
# PORT=8771 ./run.sh 换端口启动
|
||||||
# DEV=0 ./run.sh 关闭热更新(改代码不自动重启、页面不自动刷新)
|
# DEV=0 ./run.sh 关闭热更新(改代码不自动重启、页面不自动刷新)
|
||||||
|
|
@ -16,7 +16,7 @@ PORT="${PORT:-8770}"
|
||||||
XLSX="${XLSX:-/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx}"
|
XLSX="${XLSX:-/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx}"
|
||||||
STAFF_XLSX="${STAFF_XLSX:-/Users/jiliu/WorkSpace/unissense/部门管理/软件开发部人员名单.xlsx}"
|
STAFF_XLSX="${STAFF_XLSX:-/Users/jiliu/WorkSpace/unissense/部门管理/软件开发部人员名单.xlsx}"
|
||||||
|
|
||||||
echo "==> 部门关键任务追踪看板"
|
echo "==> DMS管理平台"
|
||||||
|
|
||||||
# ---------- 1. 选择带依赖的 Python 解释器 ----------
|
# ---------- 1. 选择带依赖的 Python 解释器 ----------
|
||||||
CANDIDATES=(
|
CANDIDATES=(
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ CATEGORY_RULES = [
|
||||||
(("无人机", "智飞"), "智能装备"),
|
(("无人机", "智飞"), "智能装备"),
|
||||||
(("AI", "智能体", "Agent", "大模型"), "AI 能力"),
|
(("AI", "智能体", "Agent", "大模型"), "AI 能力"),
|
||||||
(("NEX", "云桌面", "云屏", "平台开发", "UniFlow"), "平台产品"),
|
(("NEX", "云桌面", "云屏", "平台开发", "UniFlow"), "平台产品"),
|
||||||
(("定制", "交付", "项目支撑"), "定开项目"),
|
(("定制", "交付", "项目支撑"), "定制项目"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ DEFAULT_CATEGORIES = [
|
||||||
("平台产品", 0),
|
("平台产品", 0),
|
||||||
("AI 能力", 1),
|
("AI 能力", 1),
|
||||||
("智能装备", 2),
|
("智能装备", 2),
|
||||||
("定开项目", 3),
|
("定制项目", 3),
|
||||||
("内部运营", 4),
|
("内部运营", 4),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
551
web/app.js
551
web/app.js
File diff suppressed because it is too large
Load Diff
|
|
@ -2,7 +2,7 @@
|
||||||
用法:Icon.svg("wallet") → 裸图标
|
用法:Icon.svg("wallet") → 裸图标
|
||||||
Icon.badge("wallet") → 图标 + 重点色底板
|
Icon.badge("wallet") → 图标 + 重点色底板
|
||||||
Icon.status("in_progress") → 执行状态图标
|
Icon.status("in_progress") → 执行状态图标
|
||||||
Icon.stage("signed") → 定开阶段图标
|
Icon.stage("signed") → 定制阶段图标
|
||||||
Icon.pipe("entry") → 交付环节图标
|
Icon.pipe("entry") → 交付环节图标
|
||||||
静态标记里写 <i data-icon="wallet"></i>,加载时自动替换成 SVG。 */
|
静态标记里写 <i data-icon="wallet"></i>,加载时自动替换成 SVG。 */
|
||||||
(function () {
|
(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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"],
|
["负责人", "users"], ["人员", "users"], ["人力", "users"], ["花名册", "users"], ["在册", "users"],
|
||||||
["区域", "layers"], ["归属", "layers"], ["分类", "pie"],
|
["区域", "layers"], ["归属", "layers"], ["分类", "pie"],
|
||||||
["明细", "list-checks"], ["待办", "list-checks"], ["本周新增", "inbox"], ["有变化", "refresh"],
|
["明细", "list-checks"], ["待办", "list-checks"], ["本周新增", "inbox"], ["有变化", "refresh"],
|
||||||
["在管项目", "layers"], ["定开项目", "package"], ["交付环节", "layers"], ["交付漏斗", "filter"],
|
["在管项目", "layers"], ["定制项目", "package"], ["交付环节", "layers"], ["交付漏斗", "filter"],
|
||||||
["交付", "package"], ["验收", "shield-check"], ["入库", "inbox"], ["签单", "pencil"],
|
["交付", "package"], ["验收", "shield-check"], ["入库", "inbox"], ["签单", "pencil"],
|
||||||
["阶段流转", "refresh"], ["阶段", "layers"], ["线索", "target"], ["开发", "code"],
|
["阶段流转", "refresh"], ["阶段", "layers"], ["线索", "target"], ["开发", "code"],
|
||||||
["重点项目", "star"], ["重点", "star"], ["周期反馈", "calendar-check"], ["周期管理", "calendar"],
|
["重点项目", "star"], ["重点", "star"], ["周期反馈", "calendar-check"], ["周期管理", "calendar"],
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>软件开发部 · 关键任务追踪看板</title>
|
<title>DMS管理平台</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -12,21 +12,25 @@
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-mark"><i data-icon="target"></i></div>
|
<div class="brand-mark"><i data-icon="target"></i></div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<strong>关键任务追踪看板</strong>
|
<strong>DMS管理平台</strong>
|
||||||
<span>软件开发部</span>
|
<span>软件开发部</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<nav class="nav" id="nav">
|
<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 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>自研产品</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>定制项目</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>每周工作</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>
|
<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="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>更新录入</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>设置</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>权限管理</button>
|
<button class="nav-item" data-view="rbac"><span class="ni" data-icon="shield-check"></span><span class="nl">权限管理</span></button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="side-user" id="sideUser"></div>
|
<div class="side-user" id="sideUser"></div>
|
||||||
|
|
@ -86,7 +90,7 @@
|
||||||
<aside class="auth-brand">
|
<aside class="auth-brand">
|
||||||
<div class="ab-top">
|
<div class="ab-top">
|
||||||
<div class="brand-mark"><i data-icon="target"></i></div>
|
<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>
|
||||||
<div class="ab-mid">
|
<div class="ab-mid">
|
||||||
<h2>进展、风险、计收<br>一张看板看完</h2>
|
<h2>进展、风险、计收<br>一张看板看完</h2>
|
||||||
|
|
@ -103,7 +107,7 @@
|
||||||
<h3>登录</h3>
|
<h3>登录</h3>
|
||||||
<p class="af-sub">使用部门分配的账号</p>
|
<p class="af-sub">使用部门分配的账号</p>
|
||||||
<label class="af-field"><span>账号</span>
|
<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>
|
<label class="af-field"><span>密码</span>
|
||||||
<input class="inp" id="authPass" type="password" autocomplete="current-password" placeholder="请输入密码" /></label>
|
<input class="inp" id="authPass" type="password" autocomplete="current-password" placeholder="请输入密码" /></label>
|
||||||
<div class="notice err" id="authErr" style="display:none"></div>
|
<div class="notice err" id="authErr" style="display:none"></div>
|
||||||
|
|
|
||||||
126
web/styles.css
126
web/styles.css
|
|
@ -76,8 +76,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
position: sticky; top: 0; height: 100vh;
|
position: sticky; top: 0; height: 100vh;
|
||||||
padding: 18px 14px;
|
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 {
|
.brand-mark {
|
||||||
width: 34px; height: 34px; border-radius: 10px; flex: none;
|
width: 34px; height: 34px; border-radius: 10px; flex: none;
|
||||||
background: var(--primary); color: #fff;
|
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 button.danger:hover { background: #fdf0f0; }
|
||||||
.user-menu .ic { width: 15px; height: 15px; }
|
.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;
|
.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%); }
|
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;
|
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); }
|
.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 {
|
.nav-arrow {
|
||||||
display: inline-grid; place-items: center;
|
display: inline-grid; place-items: center;
|
||||||
width: 30px; height: 30px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
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 { margin: 0; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||||
.card-head h4 .t, .card-head .period-hint, .card-head .switch { white-space: nowrap; }
|
.card-head h4 .t, .card-head .period-hint, .card-head .switch { white-space: nowrap; }
|
||||||
/* 统一计数 chip:卡头、分组标题共用一颗灰底小圆牌 */
|
/* 统一计数 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; }
|
.card-body { padding: 16px; }
|
||||||
|
|
||||||
.grid { display: grid; gap: 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-amber { --c: var(--amber); }
|
||||||
.kpi.accent-red { --c: var(--red); }
|
.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 ---------------- */
|
||||||
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
.chip {
|
.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 { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
|
||||||
.chip.active .ic { color: var(--primary); }
|
.chip.active .ic { color: var(--primary); }
|
||||||
|
|
||||||
/* 卡头加 chips-row:三个 tab 独占一行 header,筛选器 + 主功能按钮放同一行 */
|
/* 定制项目页签:卡片外的文件夹式页签,选中项压住卡片顶边,和下方内容区连成一块 */
|
||||||
.card-head.chips-row .head-chips { flex-basis: 100%; }
|
.ctabs { display: flex; align-items: flex-end; gap: 2px; padding: 0 14px; position: relative; z-index: 2; }
|
||||||
.card-head.chips-row .head-chips .cnt { margin-left: 4px; }
|
.ctab {
|
||||||
.card-head.chips-row .head-tools > * { flex: 0 1 auto; min-width: 0; }
|
border: 1px solid transparent; border-bottom: 0; background: transparent;
|
||||||
.card-head.chips-row .head-tools .btn { flex: 0 0 auto; }
|
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; }
|
.board-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||||||
.pcard {
|
.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 > 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; }
|
.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; }
|
.kanban { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; align-items: flex-start; }
|
||||||
.kcol {
|
.kcol {
|
||||||
flex: 0 0 236px; background: var(--panel); border: 1px solid var(--border);
|
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; }
|
table.tbl td.num, table.tbl th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.tbl-wrap { max-height: 620px; overflow: auto; }
|
.tbl-wrap { max-height: 620px; overflow: auto; }
|
||||||
.nowrap { white-space: nowrap; }
|
.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 {
|
.inp, .sel {
|
||||||
height: 32px; border: 1px solid var(--border); border-radius: 8px; padding: 0 9px; background: #fff; max-width: 100%;
|
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 { background: var(--red); border-color: var(--red); color: #fff; font-weight: 600; }
|
||||||
.btn.danger.solid:hover { background: #b42318; color: #fff; }
|
.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; }
|
.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 {
|
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);
|
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; }
|
.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; }
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue