所有需求实现完成

This commit is contained in:
Your Name
2026-02-01 15:57:42 +08:00
parent e651b92d7c
commit 6e1174a59a
16 changed files with 2354 additions and 173 deletions
+181 -10
View File
@@ -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 | 新建项目 | 已登录且市场部 |
+1
View File
@@ -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 '合同编号',
+28
View File
@@ -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` 表。
@@ -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()
@@ -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()
@@ -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()
+122
View File
@@ -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)):
+130
View File
@@ -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
+84 -3
View File
@@ -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()
+4
View File
@@ -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/updatedAtpage/pageSize 回显 |
| POST /api/projects/detail | 无 token 401、缺 id 400/422、存在项目返回完整五类字段、不存在 404 |
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400、校验失败可含 errors 数组 |
+63 -5
View File
@@ -10,6 +10,64 @@ def _auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ---------- POST /api/projects/statisticsAPI 文档第 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):
+252
View File
@@ -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") == "删除成功"
+2 -1
View File
@@ -24,7 +24,8 @@
**其他**:结算后成本测算金额、结算人工费/材料费/其他费、到期应结算项目个数、到期未完成结算个数、存在的问题、建议措施、累计进度、备注。
## 4. 项目统计
## 4. 项目统计与导出
- **搜索**:按项目名称、项目编号。
- **筛选**:按项目进度、按项目费用。
- **导出 XLS**:导出结果为**当前搜索条件下**的全部项目,不限于当前列表页;即与列表使用相同筛选条件,一次性导出所有匹配项目。
+302 -108
View File
@@ -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%;
}
+204 -39
View File
@@ -33,31 +33,28 @@
</section>
<div class="app" id="appMain" style="display: none;">
<aside class="sidebar">
<div class="sidebar-brand">
<span class="brand-icon"></span>
<span class="brand-text">项目管理系统</span>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" id="navList" data-nav="list">
<span class="nav-icon"></span>
<span>项目列表</span>
</a>
</nav>
<div class="sidebar-footer">
<span class="user-info" id="userInfo"></span>
<button type="button" class="btn-logout" id="btnLogout">退出</button>
</div>
</aside>
<main class="main">
<section id="view-list" class="view view-list active">
<header class="page-header">
<h1 class="page-title">项目列表</h1>
<div class="header-actions">
<div class="header-actions" id="listHeaderActions">
<button type="button" class="btn btn-secondary" id="btnExportXls" title="导出当前列表为 XLS 文件">
<span class="btn-icon"></span> 导出 XLS
</button>
<button type="button" class="btn btn-secondary" id="btnStats" title="按当前搜索条件统计并图表展示">
<span class="btn-icon">📊</span> 统计
</button>
<button type="button" class="btn btn-secondary" id="btnGlobalStats" title="调用后端统计 API 查看各类型项目">
<span class="btn-icon">📈</span> 全局统计
</button>
<button type="button" class="btn btn-primary btn-new-project" id="btnNewProject" style="display: none;">
<span class="btn-icon">+</span> 新建项目
</button>
<div id="topbarUserWrap" class="topbar-user">
<a href="#" class="topbar-link" id="btnUserManage" style="display: none;">用户管理</a>
<span class="user-info" id="userInfo"></span>
<button type="button" class="btn-logout" id="btnLogout">退出</button>
</div>
</div>
</header>
<div class="toolbar">
@@ -70,25 +67,6 @@
<button type="button" class="btn btn-search" id="btnSearch">搜索</button>
</div>
<div class="filter-row">
<div class="filter-group">
<label>项目进度</label>
<select id="filterProgress" class="input input-filter">
<option value="">全部</option>
<option value="planning">筹备中</option>
<option value="ongoing">进行中</option>
<option value="completed">已竣工</option>
<option value="settled">已结算</option>
</select>
</div>
<div class="filter-group">
<label>项目费用</label>
<select id="filterCost" class="input input-filter">
<option value="">全部</option>
<option value="normal">正常</option>
<option value="over">超支</option>
<option value="under">节余</option>
</select>
</div>
<div class="filter-group">
<label>日期筛选</label>
<select id="dateFilterType" class="input input-filter">
@@ -113,7 +91,7 @@
</div>
<div class="filter-row filter-row-amount">
<div class="filter-group">
<label>合同金额(万元)</label>
<label>中标合同金额(万元)</label>
<input type="number" id="amountMin" class="input input-amount" placeholder="最小" step="0.01" min="0" />
</div>
<div class="filter-group">
@@ -147,6 +125,7 @@
</div>
<div class="pagination" id="pagination">
<span class="pagination-info" id="paginationInfo">共 0 条</span>
<span class="pagination-page" id="paginationPage">第 1 / 1 页</span>
<div class="pagination-btns">
<button type="button" class="btn btn-sm" id="btnPrev" disabled>上一页</button>
<button type="button" class="btn btn-sm" id="btnNext">下一页</button>
@@ -154,11 +133,128 @@
</div>
</section>
<section id="view-statistics" class="view view-statistics">
<header class="page-header">
<button type="button" class="btn-back" id="btnBackFromStats">← 返回列表</button>
<h1 class="page-title">统计</h1>
<div class="header-actions" id="statsHeaderActions"></div>
</header>
<div class="stats-loading" id="statsLoading">正在加载符合当前搜索条件的项目数据…</div>
<div class="stats-content" id="statsContent" style="display: none;">
<p class="stats-desc" id="statsDesc">以下为当前搜索条件下的统计结果</p>
<div class="stats-cards">
<div class="stats-card">
<h3 class="stats-card-title">项目个数</h3>
<div class="stats-card-value" id="statsCount">0</div>
<canvas id="chartCount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">合计合同金额(万元)</h3>
<div class="stats-card-value" id="statsAmount">0</div>
<canvas id="chartAmount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card stats-card-wide">
<h3 class="stats-card-title">工程进度分布</h3>
<canvas id="chartProgress" class="stats-chart stats-chart-pie" width="360" height="240"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应收账款总额(万元)</h3>
<div class="stats-card-value" id="statsReceivable">0</div>
<canvas id="chartReceivable" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应付账款总额(万元)</h3>
<div class="stats-card-value" id="statsPayable">0</div>
<canvas id="chartPayable" class="stats-chart" width="280" height="120"></canvas>
</div>
</div>
</div>
<div class="stats-empty" id="statsEmpty" style="display: none;">
<span class="empty-icon">📊</span>
<p>当前搜索条件下暂无项目数据</p>
</div>
</section>
<section id="view-global-stats" class="view view-global-stats">
<header class="page-header">
<button type="button" class="btn-back" id="btnBackFromGlobalStats">← 返回列表</button>
<h1 class="page-title">全局统计</h1>
<div class="header-actions" id="globalStatsHeaderActions"></div>
</header>
<p class="global-stats-desc">按统计类型查看项目,数据来自后端统计 API。</p>
<div class="global-stats-tabs" id="globalStatsTabs" role="tablist">
<button type="button" class="tab global-stats-tab active" data-type="基建工程" role="tab">基建工程</button>
<button type="button" class="tab global-stats-tab" data-type="业扩项目" role="tab">业扩项目</button>
<button type="button" class="tab global-stats-tab" data-type="户表" role="tab">户表</button>
<button type="button" class="tab global-stats-tab" data-type="客户工程" role="tab">客户工程</button>
<button type="button" class="tab global-stats-tab" data-type="营销项目" role="tab">营销项目</button>
<button type="button" class="tab global-stats-tab" data-type="检修-技改-抢修" role="tab">检修-技改-抢修</button>
<button type="button" class="tab global-stats-tab" data-type="其他" role="tab">其他</button>
</div>
<div class="global-stats-content">
<div class="global-stats-loading" id="globalStatsLoading">加载中…</div>
<div class="global-stats-body" id="globalStatsBody" style="display: none;">
<p class="global-stats-total" id="globalStatsTotal">项目数:0</p>
<div class="global-stats-charts-wrap" id="globalStatsChartsWrap">
<div class="global-stats-charts-loading" id="globalStatsChartsLoading">加载图表数据…</div>
<div class="stats-cards global-stats-cards" id="globalStatsCards" style="display: none;">
<div class="stats-card">
<h3 class="stats-card-title">项目个数</h3>
<div class="stats-card-value" id="globalStatsCount">0</div>
<canvas id="chartGlobalCount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">合计合同金额(万元)</h3>
<div class="stats-card-value" id="globalStatsAmount">0</div>
<canvas id="chartGlobalAmount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card stats-card-wide">
<h3 class="stats-card-title">工程进度分布</h3>
<canvas id="chartGlobalProgress" class="stats-chart stats-chart-pie" width="360" height="240"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应收账款总额(万元)</h3>
<div class="stats-card-value" id="globalStatsReceivable">0</div>
<canvas id="chartGlobalReceivable" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应付账款总额(万元)</h3>
<div class="stats-card-value" id="globalStatsPayable">0</div>
<canvas id="chartGlobalPayable" class="stats-chart" width="280" height="120"></canvas>
</div>
</div>
</div>
<div class="table-wrap">
<table class="table" id="globalStatsTable">
<thead>
<tr>
<th>合同编号</th>
<th>项目名称</th>
<th>合同金额</th>
<th>业主单位</th>
<th>签订日期</th>
<th>所属项目部</th>
<th>总体成本</th>
<th>项目负责人及电话</th>
<th>操作</th>
</tr>
</thead>
<tbody id="globalStatsTableBody"></tbody>
</table>
</div>
</div>
<div class="global-stats-empty" id="globalStatsEmpty" style="display: none;">
<span class="empty-icon">📋</span>
<p>该类型暂无项目</p>
</div>
</div>
</section>
<section id="view-detail" class="view view-detail">
<header class="page-header">
<button type="button" class="btn-back" id="btnBack">← 返回列表</button>
<h1 class="page-title detail-title" id="detailTitle">项目详情</h1>
<div class="header-actions">
<div class="header-actions" id="detailHeaderActions">
<button type="button" class="btn btn-ghost" id="btnViewLog">查看操作日志</button>
<button type="button" class="btn btn-primary" id="btnSave">
<span class="btn-text">保存</span>
@@ -259,9 +355,77 @@
</div>
</div>
</section>
<section id="view-users" class="view view-users">
<header class="page-header">
<button type="button" class="btn-back" id="btnBackFromUsers">← 返回列表</button>
<h1 class="page-title">用户管理</h1>
<div class="header-actions" id="usersHeaderActions">
<button type="button" class="btn btn-primary" id="btnAddUser">+ 新增用户</button>
</div>
</header>
<div class="table-wrap">
<div class="table-loading" id="usersLoading">加载中…</div>
<div class="table-empty" id="usersEmpty" style="display: none;">
<span class="empty-icon">👤</span>
<p>暂无用户</p>
</div>
<table class="table" id="usersTable">
<thead>
<tr>
<th>账号</th>
<th>显示名称</th>
<th>角色</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="usersTableBody"></tbody>
</table>
</div>
</section>
</main>
</div>
<div class="modal-overlay" id="userModalOverlay" aria-hidden="true"></div>
<div class="modal" id="userModal">
<div class="modal-header">
<h2 class="modal-title" id="userModalTitle">新增用户</h2>
<button type="button" class="modal-close" id="userModalClose">×</button>
</div>
<form id="userForm" class="modal-form">
<input type="hidden" id="userFormId" value="" />
<div class="form-group">
<label for="userFormUsername">账号 <em>*</em></label>
<input type="text" id="userFormUsername" class="input" required placeholder="唯一账号" />
</div>
<div class="form-group">
<label for="userFormPassword">密码 <em>*</em></label>
<input type="password" id="userFormPassword" class="input" placeholder="留空则不修改(编辑时)" />
</div>
<div class="form-group">
<label for="userFormRole">角色 <em>*</em></label>
<select id="userFormRole" class="input" required>
<option value="">请选择</option>
<option value="管理员">管理员</option>
<option value="市场部">市场部</option>
<option value="工程部">工程部</option>
<option value="技经部">技经部</option>
<option value="财务部">财务部</option>
<option value="物贸部">物贸部</option>
</select>
</div>
<div class="form-group">
<label for="userFormDisplayName">显示名称</label>
<input type="text" id="userFormDisplayName" class="input" placeholder="可选" />
</div>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" id="userModalCancel">取消</button>
<button type="submit" class="btn btn-primary" id="userFormSubmit">保存</button>
</div>
</form>
</div>
<div class="drawer-overlay" id="logOverlay" aria-hidden="true"></div>
<div class="drawer" id="logDrawer">
<div class="drawer-header">
@@ -283,6 +447,7 @@
<!-- Agentation 可视化反馈工具栏(https://github.com/benjitaylor/agentation -->
<div id="agentation-root"></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script type="module" src="dist/agentation.js"></script>
+783 -7
View File
@@ -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 (
'<tr>' +
'<td>' + (p.contractCode || '—') + '</td>' +
'<td>' + (p.projectName || '—') + '</td>' +
'<td>' + (p.bidContractAmount != null && p.bidContractAmount !== '' ? p.bidContractAmount : '—') + '</td>' +
'<td>' + (p.ownerUnit || '—') + '</td>' +
'<td>' + (p.signDate || '—') + '</td>' +
'<td>' + (p.projectDepartment || '—') + '</td>' +
'<td>' + (p.totalCost != null && p.totalCost !== '' ? p.totalCost : '—') + '</td>' +
'<td>' + (p.projectLeaderContact || '—') + '</td>' +
'<td class="cell-actions">' +
'<button type="button" class="link-action" data-id="' + p.id + '" data-mode="view">查看</button> ' +
'<button type="button" class="link-action" data-id="' + p.id + '" data-mode="edit">编辑</button>' +
'</td>' +
'</tr>'
);
}).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 (
'<tr>' +
row.map(function (cell) {
return '<td>' + escapeHtml(cell) + '</td>';
}).join('') +
'</tr>'
);
})
.join('');
var headerRow =
'<tr>' +
headers.map(function (h) {
return '<th>' + escapeHtml(h) + '</th>';
}).join('') +
'</tr>';
var html =
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">' +
'<head><meta http-equiv="Content-Type" content="text/html; charset=utf-16"/><style>table{border-collapse:collapse;} th,td{border:1px solid #333; padding:4px 8px;}</style></head>' +
'<body><table>' +
headerRow +
tableRows +
'</table></body></html>';
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 (
'<tr>' +
'<td>' + (u.username || '—') + '</td>' +
'<td>' + (u.displayName || '—') + '</td>' +
'<td>' + (u.role || '—') + '</td>' +
'<td>' + updatedAt + '</td>' +
'<td class="cell-actions">' +
'<button type="button" class="link-action user-edit" data-id="' + u.id + '">编辑</button> ' +
'<button type="button" class="link-action user-delete" data-id="' + u.id + '" data-username="' + (u.username || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</td>' +
'</tr>'
);
})
.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 () {