Compare commits
No commits in common. "871a52ddb050113404cb1af4490833c3b8b4de7e" and "bcb3feb3bbcd29384d6bc4c371f5219eb6ebfcfe" have entirely different histories.
871a52ddb0
...
bcb3feb3bb
|
|
@ -1,23 +0,0 @@
|
||||||
# 构建上下文只要 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
|
|
||||||
12
.env.example
12
.env.example
|
|
@ -13,15 +13,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,4 @@
|
||||||
3. **表头排序**:`下单时间`(sign_date) / `订单金额`(amount, 数值) / `更新时间`(update_date) 三个表头可点击排序,`state.customSort = {key, dir}`;点不同列默认「降序」,再点同列切换升/降;表头显示 ▲/▼,卡片副标题显示当前排序。
|
3. **表头排序**:`下单时间`(sign_date) / `订单金额`(amount, 数值) / `更新时间`(update_date) 三个表头可点击排序,`state.customSort = {key, dir}`;点不同列默认「降序」,再点同列切换升/降;表头显示 ▲/▼,卡片副标题显示当前排序。
|
||||||
4. **新增「推动验收计划」筛选**:工具栏加 `#cAcceptPlan`(`state.customAcceptPlan`),选项取数据中实际出现过的值(Q3/Q4/27年/-- 等);导出也带 `accept_plan` 参数(后端 `export_custom` 新增 `accept_plan`)。
|
4. **新增「推动验收计划」筛选**:工具栏加 `#cAcceptPlan`(`state.customAcceptPlan`),选项取数据中实际出现过的值(Q3/Q4/27年/-- 等);导出也带 `accept_plan` 参数(后端 `export_custom` 新增 `accept_plan`)。
|
||||||
- `state` 新增 `customAcceptPlan` / `customSort`,均加入 `UI_FIELDS` 持久化。
|
- `state` 新增 `customAcceptPlan` / `customSort`,均加入 `UI_FIELDS` 持久化。
|
||||||
- 后端 `export.py` 改动 → 重启 uvicorn(PID 23436);`node --check` / `ast.parse` 通过。实测导出 `accept_plan=Q3` 生效。
|
- 后端 `export.py` 改动 → 重启 uvicorn(PID 23436);`node --check` / `ast.parse` 通过。实测导出 `accept_plan=Q3` 生效。**本轮未 push**。
|
||||||
|
|
||||||
## 第二次推送 develop-chezhiqi
|
|
||||||
- 提交 **07e1b0a**「定开项目:改名(华智下单/进行中)+ 表格排序与筛选 + 列序/按钮调整」(6 files changed, 106 insertions, 78 deletions):`web/app.js`、`app/models.py`、`app/routers/export.py`、`scripts/import_custom_ledger.py`、`data/board.db`、`.workbuddy/memory/2026-09-20.md`。
|
|
||||||
- 已 push 到 `origin/develop-chezhiqi`(`a77cd7b..07e1b0a`),本地与远端一致。
|
|
||||||
- **注意**:首次 `git push` 那次返回空输出、实际未推成功(`status -sb` 显示 ahead 1),重试后成功;以后 push 后要用 `git status -sb` / `git log origin/<branch>` 复核。
|
|
||||||
- 仍未提交:`data/board.db.bak-*` 本地备份(7 个,保持 untracked)。
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
# 2026-09-23
|
|
||||||
|
|
||||||
## 合并 main(V 0.1.1)到 develop-chezhiqi
|
|
||||||
|
|
||||||
- `git fetch`:**origin/main 从 e9854eb 前进到 `bcb3feb`「V 0.1.1」**(其祖先已包含 a77cd7b / 07e1b0a,即 develop-chezhiqi 的全部提交)。
|
|
||||||
- `develop-chezhiqi` 是 `origin/main` 的**祖先** → 直接 **fast-forward 合并**:`git merge origin/main`。分支现指向 `bcb3feb`,**领先 origin/develop-chezhiqi 1 个提交(未 push)**。
|
|
||||||
- 合并前已备份数据库:`data/board.db.bak-before-merge-main-<ts>`。
|
|
||||||
- **main 带来的新内容**:登录与权限 —— `app/security.py`、`app/routers/auth.py`、`app/routers/rbac.py`;`users` / `roles` / `user_sessions` 表;`app/config.py`(新增 `ADMIN_PASSWORD`);`app/main.py`(启动时 `security.ensure_seed`);models/schemas 扩展(+83 行等);`web/**`(登录页、按角色显示菜单);`run.sh`、`README.md`、`data/board.db`。
|
|
||||||
- **我之前的改动全部保留**:`web/app.js` 里 项目总览 / 推动验收计划 / 进行中 / 华智下单 / 年度任务 / 新增项目 / data-sort 均在;DB 迁移也在 —— origin:华智下单 84 / 存量项目 27 / 汇智直签 10 / 提前交付 2;status:进行中 10(原交付中);`update_date` 空 111 / 有值 12;共 **123** 条。
|
|
||||||
- **无需新依赖**:`security.py` 仅用 hashlib / hmac / json / secrets 等标准库。
|
|
||||||
- 已启动服务:uvicorn(PID 30036),`/health` ok。
|
|
||||||
- **现在需要登录**(重要):未登录访问 `/api/custom/*` 返回 **401**。默认管理员 **admin / admin123**(`app/config.py` 里 `ADMIN_PASSWORD` 默认值,可用 `.env` 覆盖),实测登录成功(角色「管理员」,menus 全 rw);另有 `user`(部门成员,部分菜单只读)。
|
|
||||||
- 未提交:`.workbuddy/memory/2026-09-20.md`(本地日志改动)。未跟踪:`data/board.db.bak-*`(本地备份)。
|
|
||||||
- 注意:**本地 `main` 分支仍是旧的 e9854eb**(我未改动它);本次合并用的是 `origin/main`。
|
|
||||||
|
|
||||||
## 修复定开 3 个问题
|
|
||||||
|
|
||||||
1. **统计不随项目状态变更**(排查结论):
|
|
||||||
- 不是刷新/缓存问题 —— 实测 PATCH 改状态后 GET 立刻反映;前端 `afterWrite() → refresh() → renderCustom()` 会重新取数,`/api/custom/projects` 也没有缓存头。
|
|
||||||
- 真因是**统计口径**:验收只认「验收时间」`accept_date`,改「项目状态」不会移动任何数字。
|
|
||||||
- **改口径**:验收**先按「项目状态 = 已验收」过滤**(状态一改统计立即同步),**季度归属再按「验收时间」**`accept_date`,**验收时间为空时算「本季度」**。工作看板里同口径的「本季度/本年验收」(`web/app.js` 约 1186 行)也同步改了。
|
|
||||||
- 影响数字:本年验收 65 → **68**、本季度验收 22 → **25**、本季金额 → 101.54 万(多出的 3 条是「状态=已验收但没有验收时间」的项目)。实测:把某项目改成已验收 → 本季 25→26、本年 68→69。
|
|
||||||
2. **「本周」= 9月第4周、默认展示本周**:`periods` 表原来只到「9月第3周」,`/api/periods` 的 `today_id` 为 `null`,前端只能退回最后一个周期(第3周)。
|
|
||||||
3. **周次按时间自动创建**:`app/routers/board.py` 的 `list_periods` 里加了一句 —— `_week_period(db, *constants.week_of_date(date.today()))`(复用 `focus._week_period`),每次取周期列表时自动补上「今天所在的这一周」。实测首次请求即生成 **9月第4周(id 53,2026-09-21 ~ 09-27)**,`today_id = 53`,无需手动新建。
|
|
||||||
|
|
||||||
- 重启 uvicorn(PID 26184);`node --check` / `ast.parse` / `/health` 均通过。
|
|
||||||
|
|
||||||
## 推送 develop-chezhiqi
|
|
||||||
- 提交 **cd37ea4**「定开:统计口径按项目状态同步 + 周次按时间自动创建(合并 main V 0.1.1)」(6 files changed, 53 insertions, 7 deletions)。
|
|
||||||
- 已 push:`07e1b0a..cd37ea4 develop-chezhiqi -> develop-chezhiqi`(此次同时把 fast-forward 的 `bcb3feb V 0.1.1` 一起带上远端);`git status -sb` 复核本地与远端一致。
|
|
||||||
- 仍未提交:`data/board.db.bak-*` 本地备份(8 个,保持 untracked)。
|
|
||||||
|
|
||||||
## 验收口径回退为「严格按验收时间」(用户确认)
|
|
||||||
- 用户在我提出「严格按验收时间 vs 状态联动」后回复「确认」→ 按**严格口径**改回:
|
|
||||||
- `web/app.js` 定开 `renderCustomList`:`accRows = allRows.filter(p => qOf(p.accept_date))`(有验收时间才计入);本季度 = `accept_date == 当前季度`;本年 = 有验收时间。
|
|
||||||
- 工作看板同口径(`customAll`)一起改回。
|
|
||||||
- 去掉了上一版的「项目状态=已验收」门槛与「验收时间为空算本季度」的兜底。
|
|
||||||
- 数字:改成严格口径后 **本年验收 68 / 本季度验收(Q3) 25 / 本季金额 101.54 万** —— 与改之前一致(因为用户已把 3 条缺验收时间的项目补上了验收时间,两种口径现在结果相同)。
|
|
||||||
- 副作用:**改「项目状态」不会影响验收统计**(验收只看验收时间)——已在回复中说明。
|
|
||||||
|
|
||||||
## 全部推送(含数据库备份)
|
|
||||||
- 用户要求「本地代码全部推送」;当时 `develop-chezhiqi` 已与远端一致(无待推提交),只剩 8 个未跟踪的 `data/board.db.bak-*`。
|
|
||||||
- 询问后用户选择「一并提交推送」→ 提交 **839babb**「chore(data): 归档本地数据库备份 data/board.db.bak-*」(8 个文件,约 3.25MB),push `0cf172f..839babb`。
|
|
||||||
- 结果:`git status` **完全干净**(无未跟踪文件),`develop-chezhiqi` 与 `origin/develop-chezhiqi` 一致。
|
|
||||||
- 提示:本地 `main` 分支仍停在旧的 e9854eb(落后 origin/main 3 个提交),未动。
|
|
||||||
|
|
@ -45,11 +45,6 @@
|
||||||
|
|
||||||
导入「市场责任人」等销售侧人名**不走花名册校验**,不要往 `staff` 里塞。
|
导入「市场责任人」等销售侧人名**不走花名册校验**,不要往 `staff` 里塞。
|
||||||
|
|
||||||
## 登录与权限(2026-09-23 合并 main V 0.1.1 起)
|
|
||||||
|
|
||||||
- 全站需**登录**:未登录访问 `/api/*` 返回 **401**。默认管理员 **admin / admin123**(`app/config.py` 的 `ADMIN_PASSWORD` 默认值,可用 `.env` 覆盖);另有 `user`(部门成员)。
|
|
||||||
- 权限模型:`roles.menus` 是 JSON(board/custom/focus/intake/projects/rbac/settings/staff,值 `rw` / `ro`);内置角色 管理员 / 部门成员 / 只读访客。代码:`app/security.py`(hashlib+hmac 手写,无新依赖)、`app/routers/auth.py`、`app/routers/rbac.py`;表 `users` / `roles` / `user_sessions`。
|
|
||||||
|
|
||||||
## 术语(2026-09-16 起)
|
## 术语(2026-09-16 起)
|
||||||
|
|
||||||
模块③ 的正式称呼是 **「定开项目」**(旧称「定制交付」),界面统一用「定开」:定开项目 / 定开项目明细 / 定开合同额 / 定开记录 / 定开动态 / 定开待计收。`categories` 表里也有一行分类叫「定开项目」。
|
模块③ 的正式称呼是 **「定开项目」**(旧称「定制交付」),界面统一用「定开」:定开项目 / 定开项目明细 / 定开合同额 / 定开记录 / 定开动态 / 定开待计收。`categories` 表里也有一行分类叫「定开项目」。
|
||||||
|
|
|
||||||
48
Dockerfile
48
Dockerfile
|
|
@ -1,48 +0,0 @@
|
||||||
# 部门关键任务追踪看板 · 运行镜像
|
|
||||||
#
|
|
||||||
# 构建:docker build -t unis-board . (日常部署建议用 ./deploy.sh up)
|
|
||||||
# 跑: docker run -d -p 8770:8770 -v $PWD/data:/app/data unis-board
|
|
||||||
#
|
|
||||||
# 镜像里只有依赖和代码,数据库/附件都在 data/,靠 bind mount 挂进来。
|
|
||||||
# data/ 已被 .dockerignore 排除,不会把本机 board.db 打进镜像。
|
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
||||||
|
|
||||||
# 业务代码用 datetime.now() 取本地时间(周期/周次/入库时间/更新时间),
|
|
||||||
# 容器默认 UTC 会让这些日期整体差 8 小时,所以必须显式设时区(compose 里的 TZ 会覆盖这里)。
|
|
||||||
ENV TZ=Asia/Shanghai
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 依赖单独一层:改业务代码不触发重装依赖
|
|
||||||
COPY requirements.txt ./
|
|
||||||
# 国内网络直连 PyPI 容易超时,构建时换源即可:
|
|
||||||
# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up
|
|
||||||
ARG PIP_INDEX_URL=https://pypi.org/simple
|
|
||||||
RUN pip install --retries 5 --timeout 60 -i "$PIP_INDEX_URL" -r requirements.txt
|
|
||||||
# 用 MySQL 部署时放开下面这行(与 requirements.txt 的注释一致)
|
|
||||||
# RUN pip install "pymysql>=1.1" "cryptography>=42.0"
|
|
||||||
|
|
||||||
COPY app ./app
|
|
||||||
COPY web ./web
|
|
||||||
COPY scripts ./scripts
|
|
||||||
|
|
||||||
# 上传件 / 导出件目录;SQLite 库同样落在这里,实际内容由 volume 提供
|
|
||||||
RUN mkdir -p data/uploads data/exports
|
|
||||||
|
|
||||||
# 容器内固定监听 8770,对外端口由 compose 的 HOST_PORT 控制。
|
|
||||||
# 镜像默认以 root 运行:macOS 上 bind mount 只有 root 写 data/ 最省心。
|
|
||||||
# Linux 上想换成非特权账号,在 compose 里加 user: "1000:1000"
|
|
||||||
#(前提是 ./data 的属主也是这个 uid,否则 SQLite 写不进去)。
|
|
||||||
ENV PORT=8770
|
|
||||||
EXPOSE 8770
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
||||||
CMD python -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('PORT','8770'),timeout=3).read()"
|
|
||||||
|
|
||||||
# 首次启动建表 + 预置分类(幂等,不覆盖已有数据);内置角色与管理员账号由 app/main.py 启动时补齐
|
|
||||||
CMD ["sh", "-c", "python scripts/init_db.py && exec python -m uvicorn app.main:app --host 0.0.0.0 --port \"${PORT:-8770}\""]
|
|
||||||
134
README.md
134
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# DMS管理平台
|
# 软件开发部 · 关键任务追踪看板
|
||||||
|
|
||||||
基于《软件开发部管理工作执行表.xlsx》构建的 DMS 管理平台。
|
基于《软件开发部管理工作执行表.xlsx》构建的部门关键任务追踪系统。
|
||||||
|
|
||||||
侧栏是**四个业务模块 + 三个支撑页**:
|
侧栏是**四个业务模块 + 三个支撑页**:
|
||||||
|
|
||||||
|
|
@ -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,7 +73,6 @@ 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 属主,后续写入会失败。
|
||||||
|
|
||||||
|
|
@ -95,61 +94,6 @@ 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`,顺带检查 docker 是否在跑、`.env` 与 `data/` 是否存在 |
|
|
||||||
| `.dockerignore` | 保证 `data/`、`.env`、`.git` 不会被烤进镜像 |
|
|
||||||
|
|
||||||
| 场景 | 命令 |
|
|
||||||
|---|---|
|
|
||||||
| 改完代码上线 | `./deploy.sh update`(重建镜像并替换容器) |
|
|
||||||
| 改了 `.env`(AI Key / 端口) | `./deploy.sh restart` |
|
|
||||||
| 看日志排错 | `./deploy.sh logs` |
|
|
||||||
| 看状态与健康检查 | `./deploy.sh status` |
|
|
||||||
| 停止(数据保留) | `./deploy.sh down` |
|
|
||||||
| 换对外端口 | `.env` 里 `HOST_PORT=8080`,再 `./deploy.sh up` |
|
|
||||||
| 裸 compose 命令 | `docker compose up -d --build` / `docker compose down` |
|
|
||||||
| 构建时 pip 下载超时 | `PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up`(也可写进 `.env`) |
|
|
||||||
| 把现有数据带到新机器 | `./deploy.sh backup` → 拷到目标机 `data/board.db` → `./deploy.sh up` |
|
|
||||||
|
|
||||||
几个坑:
|
|
||||||
|
|
||||||
- **数据只落在 `./data`**:重建镜像、`down` 再 `up` 都不动数据;备份依旧是拷走 `data/board.db`。
|
|
||||||
- **时区必须设**:容器默认 UTC,而周期/周次/入库时间都按本地日期算,所以 compose 固定传 `TZ=Asia/Shanghai`,别删。
|
|
||||||
- **容器内端口固定 8770**:`.env` 的 `PORT` 是本机 `run.sh` 用的,compose 里覆盖成 8770;要换端口只改 `HOST_PORT`。
|
|
||||||
- **别和 `run.sh` 同时跑**:一是 SQLite 是文件库,同一份 `board.db` 不要同时被本机进程和容器读写;二是本机进程绑的是 `127.0.0.1:8770`,比 Docker 发布的 `*:8770` 更精确,浏览器和 `curl 127.0.0.1:8770` 会全打到本机服务上(症状:`/health` 里 `dev` 是 `true`,而容器是 `false`)。两者要共存就给容器换端口:`HOST_PORT=8899 ./deploy.sh up`。
|
|
||||||
- **换 MySQL**:放开 `Dockerfile` 里 `pymysql` 那行重新 build,再把 `.env` 的 `DATABASE_URL` 指过去(见「迁移到 MySQL」)。
|
|
||||||
|
|
||||||
### 带着现有数据部署
|
|
||||||
|
|
||||||
镜像里**故意不含数据**(`.dockerignore` 排除了 `data/`),容器读的是宿主机挂载进来的 `./data/board.db`,所以:
|
|
||||||
|
|
||||||
- **就在本机这个目录部署** → 什么都不用做,`./deploy.sh up` 起来读的就是当前这份库;连 `user_sessions` 里的登录态都延续,别人不用重新登录。
|
|
||||||
- **换一台机器部署** → 先把数据取出来,落到目标机的 `./data/board.db`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh backup # 一致性快照 → data/backups/board-YYYYmmdd_HHMMSS.db
|
|
||||||
scp data/backups/board-*.db user@host:/srv/unis_manager/data/board.db
|
|
||||||
ssh user@host 'cd /srv/unis_manager && ./deploy.sh up'
|
|
||||||
```
|
|
||||||
|
|
||||||
> - `data/board.db` 本身在 git 里,`git clone` 也会带一份,但那是**最后一次提交**的状态;要「当前最新」请用 `./deploy.sh backup` 的快照(走 SQLite 备份 API,本机服务正在写也能拿到一致的库)。
|
|
||||||
> - 启动时的 `init_db.py` 和 `ensure_seed` 只会**补**缺失的表 / 分类 / 内置角色,`users` 非空就不再创建 `admin`,不会覆盖或清空任何数据。实测:拿当前生产库快照起容器,启动前后逐表行数完全一致,123 个定制项目、22 名人员、老 token 全部照常可用。
|
|
||||||
> - 目标机上的 `./data` 属主要和运行容器的人对得上(compose 以 root 跑则无此顾虑);SQLite 是文件库,同一份库别让两个实例同时写。
|
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
### 1. 工作看板
|
### 1. 工作看板
|
||||||
|
|
@ -158,15 +102,15 @@ ssh user@host 'cd /srv/unis_manager && ./deploy.sh up'
|
||||||
|
|
||||||
| 区域 | 内容 |
|
| 区域 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 顶部 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. 项目管理
|
||||||
|
|
||||||
|
|
@ -183,12 +127,12 @@ ssh user@host 'cd /srv/unis_manager && ./deploy.sh up'
|
||||||
| 操作 | 说明 |
|
| 操作 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → AI 整理 → 确认写入),抽屉里不另做一套表单,避免两条路写出两份数据。基础信息的按钮在标题栏、附表的更新按钮在附表上方,两类按钮不在同一个位置混着放;编辑与新增档的报错信息显示在正文最上方;周期反馈上方的 按周/按月 + 本周/近 4 周/… 标成「查看范围(只读筛选)」,只翻看不改数据 |
|
| **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → AI 整理 → 确认写入),抽屉里不另做一套表单,避免两条路写出两份数据。基础信息的按钮在标题栏、附表的更新按钮在附表上方,两类按钮不在同一个位置混着放;编辑与新增档的报错信息显示在正文最上方;周期反馈上方的 按周/按月 + 本周/近 4 周/… 标成「查看范围(只读筛选)」,只翻看不改数据 |
|
||||||
| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定制项目同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 |
|
| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定开项目同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 |
|
||||||
| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定制项目只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定制项目的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定制项目按交付负责人命中;团队成员不参与该筛选 |
|
| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定开项目只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定开项目的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定开项目按交付负责人命中;团队成员不参与该筛选 |
|
||||||
| **归档** | 项目从看板与列表默认视图中隐藏,数据完整保留;勾选「含归档」可查看,随时可恢复 |
|
| **归档** | 项目从看板与列表默认视图中隐藏,数据完整保留;勾选「含归档」可查看,随时可恢复 |
|
||||||
| **删除** | 高危操作:弹窗列出将连带删除的子任务数与执行记录数,并且必须**逐字输入项目名称**,「永久删除」按钮才会点亮。**删除后不可恢复**,仅想隐藏请用归档 |
|
| **删除** | 高危操作:弹窗列出将连带删除的子任务数与执行记录数,并且必须**逐字输入项目名称**,「永久删除」按钮才会点亮。**删除后不可恢复**,仅想隐藏请用归档 |
|
||||||
|
|
||||||
> 定制项目的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。
|
> 定开项目的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。
|
||||||
|
|
||||||
#### 项目合并
|
#### 项目合并
|
||||||
|
|
||||||
|
|
@ -205,7 +149,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. 定开项目
|
||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -213,7 +157,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
|
||||||
| **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 |
|
| **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 |
|
||||||
| **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 |
|
| **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 |
|
||||||
| **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 |
|
| **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 |
|
||||||
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定制项目并带上项目与周期)留在附表标题下 |
|
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定开项目并带上项目与周期)留在附表标题下 |
|
||||||
|
|
||||||
### 4. 每周重点工作
|
### 4. 每周重点工作
|
||||||
|
|
||||||
|
|
@ -225,7 +169,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「本周重点工作」读的就是这张表;看板右栏点条目给只读详情,改内容走抽屉 |
|
||||||
|
|
||||||
|
|
@ -239,12 +183,12 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
|
||||||
| 区块 | 说明 |
|
| 区块 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) |
|
| 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) |
|
||||||
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定制项目数、定制合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
|
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定开项目数、定开合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
|
||||||
| 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 |
|
| 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 |
|
||||||
| 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `constants.STAFF_GROUPS` / `STAFF_AFFILIATIONS` |
|
| 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `constants.STAFF_GROUPS` / `STAFF_AFFILIATIONS` |
|
||||||
| 改名 | 走「编辑」改姓名时,**名下项目与定制项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 |
|
| 改名 | 走「编辑」改姓名时,**名下项目与定开项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 |
|
||||||
| 归档 / 恢复 | 归档后不再出现在「负责人」下拉里,但历史项目仍完整保留其负责人;随时可恢复。离职人员用归档,不要用删除 |
|
| 归档 / 恢复 | 归档后不再出现在「负责人」下拉里,但历史项目仍完整保留其负责人;随时可恢复。离职人员用归档,不要用删除 |
|
||||||
| 删除 | 名下还挂着项目或定制项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 |
|
| 删除 | 名下还挂着项目或定开项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 |
|
||||||
|
|
||||||
导入名单(表头按名称匹配:序号 / 姓名 / 归属小组 / 人员归属 / 说明):
|
导入名单(表头按名称匹配:序号 / 姓名 / 归属小组 / 人员归属 / 说明):
|
||||||
|
|
||||||
|
|
@ -255,14 +199,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` 下拉,只有团队成员用这套名单编辑器。
|
||||||
|
|
||||||
### 入库时间
|
### 入库时间
|
||||||
|
|
||||||
定制项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**:
|
定开项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**:
|
||||||
|
|
||||||
- 优先取原始《项目明细表》台账中该项目所在的周(该列是稀疏填写的,导入时按周分组向下继承)
|
- 优先取原始《项目明细表》台账中该项目所在的周(该列是稀疏填写的,导入时按周分组向下继承)
|
||||||
- 台账没有的,取《周工作情况总结》「定制开发」块中该项目首次出现的周
|
- 台账没有的,取《周工作情况总结》「定制开发」块中该项目首次出现的周
|
||||||
|
|
@ -309,18 +253,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 带表头样式、冻结首行、自动筛选,金额列按千分位格式;文件名含导出日期。
|
||||||
|
|
||||||
## 收入确认口径
|
## 收入确认口径
|
||||||
|
|
||||||
定制项目按**完工百分比法**确认收入:
|
定开项目按**完工百分比法**确认收入:
|
||||||
|
|
||||||
| 阶段 | 确认比例 |
|
| 阶段 | 确认比例 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -336,7 +280,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()`,改一处即可全局生效
|
||||||
|
|
||||||
|
|
@ -349,10 +293,10 @@ Period(周期: 年/月/周, sort_key)
|
||||||
├── Task(子任务)
|
├── Task(子任务)
|
||||||
└── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文)
|
└── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文)
|
||||||
|
|
||||||
# 定制项目(独立)
|
# 定开项目(独立)
|
||||||
CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例,
|
CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例,
|
||||||
已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档)
|
已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档)
|
||||||
# 定制项目不做多负责人:other_owners 列保留在库里但不再读写
|
# 定开项目不做多负责人:other_owners 列保留在库里但不再读写
|
||||||
└── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步)
|
└── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步)
|
||||||
|
|
||||||
# 每周重点工作(独立:条目不是项目,不挂子任务与执行记录)
|
# 每周重点工作(独立:条目不是项目,不挂子任务与执行记录)
|
||||||
|
|
@ -375,7 +319,7 @@ LedgerItem(客户项目台账) # 只剩模型与 Excel 导入解析,界面
|
||||||
两套体系各有独立提示词:
|
两套体系各有独立提示词:
|
||||||
|
|
||||||
- **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步
|
- **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步
|
||||||
- **定制项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
|
- **定开项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
|
||||||
- **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态)
|
- **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态)
|
||||||
|
|
||||||
- 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
|
- 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
|
||||||
|
|
@ -403,18 +347,14 @@ 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 速览
|
||||||
|
|
@ -427,7 +367,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)
|
||||||
|
|
@ -435,7 +375,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}
|
||||||
|
|
|
||||||
|
|
@ -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="DMS管理平台", version="1.0.0")
|
app = FastAPI(title="部门关键任务追踪看板", 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="DMS管理平台 API", dependencies=[Depends(security.guard)])
|
api = FastAPI(title="部门关键任务追踪看板 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):
|
||||||
"""人力资源库成员。
|
"""人力资源库成员。
|
||||||
|
|
||||||
项目管理 / 定制项目的负责人一律从这里选取;归档后不再出现在下拉里,
|
项目管理 / 定开项目的负责人一律从这里选取;归档后不再出现在下拉里,
|
||||||
历史记录中的人名保持不变。
|
历史记录中的人名保持不变。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,6 @@ def resolve_period_ids(
|
||||||
@router.get("/periods")
|
@router.get("/periods")
|
||||||
def list_periods(db: Session = Depends(get_db)):
|
def list_periods(db: Session = Depends(get_db)):
|
||||||
from ..models import CustomUpdate
|
from ..models import CustomUpdate
|
||||||
from .focus import _week_period
|
|
||||||
|
|
||||||
# 周期按时间自动创建:确保「今天所在的这一周」一定存在,不再需要手动新建第几周。
|
|
||||||
# (_week_period 已用于「每周重点工作」,没有则补一条,口径与 week_of_date 一致)
|
|
||||||
_week_period(db, *constants.week_of_date(date.today()))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
weeks = db.query(Period).order_by(Period.sort_key).all()
|
weeks = db.query(Period).order_by(Period.sort_key).all()
|
||||||
uc = dict(
|
uc = dict(
|
||||||
|
|
@ -170,7 +164,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,9 +470,6 @@ 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,13 +42,12 @@ 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)$"),
|
||||||
|
|
@ -65,15 +64,13 @@ 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", "下单时间", "办事处", "行业",
|
||||||
"市场下单人天", "订单金额(元)", "市场责任人", "项目状态",
|
"市场下单人天", "订单金额(元)", "市场责任人", "项目状态",
|
||||||
|
|
@ -113,14 +110,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")
|
||||||
|
|
@ -161,14 +158,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, "定开项目周期记录")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 常规项目
|
# ---------------------------------------------------------------- 常规项目
|
||||||
|
|
@ -299,7 +296,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,9 +182,6 @@ 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"]),
|
||||||
|
|
@ -198,8 +195,6 @@ 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
118
deploy.sh
118
deploy.sh
|
|
@ -1,118 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# 部门关键任务追踪看板 · Docker 部署脚本
|
|
||||||
# 用法: ./deploy.sh up 构建镜像并后台启动(默认命令)
|
|
||||||
# ./deploy.sh update 改完代码后重建镜像并替换容器
|
|
||||||
# ./deploy.sh restart 重启容器(不换镜像)
|
|
||||||
# ./deploy.sh down 停止并删除容器,数据留在 ./data
|
|
||||||
# ./deploy.sh logs 跟踪日志(Ctrl+C 退出)
|
|
||||||
# ./deploy.sh backup 导出数据库快照(部署到新机器时带走数据)
|
|
||||||
# ./deploy.sh status 容器状态 + 健康检查 + 访问地址
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
CONTAINER=unis-board
|
|
||||||
|
|
||||||
# ---------- 0. 前置检查 ----------
|
|
||||||
command -v docker >/dev/null 2>&1 || { echo "!! 没有 docker 命令,请先安装 Docker / Docker Desktop" >&2; exit 1; }
|
|
||||||
docker info >/dev/null 2>&1 || { echo "!! docker 守护进程没起来(Docker Desktop 是否没运行?)" >&2; exit 1; }
|
|
||||||
|
|
||||||
if docker compose version >/dev/null 2>&1; then
|
|
||||||
COMPOSE=(docker compose)
|
|
||||||
elif command -v docker-compose >/dev/null 2>&1; then
|
|
||||||
COMPOSE=(docker-compose)
|
|
||||||
else
|
|
||||||
echo "!! 没找到 docker compose,也没找到 docker-compose" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 对外端口:命令行环境变量 > .env 里的 HOST_PORT > 8770
|
|
||||||
if [ -z "${HOST_PORT:-}" ] && [ -f .env ]; then
|
|
||||||
HOST_PORT="$(sed -n 's/^HOST_PORT=//p' .env | tail -1)"
|
|
||||||
fi
|
|
||||||
HOST_PORT="${HOST_PORT:-8770}"
|
|
||||||
URL="http://127.0.0.1:${HOST_PORT}"
|
|
||||||
|
|
||||||
# ---------- 1. 准备 .env 与 data 目录 ----------
|
|
||||||
prepare() {
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
cp .env.example .env
|
|
||||||
echo "==> 已从 .env.example 生成 .env(AI Key、管理员账号密码请按需修改,改完 restart 生效)"
|
|
||||||
fi
|
|
||||||
# 先建好目录,免得 Docker 用 root 建出来盖掉宿主机权限
|
|
||||||
mkdir -p data/uploads data/exports
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------- 2. 等服务就绪 ----------
|
|
||||||
wait_ready() {
|
|
||||||
echo -n "==> 等待服务就绪 "
|
|
||||||
for _ in $(seq 1 30); do
|
|
||||||
state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER" 2>/dev/null || echo missing)"
|
|
||||||
case "$state" in
|
|
||||||
healthy) echo; echo "==> 已就绪:$URL"; return 0 ;;
|
|
||||||
unhealthy) echo; echo "!! 容器不健康,执行 ./deploy.sh logs 看原因" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
echo -n "."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo; echo "!! 等待超时(当前状态:$state),执行 ./deploy.sh logs 看原因" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------- 3. 分发命令 ----------
|
|
||||||
case "${1:-up}" in
|
|
||||||
up|start)
|
|
||||||
first_run=0
|
|
||||||
[ -f data/board.db ] || first_run=1
|
|
||||||
prepare
|
|
||||||
"${COMPOSE[@]}" up -d --build
|
|
||||||
wait_ready
|
|
||||||
echo " API 文档:$URL/api/docs"
|
|
||||||
if [ "$first_run" = "1" ]; then
|
|
||||||
echo " 首次启动:管理员账号取自 .env 的 ADMIN_USERNAME/ADMIN_PASSWORD(默认 admin / admin123,请登录后立刻改密码)"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
update)
|
|
||||||
prepare
|
|
||||||
"${COMPOSE[@]}" up -d --build --force-recreate
|
|
||||||
wait_ready
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
"${COMPOSE[@]}" restart
|
|
||||||
wait_ready
|
|
||||||
;;
|
|
||||||
down|stop)
|
|
||||||
"${COMPOSE[@]}" down
|
|
||||||
echo "==> 已停止(数据仍在 ./data,要备份请复制 data/board.db)"
|
|
||||||
;;
|
|
||||||
logs)
|
|
||||||
exec "${COMPOSE[@]}" logs -f --tail=100
|
|
||||||
;;
|
|
||||||
backup)
|
|
||||||
# 用 SQLite 备份 API 取一致性快照:服务正在写也不会拷到半截页
|
|
||||||
[ -f data/board.db ] || { echo "!! 没找到 data/board.db(若已换 MySQL,请改用 mysqldump)" >&2; exit 1; }
|
|
||||||
PYBIN="$(command -v python3 || command -v python || true)"
|
|
||||||
[ -n "$PYBIN" ] || { echo "!! 备份需要 python3(只拷库,不用装项目依赖)" >&2; exit 1; }
|
|
||||||
mkdir -p data/backups
|
|
||||||
OUT="data/backups/board-$(date +%Y%m%d_%H%M%S).db"
|
|
||||||
"$PYBIN" - "$OUT" <<'PYEOF'
|
|
||||||
import os, sqlite3, sys
|
|
||||||
src = sqlite3.connect("data/board.db")
|
|
||||||
dst = sqlite3.connect(sys.argv[1])
|
|
||||||
with dst:
|
|
||||||
src.backup(dst)
|
|
||||||
print(f"[ok] 已导出 {sys.argv[1]}({os.path.getsize(sys.argv[1]) // 1024} KB)")
|
|
||||||
PYEOF
|
|
||||||
echo "==> 部署到新机器:把这个文件放到目标机的 data/board.db,再执行 ./deploy.sh up"
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
"${COMPOSE[@]}" ps
|
|
||||||
echo -n "==> /health:"
|
|
||||||
curl -s --max-time 3 "$URL/health" 2>/dev/null || echo " 无响应(容器未启动?./deploy.sh up)"
|
|
||||||
echo
|
|
||||||
echo "==> 访问地址:$URL"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
sed -n '3,9p' "$0"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# 部门关键任务追踪看板 · 部署编排
|
|
||||||
#
|
|
||||||
# 常用命令(推荐用封装脚本,会顺带检查 docker/.env/data 目录并等健康检查):
|
|
||||||
# ./deploy.sh up 构建 + 后台启动
|
|
||||||
# ./deploy.sh update 改完代码重建并替换容器
|
|
||||||
# ./deploy.sh logs # 看日志
|
|
||||||
# ./deploy.sh down # 停止(数据留在 ./data)
|
|
||||||
#
|
|
||||||
# 等价的裸 compose 命令:docker compose up -d --build
|
|
||||||
|
|
||||||
services:
|
|
||||||
board:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
# 传给 Dockerfile 的 pip 源,默认官方;国内网络可在 .env 里设
|
|
||||||
# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.org/simple}"
|
|
||||||
image: unis-board:latest
|
|
||||||
container_name: unis-board
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# AI 整理参数、管理员账号等都来自 .env(compose 会自动读同目录的 .env 做变量替换)
|
|
||||||
env_file: .env
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# 容器内端口固定 8770,跟 EXPOSE/健康检查对齐;要换对外端口请改 HOST_PORT
|
|
||||||
PORT: "8770"
|
|
||||||
# 容器里不开热加载(uvicorn --reload 会白耗 CPU,且代码在镜像内不可改)
|
|
||||||
DEV: "0"
|
|
||||||
# 覆盖宿主机的 TZ:周期/周次/入库时间都按这个时区计算
|
|
||||||
TZ: "${TZ:-Asia/Shanghai}"
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- "${HOST_PORT:-8770}:8770"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# SQLite 库、上传件、导出件都落在宿主机的 ./data,升级镜像不动数据
|
|
||||||
- ./data:/app/data
|
|
||||||
|
|
||||||
# Linux 上想让容器以普通用户跑(配合 ./data 的属主):
|
|
||||||
# user: "1000:1000"
|
|
||||||
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 "==> DMS管理平台"
|
echo "==> 部门关键任务追踪看板"
|
||||||
|
|
||||||
# ---------- 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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
559
web/app.js
559
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,8 +17,6 @@
|
||||||
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"/>',
|
||||||
|
|
@ -135,7 +133,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>DMS管理平台</title>
|
<title>软件开发部 · 关键任务追踪看板</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -12,25 +12,21 @@
|
||||||
<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>DMS管理平台</strong>
|
<strong>关键任务追踪看板</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><span class="nl">工作看板</span></button>
|
<button class="nav-item active" data-view="board"><span class="ni" data-icon="board"></span>工作看板</button>
|
||||||
<button class="nav-item" data-view="projects"><span class="ni" data-icon="list-checks"></span><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="custom"><span class="ni" data-icon="package"></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="focus"><span class="ni" data-icon="target"></span><span class="nl">每周工作</span></button>
|
<button class="nav-item" data-view="focus"><span class="ni" data-icon="target"></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><span class="nl">人力资源</span></button>
|
<button class="nav-item" data-view="staff"><span class="ni" data-icon="users"></span>人力资源</button>
|
||||||
<button class="nav-item" data-view="intake"><span class="ni" data-icon="pencil"></span><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="settings"><span class="ni" data-icon="sliders"></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="rbac"><span class="ni" data-icon="shield-check"></span><span class="nl">权限管理</span></button>
|
<button class="nav-item" data-view="rbac"><span class="ni" data-icon="shield-check"></span>权限管理</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="side-user" id="sideUser"></div>
|
<div class="side-user" id="sideUser"></div>
|
||||||
|
|
@ -90,7 +86,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>DMS管理平台</strong><span>软件开发部</span></div>
|
<div class="brand-text"><strong>关键任务追踪看板</strong><span>软件开发部</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ab-mid">
|
<div class="ab-mid">
|
||||||
<h2>进展、风险、计收<br>一张看板看完</h2>
|
<h2>进展、风险、计收<br>一张看板看完</h2>
|
||||||
|
|
@ -107,7 +103,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="请输入账户" /></label>
|
<input class="inp" id="authUser" autocomplete="username" placeholder="如 admin" /></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,9 +76,8 @@ 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 { position: relative; display: flex; gap: 10px; align-items: center; margin-bottom: 22px; padding: 0 4px; }
|
.brand { 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;
|
||||||
|
|
@ -149,47 +148,6 @@ 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%); }
|
||||||
|
|
@ -264,17 +222,6 @@ 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);
|
||||||
|
|
@ -343,7 +290,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 { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f3f7; border-radius: 999px; padding: 1px 7px; }
|
.card-head h4 .cnt, .card-head .head-chips .cnt { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f3f7; border-radius: 999px; padding: 1px 7px; }
|
||||||
.card-body { padding: 16px; }
|
.card-body { padding: 16px; }
|
||||||
|
|
||||||
.grid { display: grid; gap: 16px; }
|
.grid { display: grid; gap: 16px; }
|
||||||
|
|
@ -372,6 +319,25 @@ 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 {
|
||||||
|
|
@ -383,18 +349,11 @@ 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,筛选器 + 主功能按钮放同一行 */
|
||||||
.ctabs { display: flex; align-items: flex-end; gap: 2px; padding: 0 14px; position: relative; z-index: 2; }
|
.card-head.chips-row .head-chips { flex-basis: 100%; }
|
||||||
.ctab {
|
.card-head.chips-row .head-chips .cnt { margin-left: 4px; }
|
||||||
border: 1px solid transparent; border-bottom: 0; background: transparent;
|
.card-head.chips-row .head-tools > * { flex: 0 1 auto; min-width: 0; }
|
||||||
color: var(--muted); font-size: 13px; padding: 8px 18px; cursor: pointer;
|
.card-head.chips-row .head-tools .btn { flex: 0 0 auto; }
|
||||||
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 {
|
||||||
|
|
@ -433,7 +392,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);
|
||||||
|
|
@ -466,23 +425,6 @@ 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%;
|
||||||
}
|
}
|
||||||
|
|
@ -646,7 +588,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);
|
||||||
}
|
}
|
||||||
|
|
@ -734,3 +676,15 @@ 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