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 @@ + + +
@@ -283,6 +447,7 @@
+ diff --git a/frontend/js/app.js b/frontend/js/app.js index a2ffec5..ed58525 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -5,10 +5,11 @@ (function () { 'use strict'; - var PAGE_SIZE = 20; + var PAGE_SIZE = 50; var currentPage = 1; var totalCount = 0; var currentProjectId = null; + var currentList = []; var viewLogin = document.getElementById('view-login'); var appMain = document.getElementById('appMain'); @@ -18,14 +19,13 @@ var btnLogout = document.getElementById('btnLogout'); var userInfo = document.getElementById('userInfo'); var btnNewProject = document.getElementById('btnNewProject'); + var btnExportXls = document.getElementById('btnExportXls'); var navList = document.getElementById('navList'); var viewList = document.getElementById('view-list'); var viewDetail = document.getElementById('view-detail'); var searchType = document.getElementById('searchType'); var searchKeyword = document.getElementById('searchKeyword'); var btnSearch = document.getElementById('btnSearch'); - var filterProgress = document.getElementById('filterProgress'); - var filterCost = document.getElementById('filterCost'); var dateFilterType = document.getElementById('dateFilterType'); var dateFrom = document.getElementById('dateFrom'); var dateTo = document.getElementById('dateTo'); @@ -37,6 +37,7 @@ var projectTable = document.getElementById('projectTable'); var projectTableBody = document.getElementById('projectTableBody'); var paginationInfo = document.getElementById('paginationInfo'); + var paginationPage = document.getElementById('paginationPage'); var btnPrev = document.getElementById('btnPrev'); var btnNext = document.getElementById('btnNext'); var btnBack = document.getElementById('btnBack'); @@ -52,6 +53,52 @@ var logEmpty = document.getElementById('logEmpty'); var logList = document.getElementById('logList'); var toast = document.getElementById('toast'); + var topbarUserWrap = document.getElementById('topbarUserWrap'); + var listHeaderActions = document.getElementById('listHeaderActions'); + var detailHeaderActions = document.getElementById('detailHeaderActions'); + var viewUsers = document.getElementById('view-users'); + var usersHeaderActions = document.getElementById('usersHeaderActions'); + var btnUserManage = document.getElementById('btnUserManage'); + var btnBackFromUsers = document.getElementById('btnBackFromUsers'); + var btnAddUser = document.getElementById('btnAddUser'); + var usersLoading = document.getElementById('usersLoading'); + var usersEmpty = document.getElementById('usersEmpty'); + var usersTable = document.getElementById('usersTable'); + var usersTableBody = document.getElementById('usersTableBody'); + var userModalOverlay = document.getElementById('userModalOverlay'); + var userModal = document.getElementById('userModal'); + var userModalTitle = document.getElementById('userModalTitle'); + var userModalClose = document.getElementById('userModalClose'); + var userForm = document.getElementById('userForm'); + var userFormId = document.getElementById('userFormId'); + var userFormUsername = document.getElementById('userFormUsername'); + var userFormPassword = document.getElementById('userFormPassword'); + var userFormRole = document.getElementById('userFormRole'); + var userFormDisplayName = document.getElementById('userFormDisplayName'); + var userModalCancel = document.getElementById('userModalCancel'); + var btnStats = document.getElementById('btnStats'); + var btnBackFromStats = document.getElementById('btnBackFromStats'); + var viewStatistics = document.getElementById('view-statistics'); + var statsHeaderActions = document.getElementById('statsHeaderActions'); + var statsLoading = document.getElementById('statsLoading'); + var statsContent = document.getElementById('statsContent'); + var statsEmpty = document.getElementById('statsEmpty'); + var statsDesc = document.getElementById('statsDesc'); + var statsCount = document.getElementById('statsCount'); + var statsAmount = document.getElementById('statsAmount'); + var statsReceivable = document.getElementById('statsReceivable'); + var statsPayable = document.getElementById('statsPayable'); + var btnGlobalStats = document.getElementById('btnGlobalStats'); + var btnBackFromGlobalStats = document.getElementById('btnBackFromGlobalStats'); + var viewGlobalStats = document.getElementById('view-global-stats'); + var globalStatsHeaderActions = document.getElementById('globalStatsHeaderActions'); + var globalStatsTabs = document.getElementById('globalStatsTabs'); + var globalStatsLoading = document.getElementById('globalStatsLoading'); + var globalStatsBody = document.getElementById('globalStatsBody'); + var globalStatsTotal = document.getElementById('globalStatsTotal'); + var globalStatsTableBody = document.getElementById('globalStatsTableBody'); + var globalStatsEmpty = document.getElementById('globalStatsEmpty'); + var currentGlobalStatsType = '基建工程'; function showLogin() { if (viewLogin) viewLogin.classList.remove('hidden'); @@ -64,9 +111,23 @@ var u = window.API && window.API.getUser ? window.API.getUser() : null; if (userInfo && u) userInfo.textContent = (u.displayName || u.username || '') + ' · ' + (u.role || ''); updateNewProjectVisibility(); + updateAdminEntryVisibility(); loadList(); } + function isAdminRole() { + var u = window.API && window.API.getUser ? window.API.getUser() : null; + if (!u) return false; + var role = (u.role && typeof u.role === 'string') ? u.role.trim() : ''; + var username = (u.username && typeof u.username === 'string') ? u.username.trim().toLowerCase() : ''; + return role === '管理员' || username === 'admin'; + } + + function updateAdminEntryVisibility() { + if (!btnUserManage) return; + btnUserManage.style.display = isAdminRole() ? 'inline' : 'none'; + } + window.onAuthRequired = function () { showLogin(); }; @@ -93,9 +154,219 @@ function showList() { if (viewList) viewList.classList.add('active'); if (viewDetail) viewDetail.classList.remove('active'); + if (viewUsers) viewUsers.classList.remove('active'); + if (viewStatistics) viewStatistics.classList.remove('active'); + if (viewGlobalStats) viewGlobalStats.classList.remove('active'); if (navList) navList.classList.add('active'); document.querySelectorAll('.nav-item').forEach(function (n) { n.classList.remove('active'); }); if (navList) navList.classList.add('active'); + if (topbarUserWrap && listHeaderActions) listHeaderActions.appendChild(topbarUserWrap); + } + + function showUsers() { + if (viewList) viewList.classList.remove('active'); + if (viewDetail) viewDetail.classList.remove('active'); + if (viewUsers) viewUsers.classList.add('active'); + if (viewStatistics) viewStatistics.classList.remove('active'); + if (viewGlobalStats) viewGlobalStats.classList.remove('active'); + if (navList) navList.classList.remove('active'); + if (topbarUserWrap && usersHeaderActions) usersHeaderActions.appendChild(topbarUserWrap); + loadUsers(); + } + + function showGlobalStats() { + if (viewList) viewList.classList.remove('active'); + if (viewDetail) viewDetail.classList.remove('active'); + if (viewUsers) viewUsers.classList.remove('active'); + if (viewStatistics) viewStatistics.classList.remove('active'); + if (viewGlobalStats) viewGlobalStats.classList.add('active'); + if (navList) navList.classList.remove('active'); + if (topbarUserWrap && globalStatsHeaderActions) globalStatsHeaderActions.appendChild(topbarUserWrap); + currentGlobalStatsType = '基建工程'; + if (globalStatsTabs) { + globalStatsTabs.querySelectorAll('.global-stats-tab').forEach(function (t) { + t.classList.toggle('active', t.getAttribute('data-type') === currentGlobalStatsType); + }); + } + loadGlobalStatsTab(currentGlobalStatsType); + } + + function loadGlobalStatsTab(type) { + currentGlobalStatsType = type; + destroyGlobalStatsCharts(); + if (globalStatsLoading) globalStatsLoading.style.display = 'block'; + if (globalStatsBody) globalStatsBody.style.display = 'none'; + if (globalStatsEmpty) globalStatsEmpty.style.display = 'none'; + var globalStatsChartsLoading = document.getElementById('globalStatsChartsLoading'); + var globalStatsCards = document.getElementById('globalStatsCards'); + if (globalStatsChartsLoading) globalStatsChartsLoading.style.display = 'none'; + if (globalStatsCards) globalStatsCards.style.display = 'none'; + if (globalStatsTabs) { + globalStatsTabs.querySelectorAll('.global-stats-tab').forEach(function (t) { + t.classList.toggle('active', t.getAttribute('data-type') === type); + }); + } + + window.API.post('/api/projects/statistics', { statisticsType: type }).then(function (res) { + if (globalStatsLoading) globalStatsLoading.style.display = 'none'; + if (!res.ok) { + showToast(res.message || '加载失败', 'error'); + if (globalStatsEmpty) globalStatsEmpty.querySelector('p').textContent = res.message || '加载失败'; + if (globalStatsEmpty) globalStatsEmpty.style.display = 'block'; + return; + } + var total = (res.data && res.data.total) != null ? res.data.total : 0; + var list = (res.data && res.data.list) || []; + if (total === 0 && list.length === 0) { + if (globalStatsBody) globalStatsBody.style.display = 'none'; + if (globalStatsEmpty) globalStatsEmpty.style.display = 'block'; + return; + } + if (globalStatsTotal) globalStatsTotal.textContent = '项目数:' + (total != null ? total : list.length); + if (globalStatsBody) globalStatsBody.style.display = 'block'; + if (globalStatsEmpty) globalStatsEmpty.style.display = 'none'; + if (globalStatsTableBody) { + var rows = (list.length ? list : []).map(function (p) { + return ( + '' + + '' + (p.contractCode || '—') + '' + + '' + (p.projectName || '—') + '' + + '' + (p.bidContractAmount != null && p.bidContractAmount !== '' ? p.bidContractAmount : '—') + '' + + '' + (p.ownerUnit || '—') + '' + + '' + (p.signDate || '—') + '' + + '' + (p.projectDepartment || '—') + '' + + '' + (p.totalCost != null && p.totalCost !== '' ? p.totalCost : '—') + '' + + '' + (p.projectLeaderContact || '—') + '' + + '' + + ' ' + + '' + + '' + + '' + ); + }).join(''); + globalStatsTableBody.innerHTML = rows; + globalStatsTableBody.querySelectorAll('.link-action').forEach(function (btn) { + btn.addEventListener('click', function () { + var id = btn.getAttribute('data-id'); + var mode = btn.getAttribute('data-mode') || 'edit'; + showDetail(id, mode); + }); + }); + } + if (list.length === 0) return; + if (globalStatsChartsLoading) globalStatsChartsLoading.style.display = 'block'; + var chain = Promise.resolve([]); + list.forEach(function (p) { + chain = chain.then(function (acc) { + return window.API.post('/api/projects/detail', { id: p.id }).then(function (detailRes) { + if (detailRes.ok && detailRes.data) acc.push(detailRes.data); + return acc; + }); + }); + }); + return chain; + }).then(function (fullList) { + if (!fullList || fullList.length === 0) return; + var globalStatsChartsLoading = document.getElementById('globalStatsChartsLoading'); + var globalStatsCards = document.getElementById('globalStatsCards'); + var globalStatsCount = document.getElementById('globalStatsCount'); + var globalStatsAmount = document.getElementById('globalStatsAmount'); + var globalStatsReceivable = document.getElementById('globalStatsReceivable'); + var globalStatsPayable = document.getElementById('globalStatsPayable'); + if (globalStatsChartsLoading) globalStatsChartsLoading.style.display = 'none'; + if (globalStatsCards) globalStatsCards.style.display = ''; /* .stats-cards 已是 grid */ + + var count = fullList.length; + var totalAmount = 0; + var totalReceivable = 0; + var totalPayable = 0; + var progressMap = {}; + fullList.forEach(function (d) { + var amt = d.contract && d.contract.bidContractAmount != null && d.contract.bidContractAmount !== '' + ? parseFloat(String(d.contract.bidContractAmount)) : 0; + if (!isNaN(amt)) totalAmount += amt; + var recv = d.receivable && d.receivable.receivableProgress != null && d.receivable.receivableProgress !== '' + ? parseFloat(String(d.receivable.receivableProgress)) : 0; + if (!isNaN(recv)) totalReceivable += recv; + var pay = d.payable && d.payable.payableAmount != null && d.payable.payableAmount !== '' + ? parseFloat(String(d.payable.payableAmount)) : 0; + if (!isNaN(pay)) totalPayable += pay; + var prog = (d.other && d.other.cumulativeProgress) || (d.contract && d.contract.cumulativeProgress) || d.progress || ''; + if (typeof prog !== 'string') prog = String(prog); + prog = prog.trim() || '未填'; + progressMap[prog] = (progressMap[prog] || 0) + 1; + }); + + if (globalStatsCount) globalStatsCount.textContent = count; + if (globalStatsAmount) globalStatsAmount.textContent = totalAmount.toFixed(2); + if (globalStatsReceivable) globalStatsReceivable.textContent = totalReceivable.toFixed(2); + if (globalStatsPayable) globalStatsPayable.textContent = totalPayable.toFixed(2); + + var ChartLib = typeof Chart !== 'undefined' ? Chart : null; + if (!ChartLib) return; + var gridColor = 'rgba(148, 163, 184, 0.2)'; + var fontColor = '#94a3b8'; + var barColors = ['#8b5cf6', '#22d3ee', '#ec4899', '#f59e0b', '#14b8a6', '#a78bfa', '#06b6d4', '#f472b6']; + + var elCount = document.getElementById('chartGlobalCount'); + if (elCount) { + new ChartLib(elCount, { + type: 'bar', + data: { labels: ['项目个数'], datasets: [{ label: '个数', data: [count], backgroundColor: barColors[0] }] }, + options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } } + }); + } + var elAmount = document.getElementById('chartGlobalAmount'); + if (elAmount) { + new ChartLib(elAmount, { + type: 'bar', + data: { labels: ['合同金额(万元)'], datasets: [{ label: '合计', data: [totalAmount], backgroundColor: barColors[1] }] }, + options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } } + }); + } + var progressLabels = Object.keys(progressMap); + var progressData = progressLabels.map(function (k) { return progressMap[k]; }); + var pieColors = progressLabels.map(function (_, i) { return barColors[i % barColors.length]; }); + var elProgress = document.getElementById('chartGlobalProgress'); + if (elProgress && progressLabels.length > 0) { + new ChartLib(elProgress, { + type: 'pie', + data: { labels: progressLabels, datasets: [{ data: progressData, backgroundColor: pieColors }] }, + options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { position: 'right', labels: { color: fontColor } } } } + }); + } + var elReceivable = document.getElementById('chartGlobalReceivable'); + if (elReceivable) { + new ChartLib(elReceivable, { + type: 'bar', + data: { labels: ['应收账款(万元)'], datasets: [{ label: '合计', data: [totalReceivable], backgroundColor: barColors[2] }] }, + options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } } + }); + } + var elPayable = document.getElementById('chartGlobalPayable'); + if (elPayable) { + new ChartLib(elPayable, { + type: 'bar', + data: { labels: ['应付账款(万元)'], datasets: [{ label: '合计', data: [totalPayable], backgroundColor: barColors[3] }] }, + options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } } + }); + } + }).catch(function () { + if (globalStatsLoading) globalStatsLoading.style.display = 'none'; + showToast('加载失败', 'error'); + if (globalStatsEmpty) globalStatsEmpty.querySelector('p').textContent = '加载失败'; + if (globalStatsEmpty) globalStatsEmpty.style.display = 'block'; + }); + } + + function showStatistics() { + if (viewList) viewList.classList.remove('active'); + if (viewDetail) viewDetail.classList.remove('active'); + if (viewUsers) viewUsers.classList.remove('active'); + if (viewStatistics) viewStatistics.classList.add('active'); + if (navList) navList.classList.remove('active'); + if (topbarUserWrap && statsHeaderActions) statsHeaderActions.appendChild(topbarUserWrap); + loadStatistics(); } var detailMode = 'edit'; // 'view' | 'edit' @@ -110,11 +381,15 @@ detailMode = mode != null ? mode : (projectId ? 'edit' : 'edit'); if (viewList) viewList.classList.remove('active'); if (viewDetail) viewDetail.classList.add('active'); + if (viewUsers) viewUsers.classList.remove('active'); + if (viewStatistics) viewStatistics.classList.remove('active'); + if (viewGlobalStats) viewGlobalStats.classList.remove('active'); document.querySelectorAll('.nav-item').forEach(function (n) { n.classList.remove('active'); }); if (detailTitle) detailTitle.textContent = projectId ? '项目详情' : '新建项目'; if (detailLoading) detailLoading.style.display = 'block'; if (detailContent) detailContent.style.display = 'none'; updateDetailHeaderVisibility(detailMode); + if (topbarUserWrap && detailHeaderActions) detailHeaderActions.appendChild(topbarUserWrap); if (projectId) { window.API.post('/api/projects/detail', { id: projectId }).then(function (res) { @@ -203,8 +478,6 @@ pageSize: PAGE_SIZE, searchType: searchType && searchType.value ? searchType.value : undefined, keyword: searchKeyword && searchKeyword.value ? searchKeyword.value.trim() : undefined, - progress: filterProgress && filterProgress.value ? filterProgress.value : undefined, - cost: filterCost && filterCost.value ? filterCost.value : undefined, dateFilterType: dateFilterType && dateFilterType.value ? dateFilterType.value : undefined, dateFrom: dateFrom && dateFrom.value ? dateFrom.value : undefined, dateTo: dateTo && dateTo.value ? dateTo.value : undefined, @@ -224,6 +497,7 @@ return; } var list = (res.data && res.data.list) || []; + currentList = list; totalCount = (res.data && res.data.total) || 0; if (list.length === 0) { if (listEmpty) listEmpty.style.display = 'flex'; @@ -262,11 +536,470 @@ }); } if (paginationInfo) paginationInfo.textContent = '共 ' + totalCount + ' 条'; + var totalPages = totalCount > 0 ? Math.ceil(totalCount / PAGE_SIZE) : 1; + if (paginationPage) paginationPage.textContent = '第 ' + currentPage + ' / ' + totalPages + ' 页'; if (btnPrev) btnPrev.disabled = currentPage <= 1; if (btnNext) btnNext.disabled = currentPage * PAGE_SIZE >= totalCount; }); } + function escapeHtml(s) { + if (s == null || s === '') return ''; + var str = String(s); + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + var EXPORT_COLUMNS = [ + { section: 'contract', key: 'contractCode', label: '合同编号' }, + { section: 'contract', key: 'powerBureauContractCode', label: '供电局项目合同编号' }, + { section: 'contract', key: 'projectName', label: '项目名称' }, + { section: 'contract', key: 'subItemCount', label: '子项个数' }, + { section: 'contract', key: 'subItemCode', label: '子项编码' }, + { section: 'contract', key: 'totalInvestment', label: '项目总投资(万元)' }, + { section: 'contract', key: 'bidContractAmount', label: '中标合同金额(万元)' }, + { section: 'contract', key: 'warrantyRatio', label: '质保金比例' }, + { section: 'contract', key: 'settlementAmount', label: '结算金额(万元)' }, + { section: 'contract', key: 'totalCostEstimate', label: '总成本测算' }, + { section: 'contract', key: 'voltageLevel', label: '工程电压等级' }, + { section: 'contract', key: 'projectCategory', label: '工程类别' }, + { section: 'contract', key: 'ownerUnit', label: '业主单位' }, + { section: 'contract', key: 'ownerContact', label: '业主联系人及电话' }, + { section: 'contract', key: 'bidType', label: '中标形式' }, + { section: 'contract', key: 'signDate', label: '签订日期' }, + { section: 'contract', key: 'startDate', label: '开工日期' }, + { section: 'contract', key: 'plannedCompletionDate', label: '计划竣工日期' }, + { section: 'contract', key: 'actualCompletionDate', label: '实际竣工日期' }, + { section: 'contract', key: 'warrantyAmount', label: '质保金(万元)' }, + { section: 'contract', key: 'warrantyEndDate', label: '质保期截止日' }, + { section: 'contract', key: 'actualWarrantyRefundDate', label: '实际退质保金日期' }, + { section: 'contract', key: 'projectDepartment', label: '所属项目部' }, + { section: 'contract', key: 'projectLeaderContact', label: '项目负责人及电话' }, + { section: 'contract', key: 'paymentMethod', label: '工程款拨付方式' }, + { section: 'costControl', key: 'totalCost', label: '总体成本' }, + { section: 'costControl', key: 'isAdjusted', label: '是否调整' }, + { section: 'costControl', key: 'migrantWorkerPlan', label: '农民工工资(按进度计划)' }, + { section: 'costControl', key: 'migrantWorkerActual', label: '农民工工资(实付)' }, + { section: 'costControl', key: 'selfSupplyMaterialControl', label: '乙供材料费(控制)' }, + { section: 'costControl', key: 'materialPayableByRatio', label: '应付材料费(按收款比例)' }, + { section: 'costControl', key: 'materialActualOccurred', label: '实际发生材料费' }, + { section: 'costControl', key: 'materialActualPaid', label: '实际支付材料费' }, + { section: 'costControl', key: 'otherCostControl', label: '其他费用(控制)' }, + { section: 'costControl', key: 'otherPayable', label: '应付其他费' }, + { section: 'costControl', key: 'otherActual', label: '实际其他费用' }, + { section: 'costControl', key: 'tax', label: '税金' }, + { section: 'costControl', key: 'profit', label: '利润(万元)' }, + { section: 'costControl', key: 'actualProfit', label: '实际利润(万元)' }, + { section: 'costControl', key: 'costSettlementAmount', label: '成本结算金额(万元)' }, + { section: 'receivable', key: 'receivableProgress', label: '应收款(完成进度款)' }, + { section: 'receivable', key: 'invoiceAmount', label: '开票金额(万元)' }, + { section: 'receivable', key: 'actualReceiptAmount', label: '实际收款金额(万元)' }, + { section: 'receivable', key: 'actualReceiptRate', label: '实际收款完成率' }, + { section: 'payable', key: 'payableAmount', label: '应付款金额(万元)' }, + { section: 'payable', key: 'actualPaymentAmount', label: '实际付款金额(万元)' }, + { section: 'payable', key: 'unreceivedAmount', label: '未收款(万元)' }, + { section: 'payable', key: 'actualPaymentRate', label: '实际付款完成率' }, + { section: 'payable', key: 'migrantWorkerArrears', label: '民工工资清欠金额(万元)' }, + { section: 'other', key: 'settlementCostEstimate', label: '结算后成本测算金额' }, + { section: 'other', key: 'settlementLaborCost', label: '结算人工费' }, + { section: 'other', key: 'settlementMaterialCost', label: '结算材料费' }, + { section: 'other', key: 'settlementOtherCost', label: '结算其他费' }, + { section: 'other', key: 'dueSettlementCount', label: '到期应结算项目个数' }, + { section: 'other', key: 'overdueSettlementCount', label: '到期未完成结算个数' }, + { section: 'other', key: 'existingProblems', label: '存在的问题' }, + { section: 'other', key: 'suggestions', label: '建议措施' }, + { section: 'other', key: 'cumulativeProgress', label: '累计进度' }, + { section: 'other', key: 'remark', label: '备注' }, + ]; + + function getExportCellValue(project, col) { + var v; + if (col.section) { + var sec = project[col.section]; + v = sec && sec[col.key] !== undefined && sec[col.key] !== null ? sec[col.key] : ''; + } else { + v = project[col.key] !== undefined && project[col.key] !== null ? project[col.key] : ''; + } + return v === null || v === undefined ? '' : String(v); + } + + function exportToXls() { + showToast('正在导出…', ''); + // 导出 = 当前搜索条件下的全部项目,不限于当前页 + var listBody = { + page: 1, + pageSize: 99999, + searchType: searchType && searchType.value ? searchType.value : undefined, + keyword: searchKeyword && searchKeyword.value ? searchKeyword.value.trim() : undefined, + dateFilterType: dateFilterType && dateFilterType.value ? dateFilterType.value : undefined, + dateFrom: dateFrom && dateFrom.value ? dateFrom.value : undefined, + dateTo: dateTo && dateTo.value ? dateTo.value : undefined, + dateAbnormal: dateAbnormal && dateAbnormal.checked ? true : undefined, + amountMin: amountMin && amountMin.value !== '' ? parseFloat(amountMin.value) : undefined, + amountMax: amountMax && amountMax.value !== '' ? parseFloat(amountMax.value) : undefined, + }; + if (!listBody.keyword) listBody.searchType = undefined; + if (!listBody.dateFilterType) listBody.dateFrom = listBody.dateTo = undefined; + + window.API.post('/api/projects/list', listBody).then(function (res) { + if (!res.ok) { + showToast(res.message || '获取列表失败', 'error'); + return; + } + var list = (res.data && res.data.list) || []; + if (list.length === 0) { + showToast('当前搜索条件下无数据可导出', 'error'); + return; + } + var chain = Promise.resolve([]); + list.forEach(function (p) { + chain = chain.then(function (acc) { + return window.API.post('/api/projects/detail', { id: p.id }).then(function (detailRes) { + if (detailRes.ok && detailRes.data) acc.push(detailRes.data); + return acc; + }); + }); + }); + return chain; + }).then(function (fullList) { + if (!fullList || fullList.length === 0) return; + var headers = EXPORT_COLUMNS.map(function (c) { return c.label; }); + var rows = fullList.map(function (project) { + return EXPORT_COLUMNS.map(function (col) { + return getExportCellValue(project, col); + }); + }); + var tableRows = rows + .map(function (row) { + return ( + '' + + row.map(function (cell) { + return '' + escapeHtml(cell) + ''; + }).join('') + + '' + ); + }) + .join(''); + var headerRow = + '' + + headers.map(function (h) { + return '' + escapeHtml(h) + ''; + }).join('') + + ''; + var html = + '' + + '' + + '' + + headerRow + + tableRows + + '
'; + var str = html; + var bom = new Uint8Array([0xFF, 0xFE]); + var len = str.length; + var arr = new Uint8Array(bom.length + len * 2); + arr.set(bom, 0); + for (var i = 0; i < len; i++) { + var c = str.charCodeAt(i); + arr[bom.length + i * 2] = c & 0xFF; + arr[bom.length + i * 2 + 1] = (c >> 8) & 0xFF; + } + var blob = new Blob([arr], { type: 'application/vnd.ms-excel' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = '项目列表_' + new Date().toISOString().slice(0, 10) + '.xls'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast('已导出 ' + fullList.length + ' 条', 'success'); + }).catch(function () { + showToast('导出失败', 'error'); + }); + } + + function getStatsListBody() { + var body = { + page: 1, + pageSize: 99999, + searchType: searchType && searchType.value ? searchType.value : undefined, + keyword: searchKeyword && searchKeyword.value ? searchKeyword.value.trim() : undefined, + dateFilterType: dateFilterType && dateFilterType.value ? dateFilterType.value : undefined, + dateFrom: dateFrom && dateFrom.value ? dateFrom.value : undefined, + dateTo: dateTo && dateTo.value ? dateTo.value : undefined, + dateAbnormal: dateAbnormal && dateAbnormal.checked ? true : undefined, + amountMin: amountMin && amountMin.value !== '' ? parseFloat(amountMin.value) : undefined, + amountMax: amountMax && amountMax.value !== '' ? parseFloat(amountMax.value) : undefined, + }; + if (!body.keyword) body.searchType = undefined; + if (!body.dateFilterType) body.dateFrom = body.dateTo = undefined; + return body; + } + + function destroyStatsCharts() { + ['chartCount', 'chartAmount', 'chartProgress', 'chartReceivable', 'chartPayable'].forEach(function (id) { + var el = document.getElementById(id); + if (el && typeof Chart !== 'undefined' && Chart.getChart) { + var c = Chart.getChart(el); + if (c) c.destroy(); + } + }); + } + + function destroyGlobalStatsCharts() { + ['chartGlobalCount', 'chartGlobalAmount', 'chartGlobalProgress', 'chartGlobalReceivable', 'chartGlobalPayable'].forEach(function (id) { + var el = document.getElementById(id); + if (el && typeof Chart !== 'undefined' && Chart.getChart) { + var c = Chart.getChart(el); + if (c) c.destroy(); + } + }); + } + + function loadStatistics() { + if (statsLoading) statsLoading.style.display = 'block'; + if (statsContent) statsContent.style.display = 'none'; + if (statsEmpty) statsEmpty.style.display = 'none'; + destroyStatsCharts(); + + var listBody = getStatsListBody(); + window.API.post('/api/projects/list', listBody).then(function (res) { + if (!res.ok) { + if (statsLoading) statsLoading.style.display = 'none'; + showToast(res.message || '获取列表失败', 'error'); + if (statsEmpty) statsEmpty.querySelector('p').textContent = res.message || '获取列表失败'; + if (statsEmpty) statsEmpty.style.display = 'block'; + return; + } + var list = (res.data && res.data.list) || []; + if (list.length === 0) { + if (statsLoading) statsLoading.style.display = 'none'; + if (statsEmpty) statsEmpty.style.display = 'block'; + return; + } + var chain = Promise.resolve([]); + list.forEach(function (p) { + chain = chain.then(function (acc) { + return window.API.post('/api/projects/detail', { id: p.id }).then(function (detailRes) { + if (detailRes.ok && detailRes.data) acc.push(detailRes.data); + return acc; + }); + }); + }); + return chain; + }).then(function (fullList) { + if (statsLoading) statsLoading.style.display = 'none'; + if (!fullList || fullList.length === 0) return; + + var count = fullList.length; + var totalAmount = 0; + var totalReceivable = 0; + var totalPayable = 0; + var progressMap = {}; + fullList.forEach(function (d) { + var amt = d.contract && d.contract.bidContractAmount != null && d.contract.bidContractAmount !== '' + ? parseFloat(String(d.contract.bidContractAmount)) : 0; + if (!isNaN(amt)) totalAmount += amt; + var recv = d.receivable && d.receivable.receivableProgress != null && d.receivable.receivableProgress !== '' + ? parseFloat(String(d.receivable.receivableProgress)) : 0; + if (!isNaN(recv)) totalReceivable += recv; + var pay = d.payable && d.payable.payableAmount != null && d.payable.payableAmount !== '' + ? parseFloat(String(d.payable.payableAmount)) : 0; + if (!isNaN(pay)) totalPayable += pay; + var prog = (d.other && d.other.cumulativeProgress) || (d.contract && d.contract.cumulativeProgress) || d.progress || ''; + if (typeof prog !== 'string') prog = String(prog); + prog = prog.trim() || '未填'; + progressMap[prog] = (progressMap[prog] || 0) + 1; + }); + + if (statsCount) statsCount.textContent = count; + if (statsAmount) statsAmount.textContent = totalAmount.toFixed(2); + if (statsReceivable) statsReceivable.textContent = totalReceivable.toFixed(2); + if (statsPayable) statsPayable.textContent = totalPayable.toFixed(2); + + if (statsContent) statsContent.style.display = 'block'; + + var ChartLib = typeof Chart !== 'undefined' ? Chart : null; + if (!ChartLib) return; + + var gridColor = 'rgba(148, 163, 184, 0.2)'; + var fontColor = '#94a3b8'; + var barColors = ['#8b5cf6', '#22d3ee', '#ec4899', '#f59e0b', '#14b8a6', '#a78bfa', '#06b6d4', '#f472b6']; + + var elCount = document.getElementById('chartCount'); + if (elCount) { + new ChartLib(elCount, { + type: 'bar', + data: { labels: ['项目个数'], datasets: [{ label: '个数', data: [count], backgroundColor: barColors[0] }] }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { display: false } }, + scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } + } + }); + } + var elAmount = document.getElementById('chartAmount'); + if (elAmount) { + new ChartLib(elAmount, { + type: 'bar', + data: { labels: ['合同金额(万元)'], datasets: [{ label: '合计', data: [totalAmount], backgroundColor: barColors[1] }] }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { display: false } }, + scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } + } + }); + } + var progressLabels = Object.keys(progressMap); + var progressData = progressLabels.map(function (k) { return progressMap[k]; }); + var pieColors = progressLabels.map(function (_, i) { return barColors[i % barColors.length]; }); + var elProgress = document.getElementById('chartProgress'); + if (elProgress && progressLabels.length > 0) { + new ChartLib(elProgress, { + type: 'pie', + data: { labels: progressLabels, datasets: [{ data: progressData, backgroundColor: pieColors }] }, + options: { + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { position: 'right', labels: { color: fontColor } } } + } + }); + } + var elReceivable = document.getElementById('chartReceivable'); + if (elReceivable) { + new ChartLib(elReceivable, { + type: 'bar', + data: { labels: ['应收账款(万元)'], datasets: [{ label: '合计', data: [totalReceivable], backgroundColor: barColors[2] }] }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { display: false } }, + scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } + } + }); + } + var elPayable = document.getElementById('chartPayable'); + if (elPayable) { + new ChartLib(elPayable, { + type: 'bar', + data: { labels: ['应付账款(万元)'], datasets: [{ label: '合计', data: [totalPayable], backgroundColor: barColors[3] }] }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { display: false } }, + scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } + } + }); + } + }).catch(function () { + if (statsLoading) statsLoading.style.display = 'none'; + showToast('加载统计失败', 'error'); + if (statsEmpty) statsEmpty.querySelector('p').textContent = '加载统计失败'; + if (statsEmpty) statsEmpty.style.display = 'block'; + }); + } + + function loadUsers() { + if (usersLoading) usersLoading.style.display = 'block'; + if (usersEmpty) usersEmpty.style.display = 'none'; + if (usersTable) usersTable.style.visibility = 'hidden'; + window.API.post('/api/users/list', { page: 1, pageSize: 100 }).then(function (res) { + if (usersLoading) usersLoading.style.display = 'none'; + if (!res.ok) { + showToast(res.message || '加载失败', 'error'); + if (usersEmpty) { usersEmpty.style.display = 'flex'; usersEmpty.querySelector('p').textContent = res.message || '加载失败'; } + return; + } + var list = (res.data && res.data.list) || []; + if (list.length === 0) { + if (usersEmpty) { usersEmpty.style.display = 'flex'; usersEmpty.querySelector('p').textContent = '暂无用户'; } + if (usersTable) usersTable.style.visibility = 'hidden'; + } else { + if (usersEmpty) usersEmpty.style.display = 'none'; + if (usersTable) usersTable.style.visibility = 'visible'; + var html = list + .map(function (u) { + var updatedAt = u.updatedAt ? u.updatedAt.slice(0, 10) : '—'; + return ( + '' + + '' + (u.username || '—') + '' + + '' + (u.displayName || '—') + '' + + '' + (u.role || '—') + '' + + '' + updatedAt + '' + + '' + + ' ' + + '' + + '' + + '' + ); + }) + .join(''); + if (usersTableBody) usersTableBody.innerHTML = html; + if (usersTableBody) { + usersTableBody.querySelectorAll('.user-edit').forEach(function (btn) { + btn.addEventListener('click', function () { + var id = btn.getAttribute('data-id'); + var row = btn.closest('tr'); + var username = row && row.cells[0] ? row.cells[0].textContent : ''; + var displayName = row && row.cells[1] ? row.cells[1].textContent : ''; + var role = row && row.cells[2] ? row.cells[2].textContent : ''; + openUserModal(true, { id: id, username: username, displayName: displayName, role: role }); + }); + }); + usersTableBody.querySelectorAll('.user-delete').forEach(function (btn) { + btn.addEventListener('click', function () { + var id = btn.getAttribute('data-id'); + var username = btn.getAttribute('data-username') || id; + if (!window.confirm('确定要删除用户「' + username + '」吗?')) return; + window.API.post('/api/users/delete', { id: id }).then(function (res) { + if (res.ok) { + showToast('删除成功', 'success'); + loadUsers(); + } else { + showToast(res.message || '删除失败', 'error'); + } + }); + }); + }); + } + } + }); + } + + function openUserModal(isEdit, user) { + if (userModalTitle) userModalTitle.textContent = isEdit ? '修改用户' : '新增用户'; + if (userFormId) userFormId.value = user && user.id ? user.id : ''; + if (userFormUsername) { + userFormUsername.value = user && user.username ? user.username : ''; + userFormUsername.readOnly = !!isEdit; + } + if (userFormPassword) { + userFormPassword.value = ''; + userFormPassword.placeholder = isEdit ? '留空则不修改' : '请输入密码'; + userFormPassword.required = !isEdit; + } + if (userFormRole) { + userFormRole.value = user && user.role ? user.role : ''; + } + if (userFormDisplayName) userFormDisplayName.value = (user && user.displayName && user.displayName !== '—') ? user.displayName : ''; + if (userModalOverlay) { userModalOverlay.classList.add('visible'); userModalOverlay.setAttribute('aria-hidden', 'false'); } + if (userModal) userModal.classList.add('visible'); + } + + function closeUserModal() { + if (userModalOverlay) { userModalOverlay.classList.remove('visible'); userModalOverlay.setAttribute('aria-hidden', 'true'); } + if (userModal) userModal.classList.remove('visible'); + } + function bindTabs() { document.querySelectorAll('.tab').forEach(function (tab) { tab.addEventListener('click', function () { @@ -315,8 +1048,19 @@ if (btnSearch) btnSearch.addEventListener('click', function () { currentPage = 1; loadList(); }); if (searchKeyword) searchKeyword.addEventListener('keydown', function (e) { if (e.key === 'Enter') { currentPage = 1; loadList(); } }); - if (filterProgress) filterProgress.addEventListener('change', function () { currentPage = 1; loadList(); }); - if (filterCost) filterCost.addEventListener('change', function () { currentPage = 1; loadList(); }); + if (btnExportXls) btnExportXls.addEventListener('click', exportToXls); + if (btnStats) btnStats.addEventListener('click', showStatistics); + if (btnGlobalStats) btnGlobalStats.addEventListener('click', showGlobalStats); + if (btnBackFromStats) btnBackFromStats.addEventListener('click', function () { showList(); loadList(); }); + if (btnBackFromGlobalStats) btnBackFromGlobalStats.addEventListener('click', function () { showList(); loadList(); }); + if (globalStatsTabs) { + globalStatsTabs.querySelectorAll('.global-stats-tab').forEach(function (tab) { + tab.addEventListener('click', function () { + var type = tab.getAttribute('data-type'); + if (type) loadGlobalStatsTab(type); + }); + }); + } if (dateFilterType) dateFilterType.addEventListener('change', function () { currentPage = 1; loadList(); }); if (dateFrom) dateFrom.addEventListener('change', function () { currentPage = 1; loadList(); }); if (dateTo) dateTo.addEventListener('change', function () { currentPage = 1; loadList(); }); @@ -328,6 +1072,38 @@ if (btnNewProject) btnNewProject.addEventListener('click', function () { showDetail(null, 'edit'); }); if (btnBack) btnBack.addEventListener('click', function () { showList(); loadList(); }); if (navList) navList.addEventListener('click', function (e) { e.preventDefault(); showList(); }); + if (btnUserManage) btnUserManage.addEventListener('click', function (e) { e.preventDefault(); showUsers(); }); + if (btnBackFromUsers) btnBackFromUsers.addEventListener('click', function () { showList(); loadList(); }); + if (btnAddUser) btnAddUser.addEventListener('click', function () { openUserModal(false); }); + if (userModalClose) userModalClose.addEventListener('click', closeUserModal); + if (userModalCancel) userModalCancel.addEventListener('click', closeUserModal); + if (userModalOverlay) userModalOverlay.addEventListener('click', closeUserModal); + if (userForm) { + userForm.addEventListener('submit', function (e) { + e.preventDefault(); + var id = userFormId && userFormId.value ? userFormId.value.trim() : ''; + var username = userFormUsername && userFormUsername.value ? userFormUsername.value.trim() : ''; + var password = userFormPassword && userFormPassword.value ? userFormPassword.value : ''; + var role = userFormRole && userFormRole.value ? userFormRole.value : ''; + var displayName = userFormDisplayName && userFormDisplayName.value ? userFormDisplayName.value.trim() : ''; + if (!username) { showToast('请输入账号', 'error'); return; } + if (!id && !password) { showToast('请输入密码', 'error'); return; } + if (!role) { showToast('请选择角色', 'error'); return; } + if (id) { + var body = { id: id, role: role, displayName: displayName || undefined }; + if (password) body.password = password; + window.API.post('/api/users/update', body).then(function (res) { + if (res.ok) { showToast('保存成功', 'success'); closeUserModal(); loadUsers(); } + else showToast(res.message || '保存失败', 'error'); + }); + } else { + window.API.post('/api/users/create', { username: username, password: password, role: role, displayName: displayName || undefined }).then(function (res) { + if (res.ok) { showToast('创建成功', 'success'); closeUserModal(); loadUsers(); } + else showToast(res.message || '创建失败', 'error'); + }); + } + }); + } if (btnSave) { btnSave.addEventListener('click', function () {