项目列表
-统计
+ +全局统计
+ +按统计类型查看项目,数据来自后端统计 API。
+项目详情
-用户管理
+| 账号 | +显示名称 | +角色 | +更新时间 | +操作 | +
|---|
diff --git a/backend/API.md b/backend/API.md index e8e1bbe..fc3533d 100644 --- a/backend/API.md +++ b/backend/API.md @@ -53,12 +53,177 @@ --- -## 3. 项目列表 +## 2.2 用户管理(仅管理员) + +以下接口仅**管理员**角色可调用,用于查看、创建、修改、删除其他用户。非管理员返回 HTTP 403。 + +### 2.2.0 用户列表 + +| 项目 | 说明 | +|------|------| +| **接口** | `POST /api/users/list` | +| **说明** | 分页获取用户列表(不含密码) | +| **权限** | 已登录,且角色为管理员 | + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| page | number | 否 | 页码,从 1 开始,默认 1 | +| pageSize | number | 否 | 每页条数,默认 100 | + +#### 响应参数(成功,HTTP 200) + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| list | array | 用户列表 | +| list[].id | string | 用户 ID | +| list[].username | string | 账号 | +| list[].role | string | 角色 | +| list[].displayName | string | 显示名称 | +| list[].createdAt | string | 创建时间,ISO8601 | +| list[].updatedAt | string | 更新时间,ISO8601 | +| total | number | 总条数 | +| page | number | 当前页码 | +| pageSize | number | 每页条数 | + +--- + +### 2.2.1 创建用户 + +| 项目 | 说明 | +|------|------| +| **接口** | `POST /api/users/create` | +| **说明** | 创建新用户 | +| **权限** | 已登录,且角色为管理员 | + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| username | string | 是 | 账号(唯一) | +| password | string | 是 | 密码 | +| role | string | 是 | 角色:管理员、市场部、工程部、技经部、财务部、物贸部 | +| displayName | string | 否 | 显示名称,默认同 username | + +#### 响应参数(成功,HTTP 201) + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| id | string | 新用户 ID | +| message | string | 固定为「创建成功」 | + +#### 响应参数(失败) + +| HTTP 状态 | 说明 | +|-----------|------| +| 400 | 账号/密码为空、角色无效、账号已存在 | +| 403 | 非管理员 | + +--- + +### 2.2.2 更新用户 + +| 项目 | 说明 | +|------|------| +| **接口** | `POST /api/users/update` | +| **说明** | 修改指定用户(仅更新传入的字段) | +| **权限** | 已登录,且角色为管理员 | + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | string | 是 | 用户 ID | +| password | string | 否 | 新密码,不传则不修改 | +| role | string | 否 | 新角色 | +| displayName | string | 否 | 新显示名称 | + +#### 响应参数(成功,HTTP 200) + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| id | string | 用户 ID | +| message | string | 固定为「保存成功」 | + +#### 响应参数(失败) + +| HTTP 状态 | 说明 | +|-----------|------| +| 400 | 角色无效等 | +| 403 | 非管理员 | +| 404 | 用户不存在 | + +--- + +### 2.2.3 删除用户 + +| 项目 | 说明 | +|------|------| +| **接口** | `POST /api/users/delete` | +| **说明** | 删除指定用户。不能删除当前登录用户。 | +| **权限** | 已登录,且角色为管理员 | + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | string | 是 | 用户 ID | + +#### 响应参数(成功,HTTP 200) + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| message | string | 固定为「删除成功」 | + +#### 响应参数(失败) + +| HTTP 状态 | 说明 | +|-----------|------| +| 400 | 不能删除当前登录用户 | +| 403 | 非管理员 | +| 404 | 用户不存在 | + +--- + +## 3. 项目统计 + +| 项目 | 说明 | +|------|------| +| **接口** | `POST /api/projects/statistics` | +| **说明** | 获取项目统计信息。传入 statisticsType=全部 返回各类型数量(byType);传入具体类型返回该类型项目列表 | +| **权限** | 已登录,所有角色可访问 | + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| statisticsType | string | 否 | 全部(默认)返回各类型数量;基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他 返回该类型项目列表 | + +#### 响应参数(statisticsType=全部) + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| total | number | 项目总数 | +| statisticsType | string | 固定为「全部」 | +| byType | object | 各统计类型数量,如 { "基建工程": 10, "业扩项目": 5, "其他": 3 } | + +#### 响应参数(statisticsType 为具体类型) + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| total | number | 该类型项目数 | +| statisticsType | string | 请求的统计类型 | +| list | array | 该类型项目列表,结构同项目列表 list[].* | + +--- + +## 4. 项目列表 | 项目 | 说明 | |------|------| | **接口** | `POST /api/projects/list` | -| **说明** | 分页获取项目列表,支持按名称/合同编号搜索、按进度/费用/合同金额筛选、时间筛选、日期异常筛选 | +| **说明** | 分页获取项目列表,支持按名称/合同编号搜索、按进度/费用/合同金额/统计类型筛选、时间筛选、日期异常筛选 | | **权限** | 已登录,所有角色可访问 | #### 请求参数 @@ -77,6 +242,7 @@ | dateAbnormal | boolean | 否 | 为 true 时只返回「日期异常」项目(四个日期中任意一个非「日期正常」) | | amountMin | number | 否 | 合同金额(万元)下限,按中标合同金额筛选 | | amountMax | number | 否 | 合同金额(万元)上限,按中标合同金额筛选 | +| statisticsType | string | 否 | 统计类型筛选:全部、基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他 | #### 响应参数(成功,HTTP 200) @@ -84,6 +250,7 @@ |--------|------|------| | list | array | 项目列表,无数据时为空数组 | | list[].id | string | 项目 ID | +| list[].statisticsType | string | 统计类型 | | list[].projectName | string | 项目名称 | | list[].contractCode | string | 合同编号 | | list[].progress | string | 项目进度 | @@ -101,7 +268,7 @@ --- -## 4. 项目详情 +## 5. 项目详情 | 项目 | 说明 | |------|------| @@ -137,7 +304,7 @@ --- -## 5. 新建项目 +## 6. 新建项目 | 项目 | 说明 | |------|------| @@ -183,7 +350,7 @@ --- -## 6. 更新项目 +## 7. 更新项目 | 项目 | 说明 | |------|------| @@ -218,7 +385,7 @@ --- -## 7. 操作日志 +## 8. 操作日志 | 项目 | 说明 | |------|------| @@ -258,7 +425,7 @@ --- -## 8. 项目信息字段(5 个分类) +## 9. 项目信息字段(5 个分类) 以下为各分类在 **请求/响应** 中使用的字段定义;单位、是否必填、校验规则由后端统一规定,前端与后端对齐。 @@ -349,7 +516,7 @@ --- -## 9. 项目列表/筛选枚举 +## 10. 项目列表/筛选枚举 以下由后端定义并提供给前端(如通过配置接口或文档约定): @@ -360,7 +527,7 @@ --- -## 10. 统一错误响应 +## 11. 统一错误响应 | HTTP 状态 | 说明 | |-----------|------| @@ -394,11 +561,15 @@ --- -## 11. 接口一览 +## 12. 接口一览 | 接口 | 方法 | 说明 | 权限 | |------|------|------|------| | /api/auth/login | POST | 登录 | 公开 | +| /api/users/list | POST | 用户列表(分页) | 已登录且管理员 | +| /api/users/create | POST | 创建用户 | 已登录且管理员 | +| /api/users/update | POST | 更新用户 | 已登录且管理员 | +| /api/users/delete | POST | 删除用户 | 已登录且管理员 | | /api/projects/list | POST | 项目列表(搜索、筛选、分页) | 已登录 | | /api/projects/detail | POST | 项目详情 | 已登录 | | /api/projects/create | POST | 新建项目 | 已登录且市场部 | diff --git a/backend/schema.sql b/backend/schema.sql index 8d685e6..59aa850 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -36,6 +36,7 @@ CREATE TABLE `projects` ( `id` varchar(64) NOT NULL COMMENT '项目ID', `progress` varchar(32) DEFAULT NULL COMMENT '项目进度(列表筛选)', `cost` varchar(32) DEFAULT NULL COMMENT '项目费用(列表筛选)', + `statistics_type` varchar(32) DEFAULT NULL COMMENT '统计类型:基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他', -- 合同信息 `serial_no` varchar(32) DEFAULT NULL COMMENT '序号', `contract_code` varchar(128) NOT NULL COMMENT '合同编号', diff --git a/backend/scripts/README.md b/backend/scripts/README.md index 3e0c414..1f5d3cb 100644 --- a/backend/scripts/README.md +++ b/backend/scripts/README.md @@ -47,6 +47,34 @@ cd backend && source .venv/bin/activate && PYTHONPATH=src python scripts/clean_d --- +## migrate_add_statistics_type.py:为 projects 表增加统计类型列 + +为已有 `projects` 表添加 `statistics_type` 列(若不存在)。 + +```bash +cd backend && PYTHONPATH=src python scripts/migrate_add_statistics_type.py +``` + +**migrate_statistics_type_repair.py**:将已有数据中的「检修、技改、抢修」统一改为「检修-技改-抢修」(一次性执行)。 + +```bash +cd backend && PYTHONPATH=src python scripts/migrate_statistics_type_repair.py +``` + +--- + +## fill_statistics_type_from_xls.py:按「统计」表填充统计类型 + +根据 `docs/example.xls` 的「统计」表,为已有项目填充 `statistics_type`(基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他)。导入数据后建议执行一次。 + +```bash +cd backend && PYTHONPATH=src python scripts/fill_statistics_type_from_xls.py +# 或指定 xls 路径 +PYTHONPATH=src python scripts/fill_statistics_type_from_xls.py /path/to/example.xls +``` + +--- + ## import_xls.py:导入 example.xls 到 projects 表 将 `docs/example.xls` 中的项目数据导入 ocean 数据库的 `projects` 表。 diff --git a/backend/scripts/fill_statistics_type_from_xls.py b/backend/scripts/fill_statistics_type_from_xls.py new file mode 100644 index 0000000..4fe7de8 --- /dev/null +++ b/backend/scripts/fill_statistics_type_from_xls.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +根据 docs/example.xls 的「统计」表,为 projects 表填充 statistics_type。 +统计表结构:一、基建工程(3-29) 二、业扩项目(33-50) 三、户表(54-71) 四、客户工程 五、营销项目 六、检修、技改、抢修 -> 统计类型存为「检修-技改-抢修」 +通过 项目名称 或 所属项目部 包含统计表中的名称/关键字进行匹配。 +用法:cd backend && PYTHONPATH=src python3 scripts/fill_statistics_type_from_xls.py [path_to_example.xls] +""" +import sys +from pathlib import Path + +_backend = Path(__file__).resolve().parent.parent +if str(_backend) not in sys.path: + sys.path.insert(0, str(_backend)) + +import xlrd +from src.database import get_connection + + +def _normalize(s): + if s is None: + return "" + return str(s).strip().replace("\n", "").replace("\r", "") + + +def collect_names_from_stat_sheet(wb: xlrd.Book) -> dict: + """从「统计」表收集各类型的名称/关键字列表。返回 { 统计类型: [名称1, 名称2, ...] }""" + if "统计" not in wb.sheet_names(): + return {} + sh = wb.sheet_by_name("统计") + out = { + "基建工程": [], + "业扩项目": [], + "户表": [], + "客户工程": [], + "营销项目": [], + "检修-技改-抢修": [], + } + # 一、基建工程: row 0 title, row 1 header, rows 3-29 data, col 1 = 项目名称 + for r in range(3, 30): + v = _normalize(sh.cell_value(r, 1)) + if v and v != "合计" and not v.replace(".", "").isdigit(): + out["基建工程"].append(v) + # 二、业扩项目: row 30 title, row 31 header?, row 32 合计, rows 33-50 col 0 + for r in range(33, 51): + v = _normalize(sh.cell_value(r, 0)) + if v and v != "合计" and "业扩" in v: + out["业扩项目"].append(v) + # 三、户表: row 51 title, row 52 header, row 53 合计, rows 54-71 col 0 + for r in range(54, 72): + v = _normalize(sh.cell_value(r, 0)) + if v and v != "合计" and "户表" in v: + out["户表"].append(v) + # 四、客户工程: row 72 title, row 73 header(序号,所属项目部,项目个数,总投资), 74+ 所属项目部 col 1 + for r in range(75, 96): + v = _normalize(sh.cell_value(r, 1)) + if v and v != "合计" and not v.replace(".", "").isdigit(): + out["客户工程"].append(v) + # 五、营销项目: row 96 title, rows 97-107 + for r in range(97, 108): + v = _normalize(sh.cell_value(r, 0)) or _normalize(sh.cell_value(r, 1)) + if v and v != "合计": + out["营销项目"].append(v) + # 六、检修、技改、抢修: row 108 title, rows 109+ col 1 主项目名称 -> 统计类型「检修-技改-抢修」 + for r in range(109, min(134, sh.nrows)): + v = _normalize(sh.cell_value(r, 1)) + if v and v not in ("合计", "小计", ""): + out["检修-技改-抢修"].append(v) + return out + + +def main(): + if len(sys.argv) >= 2: + xls_path = Path(sys.argv[1]).resolve() + else: + xls_path = _backend.parent / "docs" / "example.xls" + if not xls_path.exists(): + xls_path = _backend / "docs" / "example.xls" + if not xls_path.exists(): + print(f"未找到文件:{xls_path}") + sys.exit(1) + wb = xlrd.open_workbook(str(xls_path)) + by_type = collect_names_from_stat_sheet(wb) + # 基建工程:从「凯里XX供电局」「都匀XX供电局」提取 XX,匹配 所属项目部 含 XX + build_keywords = [] + for n in by_type.get("基建工程", []): + n = _normalize(n) + if "供电局" in n: + # 凯里城区供电分局 -> 城区,凯里三穗供电局 -> 三穗,都匀瓮安供电局 -> 瓮安 + for prefix in ("凯里", "都匀"): + if n.startswith(prefix): + mid = n[len(prefix) :].split("供电局")[0].strip() + if mid and len(mid) <= 6: + build_keywords.append((mid, "基建工程")) + break + # 匹配顺序:检修-技改-抢修、业扩、户表、营销、基建(先关键字再名称)、客户工程 + order = ["检修-技改-抢修", "业扩项目", "户表", "营销项目", "基建工程", "客户工程"] + with get_connection() as conn: + cur = conn.cursor(dictionary=True) + cur.execute("SELECT id, project_name, project_department FROM projects") + rows = cur.fetchall() + cur.close() + updated = 0 + with get_connection() as conn: + cur = conn.cursor() + for r in rows: + pid = r["id"] + pname = _normalize(r.get("project_name") or "") + pdept = _normalize(r.get("project_department") or "") + st = None + # 基建:项目部关键字匹配(如 剑河项目部 -> 基建工程) + for kw, typ in build_keywords: + if kw in pdept or kw in pname: + st = typ + break + if not st: + for t in order: + names = by_type.get(t, []) + for n in names: + if n and (n in pname or n in pdept or (len(n) >= 4 and pname.startswith(n[:4]))): + st = t + break + if st: + break + if not st: + st = "其他" + cur.execute("UPDATE projects SET statistics_type = %s WHERE id = %s", (st, pid)) + updated += 1 + conn.commit() + cur.close() + print(f"已根据统计表填充 statistics_type,共更新 {updated} 条项目。") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/migrate_add_statistics_type.py b/backend/scripts/migrate_add_statistics_type.py new file mode 100644 index 0000000..0337590 --- /dev/null +++ b/backend/scripts/migrate_add_statistics_type.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +""" +为 projects 表增加 statistics_type 列(若不存在)。 +用法:cd backend && PYTHONPATH=src python3 scripts/migrate_add_statistics_type.py +""" +import sys +from pathlib import Path + +_backend = Path(__file__).resolve().parent.parent +if str(_backend) not in sys.path: + sys.path.insert(0, str(_backend)) + +from src.database import get_connection + +def main(): + with get_connection() as conn: + cur = conn.cursor() + try: + cur.execute( + "ALTER TABLE projects " + "ADD COLUMN statistics_type varchar(32) DEFAULT NULL " + "COMMENT '统计类型:基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他' " + "AFTER cost" + ) + conn.commit() + print("已为 projects 表添加 statistics_type 列。") + except Exception as e: + if "Duplicate column name" in str(e): + print("列已存在,无需迁移。") + else: + raise + finally: + cur.close() + +if __name__ == "__main__": + main() diff --git a/backend/scripts/migrate_statistics_type_repair.py b/backend/scripts/migrate_statistics_type_repair.py new file mode 100644 index 0000000..3491fe3 --- /dev/null +++ b/backend/scripts/migrate_statistics_type_repair.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +""" +将 projects 表中 statistics_type 为「检修、技改、抢修」的项统一改为「检修-技改-抢修」。 +用法:cd backend && PYTHONPATH=src python3 scripts/migrate_statistics_type_repair.py +""" +import sys +from pathlib import Path + +_backend = Path(__file__).resolve().parent.parent +if str(_backend) not in sys.path: + sys.path.insert(0, str(_backend)) + +from src.database import get_connection + +def main(): + with get_connection() as conn: + cur = conn.cursor() + cur.execute( + "UPDATE projects SET statistics_type = %s WHERE statistics_type = %s", + ("检修-技改-抢修", "检修、技改、抢修"), + ) + n = cur.rowcount + conn.commit() + cur.close() + print(f"已将 statistics_type「检修、技改、抢修」更新为「检修-技改-抢修」,共 {n} 条。") + +if __name__ == "__main__": + main() diff --git a/backend/src/app.py b/backend/src/app.py index b9c96fd..56ac1ce 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -35,6 +35,11 @@ class ProjectListBody(BaseModel): dateAbnormal: Optional[bool] = None # True:只查日期异常项目 amountMin: Optional[float] = None # 合同金额(万元)下限 amountMax: Optional[float] = None # 合同金额(万元)上限 + statisticsType: Optional[str] = None # 全部|基建工程|业扩项目|户表|客户工程|营销项目|检修-技改-抢修|其他 + + +class ProjectStatisticsBody(BaseModel): + statisticsType: Optional[str] = None # 全部 返回各类型数量;具体类型返回该类型项目列表 class ProjectDetailBody(BaseModel): @@ -52,6 +57,7 @@ class ProjectCreateBody(BaseModel): receivable: Optional[dict] = None payable: Optional[dict] = None other: Optional[dict] = None + statisticsType: Optional[str] = None # 基建工程|业扩项目|户表|客户工程|营销项目|检修-技改-抢修|其他 class ProjectUpdateBody(BaseModel): @@ -61,6 +67,7 @@ class ProjectUpdateBody(BaseModel): receivable: Optional[dict] = None payable: Optional[dict] = None other: Optional[dict] = None + statisticsType: Optional[str] = None class ProjectLogsBody(BaseModel): @@ -69,6 +76,29 @@ class ProjectLogsBody(BaseModel): pageSize: Optional[int] = 20 +class UserCreateBody(BaseModel): + username: Optional[str] = None + password: Optional[str] = None + role: Optional[str] = None + displayName: Optional[str] = None + + +class UserUpdateBody(BaseModel): + id: str + password: Optional[str] = None + role: Optional[str] = None + displayName: Optional[str] = None + + +class UserDeleteBody(BaseModel): + id: str + + +class UserListBody(BaseModel): + page: Optional[int] = 1 + pageSize: Optional[int] = 100 + + def _get_current_user(authorization: Optional[str] = Header(None)) -> dict: """从 Authorization Bearer token 获取当前用户,否则 401。""" if not authorization or not authorization.startswith("Bearer "): @@ -80,6 +110,13 @@ def _get_current_user(authorization: Optional[str] = Header(None)) -> dict: return user +def _require_admin(user: dict): + """要求当前用户为管理员,否则返回 403 JSONResponse(路由需 return 其返回值)。""" + if user.get("role") != "管理员": + return JSONResponse(status_code=403, content={"code": 403, "message": "仅管理员可操作"}) + return None + + # ---------- 认证 ---------- @app.post("/api/auth/login") def login(body: LoginBody): @@ -126,10 +163,21 @@ def project_list(body: ProjectListBody, authorization: Optional[str] = Header(No date_abnormal=body.dateAbnormal, amount_min=body.amountMin, amount_max=body.amountMax, + statistics_type=body.statisticsType, ) return {"list": list_, "total": total, "page": page, "pageSize": page_size} +# ---------- 项目统计 ---------- +@app.post("/api/projects/statistics") +def project_statistics(body: ProjectStatisticsBody, authorization: Optional[str] = Header(None)): + """获取项目统计信息。statisticsType=全部 返回各类型数量;具体类型返回该类型项目列表。""" + _get_current_user(authorization) + st = (body.statisticsType or "全部").strip() + data = projects_repo.get_project_statistics(st) + return data + + # ---------- 项目详情 ---------- @app.post("/api/projects/detail") def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)): @@ -162,6 +210,7 @@ def project_create(body: ProjectCreateBody, authorization: Optional[str] = Heade receivable=body.receivable, payable=body.payable, other=body.other, + statistics_type=body.statisticsType, ) return {"id": pid, "message": "创建成功"} @@ -179,6 +228,7 @@ def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Heade receivable=body.receivable, payable=body.payable, other=body.other, + statistics_type=body.statisticsType, ) summary_parts = [] if body.contract is not None: @@ -201,6 +251,78 @@ def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Heade return {"id": body.id, "message": "保存成功"} +# ---------- 用户管理(仅管理员) ---------- +@app.post("/api/users/list") +def user_list(body: UserListBody, authorization: Optional[str] = Header(None)): + user = _get_current_user(authorization) + r = _require_admin(user) + if r is not None: + return r + page = body.page or 1 + page_size = body.pageSize or 100 + list_, total = auth.list_users(page=page, page_size=page_size) + return {"list": list_, "total": total, "page": page, "pageSize": page_size} + + +@app.post("/api/users/create", status_code=201) +def user_create(body: UserCreateBody, authorization: Optional[str] = Header(None)): + user = _get_current_user(authorization) + r = _require_admin(user) + if r is not None: + return r + if not body.username or (isinstance(body.username, str) and not body.username.strip()): + return JSONResponse(status_code=400, content={"code": 400, "message": "账号不能为空"}) + if not body.password or (isinstance(body.password, str) and not body.password.strip()): + return JSONResponse(status_code=400, content={"code": 400, "message": "密码不能为空"}) + if not body.role or body.role.strip() not in auth.ROLES: + return JSONResponse(status_code=400, content={"code": 400, "message": "角色无效"}) + try: + uid = auth.create_user( + username=body.username.strip(), + password=body.password, + role=body.role.strip(), + display_name=body.displayName.strip() if body.displayName else None, + ) + return {"id": uid, "message": "创建成功"} + except ValueError as e: + return JSONResponse(status_code=400, content={"code": 400, "message": str(e)}) + + +@app.post("/api/users/update") +def user_update(body: UserUpdateBody, authorization: Optional[str] = Header(None)): + user = _get_current_user(authorization) + r = _require_admin(user) + if r is not None: + return r + if not auth.get_user_by_id(body.id): + return JSONResponse(status_code=404, content={"code": 404, "message": "用户不存在"}) + if body.role is not None and body.role.strip() not in auth.ROLES: + return JSONResponse(status_code=400, content={"code": 400, "message": "角色无效"}) + try: + auth.update_user( + body.id, + password=body.password, + role=body.role.strip() if body.role else None, + display_name=body.displayName.strip() if body.displayName else None, + ) + return {"id": body.id, "message": "保存成功"} + except ValueError as e: + return JSONResponse(status_code=400, content={"code": 400, "message": str(e)}) + + +@app.post("/api/users/delete") +def user_delete(body: UserDeleteBody, authorization: Optional[str] = Header(None)): + user = _get_current_user(authorization) + r = _require_admin(user) + if r is not None: + return r + if body.id == user["id"]: + return JSONResponse(status_code=400, content={"code": 400, "message": "不能删除当前登录用户"}) + if not auth.delete_user(body.id): + return JSONResponse(status_code=404, content={"code": 404, "message": "用户不存在"}) + return {"message": "删除成功"} + + # ---------- 操作日志 ---------- @app.post("/api/projects/logs") def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)): diff --git a/backend/src/auth.py b/backend/src/auth.py index 9f0ff42..0ff8d4e 100644 --- a/backend/src/auth.py +++ b/backend/src/auth.py @@ -68,3 +68,133 @@ def get_user_by_token(token: str) -> Optional[dict[str, Any]]: def hash_password(plain: str) -> str: """密码哈希(用于初始化用户等)""" return pwd_ctx.hash(plain) + + +def _hash_bcrypt(plain: str) -> str: + """使用 bcrypt 生成哈希,与 _verify_password 兼容""" + return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +ROLES = ("管理员", "市场部", "工程部", "技经部", "财务部", "物贸部") + + +def create_user(username: str, password: str, role: str, display_name: Optional[str] = None) -> str: + """创建用户,返回用户 id。username 重复则抛 ValueError。""" + username = username.strip() + if not username: + raise ValueError("账号不能为空") + if not password or len(password.strip()) < 1: + raise ValueError("密码不能为空") + if role not in ROLES: + raise ValueError("角色无效") + uid = new_id() + pwd_hash = _hash_bcrypt(password) + display = (display_name or username).strip() or username + with get_connection() as conn: + cur = conn.cursor() + try: + cur.execute( + "INSERT INTO users (id, username, password_hash, role, display_name) VALUES (%s, %s, %s, %s, %s)", + (uid, username, pwd_hash, role, display), + ) + conn.commit() + except Exception as e: + if "Duplicate entry" in str(e) or "1062" in str(e): + raise ValueError("账号已存在") + raise + finally: + cur.close() + return uid + + +def list_users(page: int = 1, page_size: int = 100) -> tuple[list[dict], int]: + """分页获取用户列表(不含密码),返回 (list, total)。""" + with get_connection() as conn: + cur = conn.cursor(dictionary=True) + cur.execute("SELECT COUNT(*) AS cnt FROM users") + total = cur.fetchone()["cnt"] + offset = (page - 1) * page_size + cur.execute( + "SELECT id, username, role, display_name, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT %s OFFSET %s", + (page_size, offset), + ) + rows = cur.fetchall() + cur.close() + list_ = [ + { + "id": r["id"], + "username": r["username"], + "role": r["role"], + "displayName": r["display_name"] or r["username"], + "createdAt": r["created_at"].isoformat() if r.get("created_at") else "", + "updatedAt": r["updated_at"].isoformat() if r.get("updated_at") else "", + } + for r in rows + ] + return list_, total + + +def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]: + """按 id 查询用户(不含密码),不存在返回 None。""" + with get_connection() as conn: + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT id, username, role, display_name, created_at, updated_at FROM users WHERE id = %s", + (user_id,), + ) + row = cur.fetchone() + cur.close() + if not row: + return None + return { + "id": row["id"], + "username": row["username"], + "role": row["role"], + "displayName": row["display_name"] or row["username"], + "createdAt": row["created_at"].isoformat() if row.get("created_at") else None, + "updatedAt": row["updated_at"].isoformat() if row.get("updated_at") else None, + } + + +def update_user( + user_id: str, + password: Optional[str] = None, + role: Optional[str] = None, + display_name: Optional[str] = None, +) -> bool: + """更新用户(仅更新传入的字段)。不存在返回 False。""" + if not get_user_by_id(user_id): + return False + if role is not None and role not in ROLES: + raise ValueError("角色无效") + updates = [] + params = [] + if password is not None and len(password.strip()) > 0: + updates.append("password_hash = %s") + params.append(_hash_bcrypt(password)) + if role is not None: + updates.append("role = %s") + params.append(role) + if display_name is not None: + updates.append("display_name = %s") + params.append(display_name.strip() or None) + if not updates: + return True + params.append(user_id) + with get_connection() as conn: + cur = conn.cursor() + cur.execute("UPDATE users SET " + ", ".join(updates) + " WHERE id = %s", params) + conn.commit() + cur.close() + return True + + +def delete_user(user_id: str) -> bool: + """删除用户。不存在返回 False。""" + with get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM users WHERE id = %s", (user_id,)) + n = cur.rowcount + conn.commit() + cur.close() + return n > 0 diff --git a/backend/src/projects_repo.py b/backend/src/projects_repo.py index 29923ed..e5ee86b 100644 --- a/backend/src/projects_repo.py +++ b/backend/src/projects_repo.py @@ -171,12 +171,16 @@ def list_projects( date_abnormal: Optional[bool] = None, amount_min: Optional[float] = None, amount_max: Optional[float] = None, + statistics_type: Optional[str] = None, ) -> tuple[list[dict], int]: - """分页列表,支持按项目名称/合同编号搜索、按进度/费用/合同金额筛选、时间筛选、日期异常筛选。返回 (list, total)。""" + """分页列表,支持按项目名称/合同编号搜索、按进度/费用/合同金额/统计类型筛选、时间筛选、日期异常筛选。返回 (list, total)。""" with get_connection() as conn: cur = conn.cursor(dictionary=True) where_parts = [] params: list[Any] = [] + if statistics_type and statistics_type.strip() and statistics_type.strip() != "全部": + where_parts.append("statistics_type = %s") + params.append(statistics_type.strip()) if keyword and keyword.strip(): if search_type == "name": where_parts.append("project_name LIKE %s") @@ -226,7 +230,7 @@ def list_projects( cur.execute( f"""SELECT id, project_name, contract_code, progress, cost, updated_at, bid_contract_amount, owner_unit, sign_date, sign_date_str, - project_department, total_cost, project_leader_contact + project_department, total_cost, project_leader_contact, statistics_type FROM projects WHERE {where_sql} ORDER BY updated_at DESC LIMIT %s OFFSET %s""", tuple(params) + (page_size, offset), ) @@ -235,7 +239,12 @@ def list_projects( list_ = [] for r in rows: _sd = r.get("sign_date") - sign_val = r.get("sign_date_str") or (_sd.strftime("%Y-%m-%d") if _sd else None) + sign_date_str_val = r.get("sign_date_str") + # 只有不是「日期正常」时才显示原文;否则显示实际日期 + if sign_date_str_val and str(sign_date_str_val).strip() != DATE_NORMAL: + sign_val = sign_date_str_val + else: + sign_val = (_sd.strftime("%Y-%m-%d") if _sd else None) or (sign_date_str_val or "") list_.append({ "id": r["id"], "projectName": r["project_name"] or "", @@ -249,10 +258,76 @@ def list_projects( "projectDepartment": r.get("project_department") or "", "totalCost": str(r["total_cost"]) if r.get("total_cost") is not None else "", "projectLeaderContact": r.get("project_leader_contact") or "", + "statisticsType": r.get("statistics_type") or "", }) return list_, total +# 统计类型枚举(与前端/API 一致) +STATISTICS_TYPES = ( + "全部", + "基建工程", + "业扩项目", + "户表", + "客户工程", + "营销项目", + "检修-技改-抢修", + "其他", +) + + +def get_project_statistics(statistics_type: Optional[str] = None) -> dict: + """获取项目统计信息。statistics_type 为「全部」或空时返回各类型数量;否则返回该类型下的 list 与 total。""" + with get_connection() as conn: + cur = conn.cursor(dictionary=True) + if not statistics_type or statistics_type.strip() == "全部": + cur.execute( + """SELECT statistics_type AS type, COUNT(*) AS cnt FROM projects + GROUP BY statistics_type""" + ) + rows = cur.fetchall() + cur.close() + by_type = {r["type"] or "其他": r["cnt"] for r in rows} + total = sum(by_type.values()) + return {"total": total, "statisticsType": "全部", "byType": by_type} + st = statistics_type.strip() + cur.execute("SELECT COUNT(*) AS cnt FROM projects WHERE statistics_type = %s", (st,)) + total = cur.fetchone()["cnt"] + cur.execute( + """SELECT id, project_name, contract_code, progress, cost, updated_at, + bid_contract_amount, owner_unit, sign_date, sign_date_str, + project_department, total_cost, project_leader_contact, statistics_type + FROM projects WHERE statistics_type = %s ORDER BY updated_at DESC""", + (st,), + ) + rows = cur.fetchall() + cur.close() + list_ = [] + for r in rows: + _sd = r.get("sign_date") + sign_date_str_val = r.get("sign_date_str") + if sign_date_str_val and str(sign_date_str_val).strip() != DATE_NORMAL: + sign_val = sign_date_str_val + else: + sign_val = (_sd.strftime("%Y-%m-%d") if _sd else None) or (sign_date_str_val or "") + list_.append({ + "id": r["id"], + "projectName": r["project_name"] or "", + "contractCode": r["contract_code"] or "", + "progress": r["progress"] or "", + "cost": r["cost"] or "", + "updatedAt": r["updated_at"].isoformat() if r.get("updated_at") else "", + "bidContractAmount": r.get("bid_contract_amount") or "", + "ownerUnit": r.get("owner_unit") or "", + "signDate": sign_val or "", + "projectDepartment": r.get("project_department") or "", + "totalCost": str(r["total_cost"]) if r.get("total_cost") is not None else "", + "projectLeaderContact": r.get("project_leader_contact") or "", + "statisticsType": r.get("statistics_type") or "", + }) + return {"total": total, "statisticsType": st, "list": list_} + + def get_project(project_id: str) -> Optional[dict[str, Any]]: """按 id 查项目,返回 API 形态详情,不存在返回 None。""" with get_connection() as conn: @@ -271,6 +346,7 @@ def create_project( receivable: Optional[dict] = None, payable: Optional[dict] = None, other: Optional[dict] = None, + statistics_type: Optional[str] = None, ) -> str: """新建项目,合同信息必含 contract_code、project_name。返回新项目 id。""" pid = new_id() @@ -285,6 +361,8 @@ def create_project( row["id"] = pid row["progress"] = (row.get("cumulative_progress") or row.get("project_name") or "")[:32] if row.get("cumulative_progress") or row.get("project_name") else "" row["cost"] = "" + if statistics_type is not None and str(statistics_type).strip(): + row["statistics_type"] = str(statistics_type).strip() row["created_at"] = now row["updated_at"] = now cols = [k for k in row.keys()] @@ -307,6 +385,7 @@ def update_project( receivable: Optional[dict] = None, payable: Optional[dict] = None, other: Optional[dict] = None, + statistics_type: Optional[str] = None, ) -> bool: """更新项目(合并传入的字段),不存在返回 False。""" c = _api_to_db(contract, CONTRACT_API_TO_DB) @@ -316,6 +395,8 @@ def update_project( pay = _api_to_db(payable, PAYABLE_API_TO_DB) oth = _api_to_db(other, OTHER_API_TO_DB) merged = {**c, **cc, **recv, **pay, **oth} + if statistics_type is not None: + merged["statistics_type"] = str(statistics_type).strip() if statistics_type else None if not merged: return project_exists(project_id) merged["updated_at"] = datetime.utcnow() diff --git a/backend/tests/README.md b/backend/tests/README.md index 33f55d6..5d0decf 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -37,6 +37,10 @@ TEST_BASE_URL=http://localhost:8000 pytest tests/ -v | 接口 | 用例 | |------|------| | POST /api/auth/login | 成功返回 token/user、错误凭证 401、缺 username/password 返回 400 | +| POST /api/users/create | 无 token 401、非管理员 403、管理员 201+id+message、缺 username/password/role 400 | +| POST /api/users/update | 无 token 401、非管理员 403、管理员 200+id+message、不存在 404 | +| POST /api/users/delete | 无 token 401、非管理员 403、管理员 200+message、不存在 404、不能删自己 400 | +| POST /api/projects/statistics | 无 token 401;有 token 默认/全部 返回 total、statisticsType、byType;具体类型返回 total、statisticsType、list | | POST /api/projects/list | 无 token 401;有 token 返回 list/total/page/pageSize;无数据 list 空且 total=0;支持 searchType+keyword(name/code)、progress、cost、dateFilterType+dateFrom/dateTo、dateAbnormal;列表项含 id/projectName/contractCode/progress/cost/updatedAt;page/pageSize 回显 | | POST /api/projects/detail | 无 token 401、缺 id 400/422、存在项目返回完整五类字段、不存在 404 | | POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400、校验失败可含 errors 数组 | diff --git a/backend/tests/test_projects_api.py b/backend/tests/test_projects_api.py index 7d13ed8..7ca6301 100644 --- a/backend/tests/test_projects_api.py +++ b/backend/tests/test_projects_api.py @@ -10,6 +10,64 @@ def _auth_header(token: str) -> dict: return {"Authorization": f"Bearer {token}"} +# ---------- POST /api/projects/statistics(API 文档第 3 节)---------- + + +def test_statistics_without_token_returns_401(client): + """项目统计无 token 时返回 401""" + r = client.post("/api/projects/statistics", json={}) + assert r.status_code == 401 + + +def test_statistics_with_token_default_returns_200_with_total_and_by_type(client, auth_token): + """有 token 且未传 statisticsType(或传全部)时返回 200,含 total、statisticsType、byType""" + r = client.post( + "/api/projects/statistics", + headers=_auth_header(auth_token), + json={}, + ) + assert r.status_code == 200 + body = r.json() + assert "total" in body + assert isinstance(body["total"], int) + assert "statisticsType" in body + assert body["statisticsType"] == "全部" + assert "byType" in body + assert isinstance(body["byType"], dict) + + +def test_statistics_with_statistics_type_all_returns_by_type(client, auth_token): + """statisticsType=全部 时返回 byType 各类型数量""" + r = client.post( + "/api/projects/statistics", + headers=_auth_header(auth_token), + json={"statisticsType": "全部"}, + ) + assert r.status_code == 200 + body = r.json() + assert body.get("statisticsType") == "全部" + assert "byType" in body + for _, count in body["byType"].items(): + assert isinstance(count, (int, float)) + + +def test_statistics_with_specific_type_returns_200_with_total_and_list(client, auth_token): + """statisticsType 为具体类型时返回 200,含 total、statisticsType、list""" + r = client.post( + "/api/projects/statistics", + headers=_auth_header(auth_token), + json={"statisticsType": "基建工程"}, + ) + assert r.status_code == 200 + body = r.json() + assert "total" in body + assert isinstance(body["total"], int) + assert "statisticsType" in body + assert body["statisticsType"] == "基建工程" + assert "list" in body + assert isinstance(body["list"], list) + + # ---------- POST /api/projects/list ---------- @@ -495,7 +553,7 @@ def test_e2e_login_then_get_project_list(client): def test_e2e_login_then_create_project_then_list_includes_it(client): - """E2E:登录(市场部)→ 创建项目 → 获取列表,列表中包含新建项目""" + """E2E:登录(市场部)→ 创建项目 → 按名称搜索列表,列表中包含新建项目""" token = _login_and_get_token(client, "market", "123456") project_name = "E2E流程新建项目" contract_code = "HT-E2E-001" @@ -509,14 +567,14 @@ def test_e2e_login_then_create_project_then_list_includes_it(client): list_r = client.post( "/api/projects/list", headers=_auth_header(token), - json={"page": 1, "pageSize": 100}, + json={"page": 1, "pageSize": 20, "searchType": "name", "keyword": project_name}, ) assert list_r.status_code == 200 items = list_r.json().get("list", []) ids = [p["id"] for p in items] - assert pid in ids - names = [p.get("projectName") for p in items if p.get("id") == pid] - assert project_name in names or len(names) >= 1 + assert pid in ids, f"新建项目 id {pid} 应在搜索结果中,list ids: {ids[:5]}..." + found = next((p for p in items if p.get("id") == pid), None) + assert found is not None and found.get("projectName") == project_name def test_e2e_login_then_create_then_detail_then_update_then_detail_reflects(client): diff --git a/backend/tests/test_users_api.py b/backend/tests/test_users_api.py new file mode 100644 index 0000000..5489f20 --- /dev/null +++ b/backend/tests/test_users_api.py @@ -0,0 +1,252 @@ +""" +用户管理 API 测试(依据 API 文档 2.2 节) + +仅管理员可创建、修改、删除用户。测试对已启动服务发起 HTTP 请求,使用 admin token。 +""" +import uuid + + +def _auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# ---------- POST /api/users/create ---------- + + +def test_users_create_without_token_returns_401(client): + """创建用户无 token 时返回 401""" + r = client.post( + "/api/users/create", + json={"username": "u1", "password": "123456", "role": "工程部"}, + ) + assert r.status_code == 401 + + +def test_users_create_non_admin_returns_403(client, engineer_token): + """非管理员创建用户返回 403""" + r = client.post( + "/api/users/create", + headers=_auth_header(engineer_token), + json={"username": "u_engineer_cannot", "password": "123456", "role": "工程部"}, + ) + assert r.status_code == 403 + assert r.json().get("code") == 403 + + +def test_users_create_admin_returns_201_with_id_and_message(client, auth_token): + """管理员创建用户返回 201 且包含 id 和 message「创建成功」""" + username = f"testuser_create_{uuid.uuid4().hex[:8]}" + r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={ + "username": username, + "password": "123456", + "role": "技经部", + "displayName": "测试创建用户", + }, + ) + assert r.status_code == 201 + body = r.json() + assert "id" in body + assert body.get("message") == "创建成功" + + +def test_users_create_missing_username_returns_400(client, auth_token): + """创建用户缺少 username 时返回 400""" + r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"password": "123456", "role": "工程部"}, + ) + assert r.status_code == 400 + assert r.json().get("code") == 400 + + +def test_users_create_missing_password_returns_400(client, auth_token): + """创建用户缺少 password 时返回 400""" + r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"username": "testuser_nopwd", "role": "工程部"}, + ) + assert r.status_code == 400 + assert r.json().get("code") == 400 + + +def test_users_create_missing_role_returns_400(client, auth_token): + """创建用户缺少 role 时返回 400""" + r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"username": "testuser_norole", "password": "123456"}, + ) + assert r.status_code == 400 + assert r.json().get("code") == 400 + + +# ---------- POST /api/users/update ---------- + + +def test_users_update_without_token_returns_401(client): + """更新用户无 token 时返回 401""" + r = client.post("/api/users/update", json={"id": "any-id"}) + assert r.status_code == 401 + + +def test_users_update_non_admin_returns_403(client, engineer_token, auth_token): + """非管理员更新用户返回 403""" + username = f"testuser_for_update_{uuid.uuid4().hex[:8]}" + create_r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"username": username, "password": "123456", "role": "财务部"}, + ) + assert create_r.status_code == 201 + uid = create_r.json()["id"] + r = client.post( + "/api/users/update", + headers=_auth_header(engineer_token), + json={"id": uid, "displayName": "不应成功"}, + ) + assert r.status_code == 403 + assert r.json().get("code") == 403 + + +def test_users_update_admin_returns_200_with_id_and_message(client, auth_token): + """管理员更新用户返回 200 且包含 id 和 message「保存成功」""" + username = f"testuser_update_{uuid.uuid4().hex[:8]}" + create_r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"username": username, "password": "123456", "role": "物贸部"}, + ) + assert create_r.status_code == 201 + uid = create_r.json()["id"] + r = client.post( + "/api/users/update", + headers=_auth_header(auth_token), + json={"id": uid, "displayName": "更新后的显示名", "role": "财务部"}, + ) + assert r.status_code == 200 + body = r.json() + assert body.get("id") == uid + assert body.get("message") == "保存成功" + + +def test_users_update_nonexistent_returns_404(client, auth_token): + """更新不存在用户返回 404""" + r = client.post( + "/api/users/update", + headers=_auth_header(auth_token), + json={"id": "non-existent-user-id", "displayName": "任意"}, + ) + assert r.status_code == 404 + assert r.json().get("code") == 404 + + +# ---------- POST /api/users/delete ---------- + + +def test_users_delete_without_token_returns_401(client): + """删除用户无 token 时返回 401""" + r = client.post("/api/users/delete", json={"id": "any-id"}) + assert r.status_code == 401 + + +def test_users_delete_non_admin_returns_403(client, engineer_token, auth_token): + """非管理员删除用户返回 403""" + username = f"testuser_for_del_{uuid.uuid4().hex[:8]}" + create_r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"username": username, "password": "123456", "role": "工程部"}, + ) + assert create_r.status_code == 201 + uid = create_r.json()["id"] + r = client.post( + "/api/users/delete", + headers=_auth_header(engineer_token), + json={"id": uid}, + ) + assert r.status_code == 403 + assert r.json().get("code") == 403 + + +def test_users_delete_admin_returns_200_with_message(client, auth_token): + """管理员删除用户返回 200 且 message「删除成功」""" + username = f"testuser_delete_{uuid.uuid4().hex[:8]}" + create_r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"username": username, "password": "123456", "role": "工程部"}, + ) + assert create_r.status_code == 201 + uid = create_r.json()["id"] + r = client.post( + "/api/users/delete", + headers=_auth_header(auth_token), + json={"id": uid}, + ) + assert r.status_code == 200 + assert r.json().get("message") == "删除成功" + + +def test_users_delete_nonexistent_returns_404(client, auth_token): + """删除不存在用户返回 404""" + r = client.post( + "/api/users/delete", + headers=_auth_header(auth_token), + json={"id": "non-existent-user-id"}, + ) + assert r.status_code == 404 + assert r.json().get("code") == 404 + + +def test_users_delete_self_returns_400(client, auth_token): + """管理员不能删除当前登录用户自己,返回 400""" + # 先登录拿到当前用户 id(从 login 响应的 user.id) + login_r = client.post("/api/auth/login", json={"username": "admin", "password": "123456"}) + assert login_r.status_code == 200 + current_user_id = login_r.json()["user"]["id"] + r = client.post( + "/api/users/delete", + headers=_auth_header(auth_token), + json={"id": current_user_id}, + ) + assert r.status_code == 400 + body = r.json() + assert body.get("code") == 400 + assert "message" in body + + +# ---------- E2E:管理员 创建 → 修改 → 删除 用户 ---------- + + +def test_e2e_admin_create_then_update_then_delete_user(client, auth_token): + """E2E:管理员创建用户 → 修改用户 → 删除用户""" + username = f"testuser_e2e_{uuid.uuid4().hex[:8]}" + create_r = client.post( + "/api/users/create", + headers=_auth_header(auth_token), + json={"username": username, "password": "123456", "role": "市场部", "displayName": "E2E初始"}, + ) + assert create_r.status_code == 201 + uid = create_r.json()["id"] + assert uid + + update_r = client.post( + "/api/users/update", + headers=_auth_header(auth_token), + json={"id": uid, "displayName": "E2E已修改", "role": "技经部"}, + ) + assert update_r.status_code == 200 + assert update_r.json().get("message") == "保存成功" + + delete_r = client.post( + "/api/users/delete", + headers=_auth_header(auth_token), + json={"id": uid}, + ) + assert delete_r.status_code == 200 + assert delete_r.json().get("message") == "删除成功" diff --git a/docs/产品文档.md b/docs/产品文档.md index 9d6b82b..4fb77c6 100644 --- a/docs/产品文档.md +++ b/docs/产品文档.md @@ -24,7 +24,8 @@ **其他**:结算后成本测算金额、结算人工费/材料费/其他费、到期应结算项目个数、到期未完成结算个数、存在的问题、建议措施、累计进度、备注。 -## 4. 项目统计 +## 4. 项目统计与导出 - **搜索**:按项目名称、项目编号。 - **筛选**:按项目进度、按项目费用。 +- **导出 XLS**:导出结果为**当前搜索条件下**的全部项目,不限于当前列表页;即与列表使用相同筛选条件,一次性导出所有匹配项目。 diff --git a/frontend/css/style.css b/frontend/css/style.css index 3489272..8e3dc58 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -193,116 +193,163 @@ body { /* ---------- 主应用 · 侧栏(类似播放器/歌单区) ---------- */ .app { display: flex; + flex-direction: column; min-height: 100vh; background: var(--bg-base); } -.sidebar { - width: 240px; - min-width: 240px; - background: var(--bg-elevated); - border-right: 1px solid var(--border); - color: var(--text); - display: flex; - flex-direction: column; - padding: var(--space-24) 0; -} - -.sidebar-brand { +/* ---------- 页面右上角用户栏(集成在 header-actions 内) ---------- */ +.topbar-user { display: flex; align-items: center; - gap: 0.75rem; - padding: 0 var(--space-24) var(--space-24); - border-bottom: 1px solid var(--border); - margin-bottom: var(--space-16); + gap: var(--space-16); + margin-left: var(--space-16); + padding-left: var(--space-16); + border-left: 1px solid var(--border); } -.brand-icon { - font-size: 1.25rem; - color: var(--accent-violet-light); - font-weight: 300; -} - -.brand-text { - font-family: var(--font-head); +.topbar-user .user-info { + font-size: 0.875rem; font-weight: 400; - font-size: 0.95rem; - letter-spacing: 0.02em; - color: var(--text); -} - -.sidebar-nav { - flex: 0 0 auto; -} - -.nav-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.65rem var(--space-24); color: var(--text-muted); - text-decoration: none; - transition: color 0.2s var(--ease), background 0.2s var(--ease); + margin: 0; + padding: 0; } -.nav-item:hover { - color: var(--text); - background: var(--bg-hover); -} - -.nav-item.active { - color: var(--accent-violet-light); - background: rgba(139, 92, 246, 0.12); - font-weight: 400; -} - -.nav-icon { - font-size: 1rem; - opacity: 0.85; - color: var(--text-dim); -} - -.nav-item.active .nav-icon { - color: var(--accent-violet-light); -} - -.sidebar-footer { - padding: var(--space-16) var(--space-24) 0; - border-top: 1px solid var(--border); -} - -.user-info { - display: block; - font-size: 0.75rem; - font-weight: 300; - color: var(--text-muted); - margin-bottom: var(--space-16); - padding: 0 var(--space-24); -} - -.btn-logout { - width: calc(100% - var(--space-24) * 2); - margin: 0 var(--space-24); - padding: 0.5rem; +.topbar-user .btn-logout { + width: auto; + margin: 0; + padding: 0.4rem 0.85rem; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-muted); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 400; cursor: pointer; transition: color 0.2s var(--ease), border-color 0.2s var(--ease), background 0.2s var(--ease); } -.btn-logout:hover { +.topbar-user .btn-logout:hover { color: var(--text); border-color: var(--accent-violet); background: var(--bg-hover); } +.topbar-user .topbar-link { + color: var(--accent-cyan); + text-decoration: none; + font-size: 0.875rem; + font-weight: 400; + transition: color 0.2s var(--ease); +} + +.topbar-user .topbar-link:hover { + color: var(--accent-violet-light); +} + +/* ---------- 用户弹窗 ---------- */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transition: opacity 0.25s var(--ease), visibility 0.25s var(--ease); + z-index: 200; +} + +.modal-overlay.visible { + opacity: 1; + visibility: visible; +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90%; + max-width: 400px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); + z-index: 201; + opacity: 0; + visibility: hidden; + transition: opacity 0.25s var(--ease), visibility 0.25s var(--ease); +} + +.modal.visible { + opacity: 1; + visibility: visible; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-24); + border-bottom: 1px solid var(--border); +} + +.modal-title { + font-family: var(--font-head); + font-size: 1.125rem; + font-weight: 400; + margin: 0; + color: var(--text); +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: var(--radius-sm); + font-size: 1.25rem; + color: var(--text-muted); + cursor: pointer; + transition: color 0.2s var(--ease), background 0.2s var(--ease); +} + +.modal-close:hover { + color: var(--text); + background: var(--bg-hover); +} + +.modal-form { + padding: var(--space-24); +} + +.modal-form .form-group { + margin-bottom: var(--space-16); +} + +.modal-form .form-group label { + display: block; + font-size: 0.8125rem; + margin-bottom: 0.35rem; + color: var(--text-muted); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-16); + margin-top: var(--space-24); + padding-top: var(--space-16); + border-top: 1px solid var(--border); +} + /* ---------- 主内容区 ---------- */ .main { flex: 1; + min-height: 0; overflow: auto; padding: var(--space-24) var(--space-32) var(--space-32); background: var(--bg-base); @@ -316,6 +363,157 @@ body { display: block; } +/* ---------- 统计页 ---------- */ +.view-statistics .page-header { + margin-bottom: var(--space-24); +} + +.stats-loading, +.stats-empty { + text-align: center; + padding: var(--space-32); + color: var(--text-muted); +} + +.stats-empty .empty-icon { + font-size: 3rem; + display: block; + margin-bottom: var(--space-16); +} + +.stats-desc { + margin: 0 0 var(--space-24); + font-size: 0.9375rem; + color: var(--text-muted); +} + +.stats-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--space-24); +} + +.stats-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-24); + box-shadow: var(--shadow-soft); +} + +.stats-card-wide { + grid-column: 1 / -1; + max-width: 560px; + justify-self: start; +} + +.stats-card-title { + font-family: var(--font-head); + font-size: 0.9375rem; + font-weight: 400; + color: var(--text-muted); + margin: 0 0 var(--space-16); +} + +.stats-card-value { + font-family: var(--font-head); + font-size: 1.75rem; + font-weight: 400; + color: var(--accent-cyan); + margin-bottom: var(--space-16); +} + +.stats-chart { + display: block; + max-width: 100%; + height: auto; +} + +.stats-chart-pie { + max-height: 260px; +} + +/* ---------- 全局统计页 ---------- */ +.view-global-stats .page-header { + margin-bottom: var(--space-16); +} + +.global-stats-desc { + margin: 0 0 var(--space-24); + font-size: 0.9375rem; + color: var(--text-muted); +} + +.global-stats-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: var(--space-24); + border-bottom: 1px solid var(--border); + padding-bottom: 0.5rem; +} + +.global-stats-tab { + padding: 0.5rem 1rem; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 0.875rem; + cursor: pointer; + transition: color 0.2s var(--ease), border-color 0.2s var(--ease), background 0.2s var(--ease); +} + +.global-stats-tab:hover { + color: var(--text); + border-color: var(--accent-violet); + background: var(--bg-hover); +} + +.global-stats-tab.active { + color: var(--accent-cyan); + border-color: var(--accent-violet); + background: var(--bg-hover); +} + +.global-stats-content { + min-height: 200px; +} + +.global-stats-loading, +.global-stats-empty { + text-align: center; + padding: var(--space-32); + color: var(--text-muted); +} + +.global-stats-empty .empty-icon { + font-size: 2.5rem; + display: block; + margin-bottom: var(--space-16); +} + +.global-stats-total { + margin: 0 0 var(--space-16); + font-size: 1rem; + color: var(--accent-cyan); +} + +.global-stats-charts-wrap { + margin-bottom: var(--space-24); +} + +.global-stats-charts-loading { + text-align: center; + padding: var(--space-16); + color: var(--text-muted); + font-size: 0.9375rem; +} + +.global-stats-cards { + margin-top: 0; +} + .page-header { display: flex; align-items: center; @@ -394,6 +592,22 @@ body { cursor: not-allowed; } +.btn-secondary { + background: var(--accent-teal); + border-color: transparent; + color: #fff; +} + +.btn-secondary:hover:not(:disabled) { + background: #0d9488; + box-shadow: 0 0 20px rgba(20, 184, 166, 0.35); +} + +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .btn-ghost { background: transparent; color: var(--text-muted); @@ -662,6 +876,13 @@ body { color: var(--text-muted); } +.pagination-page { + font-size: 0.8125rem; + font-weight: 300; + color: var(--text-muted); + margin-left: var(--space-16); +} + .pagination-btns { display: flex; gap: var(--space-16); @@ -938,33 +1159,6 @@ body { grid-template-columns: 1fr; } - .sidebar { - width: 64px; - min-width: 64px; - padding: var(--space-16) 0; - } - - .brand-text, - .nav-item span:not(.nav-icon), - .user-info, - .btn-logout { - display: none; - } - - .sidebar-brand { - justify-content: center; - padding: 0 var(--space-16) var(--space-16); - } - - .nav-item { - justify-content: center; - padding: 0.75rem; - } - - .sidebar-footer { - padding: var(--space-16); - } - .drawer { width: 100%; } diff --git a/frontend/index.html b/frontend/index.html index cdccf79..89cbaa4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,31 +33,28 @@