API 第一版定型

This commit is contained in:
Your Name
2026-01-31 00:01:19 +08:00
commit 588e3bb478
8 changed files with 2242 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
# 项目管理系统 API 文档
**依据**:《产品文档》《交互文档》《初始需求》
**读者**:后端/前端工程师
**版本**v1
---
## 1. 概述
- **基础路径**`/api`(按实际部署约定)
- **请求方式****全部采用 POST**
- **认证方式**:登录后使用 Token(如 Bearer Token 或 Session),请求头携带,具体与现有账号体系对齐
- **数据格式**:请求体与响应体均为 JSON,编码 UTF-8Content-Type: application/json
- **角色**:管理员、市场部、工程部、技经部、财务部、物贸部;新建项目仅市场部可调,查看/修改所有角色可调
---
## 2. 认证
### 2.1 登录
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/auth/login` |
| **说明** | 用户登录,成功返回 Token 及用户信息(含角色) |
| **权限** | 无需登录 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 账号 |
| password | string | 是 | 密码 |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| token | string | 登录凭证,后续请求需携带 |
| user | object | 当前用户信息 |
| user.id | string | 用户 ID |
| user.username | string | 账号 |
| user.role | string | 角色:管理员 / 市场部 / 工程部 / 技经部 / 财务部 / 物贸部 |
| user.displayName | string | 显示名称 |
#### 响应参数(失败,HTTP 401
| 参数名 | 类型 | 说明 |
|--------|------|------|
| code | number | 错误码,401 |
| message | string | 错误信息,如「账号或密码错误」 |
---
## 3. 项目列表
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/projects/list` |
| **说明** | 分页获取项目列表,支持按名称/编号搜索、按进度/费用筛选 |
| **权限** | 已登录,所有角色可访问 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| page | number | 否 | 页码,从 1 开始,默认 1 |
| pageSize | number | 否 | 每页条数,默认 20 |
| searchType | string | 否 | 搜索维度:`name`(项目名称)、`code`(项目编号) |
| keyword | string | 否 | 搜索关键词,与 searchType 配合使用 |
| progress | string | 否 | 项目进度筛选,取值与业务枚举一致 |
| cost | string | 否 | 项目费用筛选,取值与业务枚举一致 |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| list | array | 项目列表,无数据时为空数组 |
| list[].id | string | 项目 ID |
| list[].projectName | string | 项目名称 |
| list[].contractCode | string | 合同编号 |
| list[].progress | string | 项目进度 |
| list[].cost | string | 项目费用 |
| list[].updatedAt | string | 最后更新时间,ISO8601 |
| total | number | 总条数,无数据时为 0 |
| page | number | 当前页码 |
| pageSize | number | 每页条数 |
---
## 4. 项目详情
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/projects/detail` |
| **说明** | 获取单个项目完整信息(5 个分类) |
| **权限** | 已登录,所有角色可访问 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | string | 是 | 项目 ID |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| id | string | 项目 ID |
| contract | object | 合同信息,字段见第 8.1 节 |
| costControl | object | 成本控制,字段见第 8.2 节 |
| receivable | object | 应收款,字段见第 8.3 节 |
| payable | object | 应付款,字段见第 8.4 节 |
| other | object | 其他,字段见第 8.5 节 |
| createdAt | string | 创建时间,ISO8601 |
| updatedAt | string | 更新时间,ISO8601 |
#### 响应参数(失败,HTTP 404
| 参数名 | 类型 | 说明 |
|--------|------|------|
| code | number | 错误码,404 |
| message | string | 错误信息,如「项目不存在」 |
---
## 5. 新建项目
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/projects/create` |
| **说明** | 新建项目,仅市场部可调用 |
| **权限** | 已登录,角色为市场部;非市场部返回 403 |
#### 请求参数
请求体为项目 5 个分类的完整或部分数据,未传字段由后端赋默认值。结构见第 8 节「项目信息字段(5 个分类)」;**新建时合同编号、项目名称为必填项**。
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| contract | object | 是 | 合同信息,字段见 8.1;其中 contractCode、projectName 必填 |
| contract.contractCode | string | 是 | 合同编号 |
| contract.projectName | string | 是 | 项目名称 |
| costControl | object | 否 | 成本控制,字段见 8.2 |
| receivable | object | 否 | 应收款,字段见 8.3 |
| payable | object | 否 | 应付款,字段见 8.4 |
| other | object | 否 | 其他,字段见 8.5 |
#### 响应参数(成功,HTTP 201
| 参数名 | 类型 | 说明 |
|--------|------|------|
| id | string | 新创建的项目 ID |
| message | string | 固定为「创建成功」 |
#### 响应参数(失败,HTTP 400
| 参数名 | 类型 | 说明 |
|--------|------|------|
| code | number | 错误码,400 |
| message | string | 错误信息,如「合同编号不能为空」「项目名称不能为空」 |
| errors | array | 可选,见第 10 节 |
#### 响应参数(失败,HTTP 403
| 参数名 | 类型 | 说明 |
|--------|------|------|
| code | number | 错误码,403 |
| message | string | 错误信息,如「无权限」 |
---
## 6. 更新项目
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/projects/update` |
| **说明** | 更新项目全部或部分信息;每次保存由后端记录一条操作日志 |
| **权限** | 已登录,所有角色可访问 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | string | 是 | 项目 ID |
| contract | object | 否 | 合同信息,传则按约定全量/合并更新,字段见 8.1 |
| costControl | object | 否 | 成本控制,字段见 8.2 |
| receivable | object | 否 | 应收款,字段见 8.3 |
| payable | object | 否 | 应付款,字段见 8.4 |
| other | object | 否 | 其他,字段见 8.5 |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| id | string | 项目 ID |
| message | string | 固定为「保存成功」 |
#### 响应参数(失败)
| HTTP 状态 | 参数名 | 类型 | 说明 |
|-----------|--------|------|------|
| 404 | code | number | 错误码,404 |
| 404 | message | string | 如「项目不存在」 |
---
## 7. 操作日志
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/projects/logs` |
| **说明** | 获取某项目的修改记录(操作人、时间、修改摘要) |
| **权限** | 已登录,所有角色可访问 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | string | 是 | 项目 ID |
| page | number | 否 | 页码,从 1 开始,默认 1 |
| pageSize | number | 否 | 每页条数,默认 20 |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| list | array | 操作日志列表,无记录时为空数组 |
| list[].id | string | 日志 ID |
| list[].operatorId | string | 操作人 ID |
| list[].operatorName | string | 操作人姓名 |
| list[].operatedAt | string | 操作时间,ISO8601 |
| list[].summary | string | 修改摘要,如「合同信息」「成本控制」 |
| list[].detail | string | 可选,修改内容详情 |
| total | number | 总条数,无记录时为 0 |
| page | number | 当前页码 |
| pageSize | number | 每页条数 |
#### 响应参数(失败,HTTP 404
| 参数名 | 类型 | 说明 |
|--------|------|------|
| code | number | 错误码,404 |
| message | string | 如「项目不存在」 |
---
## 8. 项目信息字段(5 个分类)
以下为各分类在 **请求/响应** 中使用的字段定义;单位、是否必填、校验规则由后端统一规定,前端与后端对齐。
### 8.1 合同信息 `contract`
| 字段名 | 类型 | 说明 |
|--------|------|------|
| serialNo | string | 序号 |
| contractCode | string | 合同编号 |
| powerBureauContractCode | string | 供电局项目合同编号 |
| projectName | string | 项目名称 |
| subItemCount | number | 子项个数 |
| subItemCode | string | 子项编码 |
| totalInvestment | number | 项目总投资(万元) |
| bidContractAmount | number | 中标合同金额(万元) |
| warrantyRatio | string \| number | 质保金比例 |
| settlementAmount | number | 结算金额(万元) |
| totalCostEstimate | number | 总成本测算 |
| voltageLevel | string | 工程电压等级 |
| projectCategory | string | 工程类别 |
| ownerUnit | string | 业主单位 |
| ownerContact | string | 业主联系人及电话 |
| bidType | string | 中标形式 |
| signDate | string | 签订日期(YYYY-MM-DD |
| startDate | string | 开工日期 |
| plannedCompletionDate | string | 计划竣工日期 |
| actualCompletionDate | string | 实际竣工日期 |
| warrantyAmount | number | 质保金(万元) |
| warrantyEndDate | string | 质保期截止日 |
| actualWarrantyRefundDate | string | 实际退质保金日期 |
| projectDepartment | string | 所属项目部 |
| projectLeaderContact | string | 项目负责人及电话 |
| paymentMethod | string | 工程款拨付方式 |
### 8.2 成本控制 `costControl`
| 字段名 | 类型 | 说明 |
|--------|------|------|
| totalCost | number | 总体成本 |
| isAdjusted | string | 是否调整:是/否 |
| migrantWorkerPlan | string \| number | 农民工工资(按进度计划) |
| migrantWorkerActual | number | 农民工工资(实付) |
| selfSupplyMaterialControl | number | 乙供材料费(控制) |
| materialPayableByRatio | number | 应付材料费(按收款比例) |
| materialActualOccurred | number | 实际发生材料费 |
| materialActualPaid | number | 实际支付材料费 |
| otherCostControl | number | 其他费用(控制) |
| otherPayable | number | 应付其他费 |
| otherActual | number | 实际其他费用 |
| tax | number | 税金 |
| profit | number | 利润(万元) |
| actualProfit | number | 实际利润(万元) |
| costSettlementAmount | number | 成本结算金额(万元) |
### 8.3 应收款 `receivable`
| 字段名 | 类型 | 说明 |
|--------|------|------|
| receivableProgress | number | 应收款(完成进度款) |
| invoiceAmount | number | 开票金额(万元) |
| actualReceiptAmount | number | 实际收款金额(万元) |
| actualReceiptRate | string \| number | 实际收款完成率 |
### 8.4 应付款 `payable`
| 字段名 | 类型 | 说明 |
|--------|------|------|
| payableAmount | number | 应付款金额(万元) |
| actualPaymentAmount | number | 实际付款金额(万元) |
| unreceivedAmount | number | 未收款(万元) |
| actualPaymentRate | string \| number | 实际付款完成率 |
| migrantWorkerArrears | number | 民工工资清欠金额(万元) |
### 8.5 其他 `other`
| 字段名 | 类型 | 说明 |
|--------|------|------|
| settlementCostEstimate | number | 结算后成本测算金额 |
| settlementLaborCost | number | 结算人工费 |
| settlementMaterialCost | number | 结算材料费 |
| settlementOtherCost | number | 结算其他费 |
| dueSettlementCount | number | 到期应结算项目个数 |
| overdueSettlementCount | number | 到期未完成结算个数 |
| existingProblems | string | 存在的问题 |
| suggestions | string | 建议措施 |
| cumulativeProgress | string \| number | 累计进度 |
| remark | string | 备注 |
---
## 9. 项目列表/筛选枚举
以下由后端定义并提供给前端(如通过配置接口或文档约定):
- **项目进度 progress**:枚举值列表,如「未开始 / 进行中 / 已竣工」等
- **项目费用 cost**:枚举值列表,如按金额区间的选项
具体取值以实际业务与后端实现为准。
---
## 10. 统一错误响应
| HTTP 状态 | 说明 |
|-----------|------|
| 400 | 请求参数错误 |
| 401 | 未登录或 Token 无效 |
| 403 | 无权限(如非市场部调用新建项目) |
| 404 | 资源不存在(如项目 ID 不存在) |
| 500 | 服务器内部错误 |
#### 错误响应体参数
| 参数名 | 类型 | 说明 |
|--------|------|------|
| code | number | HTTP 状态码或业务错误码 |
| message | string | 错误描述 |
| errors | array | 可选,参数校验详情 |
| errors[].field | string | 出错字段名 |
| errors[].message | string | 该字段错误说明 |
**示例**
```json
{
"code": 400,
"message": "参数校验失败",
"errors": [
{ "field": "projectName", "message": "项目名称不能为空" }
]
}
```
---
## 11. 接口一览
| 接口 | 方法 | 说明 | 权限 |
|------|------|------|------|
| /api/auth/login | POST | 登录 | 公开 |
| /api/projects/list | POST | 项目列表(搜索、筛选、分页) | 已登录 |
| /api/projects/detail | POST | 项目详情 | 已登录 |
| /api/projects/create | POST | 新建项目 | 已登录且市场部 |
| /api/projects/update | POST | 更新项目(记操作日志) | 已登录 |
| /api/projects/logs | POST | 操作日志列表 | 已登录 |
字段命名采用驼峰;日期格式建议 ISO8601 或 `YYYY-MM-DD`,前后端约定一致。
+137
View File
@@ -0,0 +1,137 @@
# 交互文档(前端)
**依据**:《产品文档》
**读者**:前端工程师
**目标**:明确页面数量、每个页面的功能与用户交互,便于实现与联调。
---
## 1. 页面总览
| 序号 | 页面名称 | 说明 |
|------|--------------|------|
| 1 | 登录页 | 用户登录入口(若与现有系统统一,可复用现有登录页) |
| 2 | 项目列表页 | 项目列表的入口页,支持搜索、筛选、进入详情、新建项目 |
| 3 | 项目详情/编辑页 | 查看/编辑单个项目信息,含 5 个 Tab;新建项目时也为本页(空表单) |
| 4 | 操作日志 | 查看某项目的修改记录,可为抽屉或独立页 |
**合计:4 个页面(或 3 个主页面 + 操作日志以抽屉形式承载)。**
---
## 2. 角色与权限(影响各页显隐)
| 角色 | 新建项目 | 查看/编辑项目 |
|------|----------|----------------|
| 市场部 | ✓ | ✓ |
| 管理员、工程部、技经部、财务部、物贸部 | ✗ | ✓ |
- **新建项目**:仅市场部展示入口;其他角色不展示或入口不可用。
- **查看/编辑**:所有角色均可进入项目列表、项目详情,并可编辑、保存;每次保存由后端记录一条操作日志。
- 前端需按当前用户角色控制按钮/入口的**显隐与禁用**。
---
## 3. 各页面功能与用户交互
### 页面 1:登录页
- **功能**:用户登录,进入系统。若项目与现有账号体系统一,则复用现有登录页;否则需单独实现登录页。
- **用户交互**
- 用户输入账号、密码(等必填项),点击「登录」。
- 成功:跳转至**项目列表页**。
- 失败:页内提示错误信息,不跳转。
---
### 页面 2:项目列表页
- **功能**:展示项目列表,支持按名称/编号搜索、按进度/费用筛选;进入项目详情或新建项目。
- **用户交互**
| 用户操作 | 系统反馈 |
|----------|----------|
| 进入页面 | 请求列表数据;请求中显示 Loading;成功则展示表格;无数据时展示空状态文案。 |
| 选择搜索维度(项目名称 / 项目编号),输入关键词,点击「搜索」或回车 | 按当前维度和关键词请求列表;请求中可保留原列表或局部 Loading;结果替换列表;无结果时展示空状态。 |
| 选择「项目进度」筛选 | 按所选进度请求列表并刷新表格;无结果时展示空状态。 |
| 选择「项目费用」筛选 | 按所选费用请求列表并刷新表格;无结果时展示空状态。 |
| 点击某一行的「查看」或「编辑」 | 跳转至**项目详情/编辑页**,并传入该项目 ID;详情加载中显示 Loading。 |
| 点击「新建项目」(仅市场部可见) | 跳转至**项目详情/编辑页**,不传项目 ID(新建模式);页面展示 5 个 Tab 的空表单。 |
| 分页操作(若有) | 请求对应页数据并刷新列表。 |
- **列表展示**:至少包含项目名称、合同编号、进度、费用等关键信息列,以及操作列(查看/编辑)。具体列与后端接口字段一致。
---
### 页面 3:项目详情/编辑页
- **功能**:查看或编辑单个项目的全部信息(5 个分类);新建时为本页空表单。所有有权限角色均可编辑并保存,保存由后端记操作日志。
- **用户交互**
| 用户操作 | 系统反馈 |
|----------|----------|
| 进入页面(带项目 ID) | 请求该项目详情;请求中显示 Loading;成功后在当前页展示 5 个 Tab 及表单内容。 |
| 进入页面(新建,无项目 ID) | 直接展示 5 个 Tab 的空表单,无 Loading。 |
| 切换 Tab(合同信息 / 成本控制 / 应收款 / 应付款 / 其他) | 切换当前展示的 Tab 内容,无请求。 |
| 填写或修改表单字段 | 仅前端状态变化;可做前端校验(与后端约定)。 |
| 点击「保存」 | 提交中:保存按钮 Loading,禁止再次点击;成功:提示「保存成功」(如 Toast),新建时可选跳转至该详情或列表;失败:提示接口返回错误信息,不关闭当前页。 |
| 点击「返回」或等价入口 | 返回**项目列表页**;是否保留列表页上次的搜索/筛选状态与产品约定。 |
| 点击「查看操作日志」 | 打开**操作日志**(抽屉或新页),展示当前项目的修改记录。 |
- **5 个 Tab**:合同信息、成本控制、应收款、应付款、其他。每个 Tab 内为该分类下的表单字段,字段与《产品文档》一致,单位与校验与后端对齐。字段清单见本文第 5 节。
---
### 页面 4:操作日志
- **功能**:展示某一项目的修改记录(操作人、时间、修改摘要等),仅查看,无编辑。
- **承载形式**:可为详情页右侧**抽屉**,或**独立页面**;与产品/前端约定一致即可。
- **用户交互**
| 用户操作 | 系统反馈 |
|----------|----------|
| 打开操作日志(从详情页入口) | 请求该项目下的操作日志;请求中显示 Loading;成功则展示记录列表;无记录时展示空状态文案。 |
| 关闭抽屉 / 返回 | 关闭抽屉或返回详情页/列表页。 |
- **列表内容**:操作人、操作时间、修改内容摘要(如「合同信息」「成本控制」等),具体字段与后端接口一致。
---
## 4. 全局状态与异常
| 场景 | 交互反馈 |
|------|----------|
| 列表/详情/日志请求中 | 明确 Loading 态(整页或局部)。 |
| 保存提交中 | 保存按钮 Loading,禁止再次点击。 |
| 保存成功 | 统一成功提示(如 Toast)。 |
| 保存或接口失败 | 页内或 Toast 提示错误信息,不关闭当前页。 |
| 无权限(如 403) | 提示无权限,可引导返回列表。 |
| 列表无数据 / 无搜索结果 | 空状态文案,无表格内容。 |
| 操作日志无记录 | 空状态文案。 |
---
## 5. 项目信息字段(5 个 Tab)
详情/编辑页按 5 个 Tab 组织,字段与《产品文档》一致;单位、校验、是否必填与后端对齐。
- **合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订日期、开工日期、计划竣工日期、实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
- **成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
- **应收款**:应收款(完成进度款)、开票金额、实际收款金额、实际收款完成率。
- **应付款**:应付款金额、实际付款金额、未收款、实际付款完成率、民工工资清欠金额。
- **其他**:结算后成本测算金额、结算人工费/材料费/其他费、到期应结算项目个数、到期未完成结算个数、存在的问题、建议措施、累计进度、备注。
---
## 6. 小结(给前端)
- **页面数**:4 个(登录、项目列表、项目详情/编辑、操作日志;操作日志可为抽屉)。
- **权限**:仅市场部展示「新建项目」;所有角色可查看、编辑、保存;保存由后端记日志,前端仅提供「查看操作日志」入口与展示。
- **搜索**:项目名称、项目编号;触发方式与后端约定(如回车/点击搜索)。
- **筛选**:项目进度、项目费用;选项与列表接口参数一致。
- **详情页**:5 个 Tab 对应 5 类字段,保存时防重复提交、成功/失败有明确反馈。
---
**文档版本**v2
**依据**:产品文档
+30
View File
@@ -0,0 +1,30 @@
# 产品文档
## 1. 概述
项目管理系统,多部门协作维护项目信息,支持按名称/编号/进度/费用检索与筛选。
## 2. 用户与权限
**角色**:管理员、市场部、工程部、技经部、财务部、物贸部。
- **创建项目**:仅市场部。
- **查看与修改**:所有角色(含市场部)均可查看和修改项目信息。
- **修改日志**:对项目信息的修改需记录操作日志。
## 3. 项目信息(5 个分类)
**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订/开工/计划竣工/实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
**成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
**应收款**:应收款(完成进度款)、开票金额、实际收款金额、实际收款完成率。
**应付款**:应付款金额、实际付款金额、未收款、实际付款完成率、民工工资清欠金额。
**其他**:结算后成本测算金额、结算人工费/材料费/其他费、到期应结算项目个数、到期未完成结算个数、存在的问题、建议措施、累计进度、备注。
## 4. 项目统计
- **搜索**:按项目名称、项目编号。
- **筛选**:按项目进度、按项目费用。
+89
View File
@@ -0,0 +1,89 @@
- 用户系统
每个用归属于这些
管理员
市场部
工程部
技经部
财务部
物贸部
只有市场部门能创建项目信息,其他用户可以查看和修改项目信息(修改项目信息需要记录日志)。
- 项目信息
有"合同信息""成本控制", "应收款", "应付款", "其他" 这5个分类。
"合同信息" 这个分类有以下这些字段
序号
合同编号
供电局项目合同编号
项目名称
子项个数
子项编码
项目总投资(万元)
中标合同金额(万元)
质保金比例
结算金额(万元)
总成本测算
工程电压等级
工程类别
业主单位
业主联系人及电话
中标形式
签订日期
开工日期
计划竣工日期
实际竣工日期
质保金(万元)
质保期截止日
实际退质保金日期
所属项目部
项目负责人及电话
工程款拨付方式
"成本控制"这个分类有以下这些字段
总体成本
是否调整(是/否)
农民工工资(按进度计划)
农民工工资(实付)
乙供材料费(控制)
应付材料费(按收款比例)
实际发生材料费
实际支付材料费
其他费用(控制)
应付其他费
实际其他费用
税金
利润(万元)
实际利润(万元)
成本结算金额(万元)
"应收款"这个分类有以下这些字段
应收款(完成进度款)
开票金额(万元)
实际收款金额(万元)
实际收款完成率
"应付款"这个分类有以下这些字段
应付款金额(万元)
实际付款金额(万元)
未收款(万元)
实际付款完成率
民工工资清欠金额(万元)
"其他"这个分类有以下这些字段
结算后成本测算金额
结算人工费
结算材料费
结算其他费
到期应结算项目个数
到期未完成结算个数
存在的问题
建议措施
累计进度
备注
- 项目统计
可以根据,项目名称和项目编号搜索项目
可以根据,项目精度筛选项目
可以根据,项目费用筛选项目
+63
View File
@@ -0,0 +1,63 @@
# 项目管理系统 · 静态页面(前端参考)
依据《产品文档》与《交互文档》实现的静态效果,供前端工程师实现与联调参考。**单入口** `index.html`4 个页面/视图在同一文件中切换。
---
## 页面与交互文档对应关系
| 序号 | 页面 | 在静态中的位置 | 交互文档章节 |
|------|------------|--------------------------|----------------|
| 1 | 登录页 | 首次打开即显示 | § 页面1 |
| 2 | 项目列表页 | 登录后主内容区默认视图 | § 页面2 |
| 3 | 项目详情/编辑页 | 点击列表「查看/编辑」或「新建项目」 | § 页面3 |
| 4 | 操作日志 | 详情页点击「查看操作日志」打开的右侧抽屉 | § 页面4 |
---
## 如何打开
```bash
# 方式一:直接打开(部分浏览器可能限制本地请求)
xdg-open ui/index.html # Linux
open ui/index.html # macOS
# 方式二:本地 HTTP 服务(推荐)
cd ui && python3 -m http.server 8080
# 浏览器访问 http://localhost:8080
```
---
## 各页面功能与操作(与交互文档一致)
**页面1 - 登录页**
- 输入账号、密码,点击「登录」。
- 成功:进入项目列表页(主应用)。
- 失败:页内提示错误(静态演示:密码填 `fail` 可触发失败)。
**页面2 - 项目列表页**
- 进入即请求列表(Mock);Loading → 表格或空状态。
- 搜索:选择「项目名称/项目编号」+ 输入关键词,点击「搜索」或回车。
- 筛选:选择「项目进度」「项目费用」后自动刷新列表。
- 操作列:「查看」「编辑」进入详情页;「新建项目」仅市场部可见(侧栏「当前角色」选市场部)。
**页面3 - 项目详情/编辑页**
- 带项目 ID 进入:Loading → 5 个 Tab 表单;新建(无 ID):直接展示空表单。
- 切换 Tab:合同信息、成本控制、应收款、应付款、其他。
- 保存:按钮 Loading、防重复点击;成功 Toast「保存成功」。
- 返回:回到项目列表。
- 查看操作日志:打开页面4 抽屉。
**页面4 - 操作日志**
- 抽屉内展示当前项目的修改记录(Mock);Loading、空状态。
---
## 文件说明
- `index.html`:4 个页面/视图的 DOM(登录、列表、详情、操作日志抽屉)。
- `css/style.css`:全局与各页面样式。
- `js/app.js`:登录/退出、视图切换、Mock 数据、搜索/筛选、保存与 Toast、操作日志抽屉。
数据均为前端 Mock,无后端接口。
+849
View File
@@ -0,0 +1,849 @@
/* ========== 变量与基础 ========== */
:root {
--sidebar-bg: #0f172a;
--sidebar-text: #e2e8f0;
--sidebar-text-muted: #94a3b8;
--sidebar-accent: #38bdf8;
--sidebar-hover: rgba(56, 189, 248, 0.12);
--main-bg: #f1f5f9;
--card-bg: #ffffff;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08);
--border: #e2e8f0;
--border-focus: #38bdf8;
--text: #1e293b;
--text-muted: #64748b;
--primary: #0ea5e9;
--primary-hover: #0284c7;
--primary-light: rgba(14, 165, 233, 0.1);
--success: #10b981;
--danger: #ef4444;
--radius: 10px;
--radius-sm: 6px;
--font-head: "Outfit", "Noto Sans SC", sans-serif;
--font-body: "Noto Sans SC", "Outfit", sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
font-family: var(--font-body);
color: var(--text);
background: var(--main-bg);
line-height: 1.5;
}
/* ========== 页面1:登录页 ========== */
.view-login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: linear-gradient(160deg, #0f172a 0%, #1e293b 50%, #334155 100%);
}
.view-login.hidden {
display: none;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: 2rem 2.25rem;
}
.login-brand {
text-align: center;
margin-bottom: 1.75rem;
}
.login-brand .brand-icon {
font-size: 2rem;
color: var(--primary);
display: block;
margin-bottom: 0.5rem;
}
.login-title {
font-family: var(--font-head);
font-weight: 600;
font-size: 1.35rem;
color: var(--text);
margin: 0 0 0.35rem 0;
}
.login-desc {
font-size: 0.875rem;
color: var(--text-muted);
margin: 0;
}
.login-form .form-group {
margin-bottom: 1rem;
}
.login-form .form-group label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text);
margin-bottom: 0.35rem;
}
.login-form .input {
width: 100%;
padding: 0.6rem 0.85rem;
}
.login-error {
font-size: 0.8125rem;
color: var(--danger);
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: rgba(239, 68, 68, 0.08);
border-radius: var(--radius-sm);
}
.btn-login-submit {
width: 100%;
padding: 0.65rem 1rem;
margin-top: 0.25rem;
}
/* ========== 布局 ========== */
.app {
display: flex;
min-height: 100vh;
}
/* ========== 侧栏 ========== */
.sidebar {
width: 240px;
min-width: 240px;
background: var(--sidebar-bg);
color: var(--sidebar-text);
display: flex;
flex-direction: column;
padding: 1.5rem 0;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 1rem;
}
.brand-icon {
font-size: 1.5rem;
color: var(--sidebar-accent);
}
.brand-text {
font-family: var(--font-head);
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
.sidebar-nav {
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 1.25rem;
color: var(--sidebar-text-muted);
text-decoration: none;
transition: color 0.15s, background 0.15s;
}
.nav-item:hover {
color: var(--sidebar-text);
background: var(--sidebar-hover);
}
.nav-item.active {
color: var(--sidebar-accent);
background: var(--sidebar-hover);
font-weight: 500;
}
.nav-icon {
font-size: 1.1rem;
opacity: 0.9;
}
.sidebar-footer {
padding: 1rem 1.25rem 0;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.role-switch {
margin-bottom: 0.75rem;
}
.role-label {
display: block;
font-size: 0.75rem;
color: var(--sidebar-text-muted);
margin-bottom: 0.35rem;
}
.role-select {
width: 100%;
padding: 0.5rem 0.6rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 0.875rem;
cursor: pointer;
}
.role-select:focus {
outline: none;
border-color: var(--sidebar-accent);
}
.btn-logout {
width: 100%;
padding: 0.5rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
color: var(--sidebar-text-muted);
font-size: 0.875rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-logout:hover {
color: var(--sidebar-text);
border-color: rgba(255, 255, 255, 0.25);
}
/* ========== 主内容区 ========== */
.main {
flex: 1;
overflow: auto;
padding: 1.5rem 2rem 2rem;
}
/* ========== 视图切换 ========== */
.view {
display: none;
}
.view.active {
display: block;
}
/* ========== 页面头部 ========== */
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.page-title {
font-family: var(--font-head);
font-weight: 600;
font-size: 1.35rem;
color: var(--text);
margin: 0;
}
.page-title.detail-title {
flex: 1;
min-width: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.75rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-back:hover {
background: #f8fafc;
border-color: var(--primary);
color: var(--primary);
}
/* ========== 按钮 ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.35);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
color: var(--primary);
border-color: var(--primary);
background: var(--primary-light);
}
.btn-icon {
font-size: 1rem;
line-height: 1;
}
.btn-sm {
padding: 0.35rem 0.75rem;
font-size: 0.8125rem;
}
.btn-new-project .btn-icon {
font-size: 1.1rem;
font-weight: 600;
}
/* ========== 工具栏(搜索与筛选) ========== */
.toolbar {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--card-shadow);
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.search-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.search-row .input-select {
width: 120px;
flex-shrink: 0;
}
.search-row .input-search {
flex: 1;
min-width: 160px;
}
.btn-search {
padding: 0.5rem 1.25rem;
background: var(--primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-search:hover {
background: var(--primary-hover);
}
.filter-row {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group label {
font-size: 0.8125rem;
color: var(--text-muted);
white-space: nowrap;
}
.input-filter {
min-width: 120px;
}
/* ========== 输入框 ========== */
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-family: inherit;
color: var(--text);
background: var(--card-bg);
transition: border-color 0.15s, box-shadow 0.15s;
}
.input::placeholder {
color: #94a3b8;
}
.input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--primary-light);
}
.input-select,
.input-filter {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
padding-right: 1.75rem;
}
.input-area {
min-height: 60px;
resize: vertical;
}
/* ========== 表格 ========== */
.table-wrap {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--card-shadow);
overflow: hidden;
position: relative;
min-height: 200px;
}
.table-loading,
.table-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.875rem;
background: var(--card-bg);
}
.table-empty .empty-icon {
font-size: 2rem;
opacity: 0.6;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table thead {
background: #f8fafc;
border-bottom: 1px solid var(--border);
}
.table th {
text-align: left;
padding: 0.75rem 1rem;
font-weight: 600;
color: var(--text);
}
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
color: var(--text);
}
.table tbody tr {
transition: background 0.12s;
}
.table tbody tr:hover {
background: #f8fafc;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table .cell-actions {
display: flex;
gap: 0.5rem;
}
.table .link-action {
color: var(--primary);
cursor: pointer;
background: none;
border: none;
font-size: 0.875rem;
padding: 0;
text-decoration: none;
}
.table .link-action:hover {
text-decoration: underline;
}
/* ========== 分页 ========== */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
padding: 0 0.25rem;
}
.pagination-info {
font-size: 0.8125rem;
color: var(--text-muted);
}
.pagination-btns {
display: flex;
gap: 0.5rem;
}
.pagination .btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ========== 详情页 Tabs ========== */
.detail-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--text-muted);
font-size: 0.875rem;
}
.detail-content {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--card-shadow);
overflow: hidden;
}
.tabs {
display: flex;
gap: 0.25rem;
padding: 0 1rem;
background: #f8fafc;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tab {
padding: 0.75rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 0.875rem;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--primary);
font-weight: 500;
border-bottom-color: var(--primary);
}
.tab-panels {
padding: 1.5rem 1.5rem 2rem;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* ========== 表单网格 ========== */
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-group-full {
grid-column: 1 / -1;
}
.form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text);
}
/* ========== 抽屉(操作日志) ========== */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 100;
}
.drawer-overlay.visible {
opacity: 1;
visibility: visible;
}
.drawer {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100%;
height: 100%;
background: var(--card-bg);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 101;
display: flex;
flex-direction: column;
}
.drawer.visible {
transform: translateX(0);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.drawer-title {
font-family: var(--font-head);
font-weight: 600;
font-size: 1.125rem;
margin: 0;
color: var(--text);
}
.drawer-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: background 0.15s, color 0.15s;
}
.drawer-close:hover {
background: #f1f5f9;
color: var(--text);
}
.drawer-body {
flex: 1;
overflow: auto;
padding: 1rem 1.5rem;
position: relative;
}
.drawer-loading,
.drawer-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 160px;
color: var(--text-muted);
font-size: 0.875rem;
}
.drawer-empty .empty-icon {
font-size: 2rem;
opacity: 0.6;
}
.log-list {
list-style: none;
margin: 0;
padding: 0;
}
.log-item {
padding: 0.75rem 0;
border-bottom: 1px solid #f1f5f9;
font-size: 0.875rem;
}
.log-item:last-child {
border-bottom: none;
}
.log-item .log-meta {
color: var(--text-muted);
font-size: 0.8125rem;
margin-bottom: 0.25rem;
}
.log-item .log-desc {
color: var(--text);
}
/* ========== Toast ========== */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 0.65rem 1.25rem;
background: var(--text);
color: #fff;
font-size: 0.875rem;
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0;
visibility: hidden;
transition: transform 0.25s ease, opacity 0.25s, visibility 0.25s;
z-index: 1000;
}
.toast.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
visibility: visible;
}
.toast.success {
background: var(--success);
}
.toast.error {
background: var(--danger);
}
/* ========== 响应式 ========== */
@media (max-width: 900px) {
.form-grid {
grid-template-columns: 1fr;
}
.sidebar {
width: 64px;
min-width: 64px;
padding: 1rem 0;
}
.brand-text,
.nav-item span:not(.nav-icon),
.role-label,
.role-select,
.btn-logout {
display: none;
}
.sidebar-brand {
justify-content: center;
padding: 0 0.5rem 1rem;
}
.nav-item {
justify-content: center;
padding: 0.75rem;
}
.sidebar-footer {
padding: 0.5rem;
}
.drawer {
width: 100%;
}
}
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<!--
项目管理系统 · 静态页面(前端参考)
依据:docs/产品文档.md、docs/交互文档.md
页面:1 登录页 | 2 项目列表页 | 3 项目详情/编辑页 | 4 操作日志(抽屉)
详见 ui/README.md
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>项目管理系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<!-- 页面1:登录页(交互文档 § 页面1) -->
<section id="view-login" class="view-login">
<div class="login-card">
<div class="login-brand">
<span class="brand-icon"></span>
<h1 class="login-title">项目管理系统</h1>
<p class="login-desc">多部门协作维护项目信息</p>
</div>
<form id="loginForm" class="login-form" novalidate>
<div class="form-group">
<label for="loginAccount">账号</label>
<input type="text" id="loginAccount" class="input" name="account" placeholder="请输入账号" required autocomplete="username" />
</div>
<div class="form-group">
<label for="loginPassword">密码</label>
<input type="password" id="loginPassword" class="input" name="password" placeholder="请输入密码" required autocomplete="current-password" />
</div>
<div id="loginError" class="login-error" role="alert" style="display: none;"></div>
<button type="submit" class="btn btn-primary btn-login-submit" id="btnLoginSubmit">登录</button>
</form>
</div>
</section>
<!-- 页面2~4:登录后主应用(侧栏 + 列表/详情 + 操作日志抽屉) -->
<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">
<div class="role-switch">
<span class="role-label">当前角色</span>
<select id="roleSelect" class="role-select" title="切换角色预览权限">
<option value="market">市场部</option>
<option value="admin">管理员</option>
<option value="engineer">工程部</option>
</select>
</div>
<button type="button" class="btn-logout">退出</button>
</div>
</aside>
<!-- 主内容 -->
<main class="main">
<!-- 列表视图 -->
<section id="view-list" class="view view-list active">
<!-- 页面2:项目列表页(交互文档 § 页面2) -->
<header class="page-header">
<h1 class="page-title">项目列表</h1>
<div class="header-actions">
<button type="button" class="btn btn-primary btn-new-project" id="btnNewProject" style="display: none;">
<span class="btn-icon">+</span> 新建项目
</button>
</div>
</header>
<div class="toolbar">
<div class="search-row">
<select id="searchType" class="input input-select">
<option value="name">项目名称</option>
<option value="code">项目编号</option>
</select>
<input type="text" id="searchInput" class="input input-search" placeholder="输入关键词搜索" />
<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>
</div>
<div class="table-wrap">
<div class="table-loading" id="listLoading">加载中…</div>
<div class="table-empty" id="listEmpty" style="display: none;">
<span class="empty-icon">📋</span>
<p>暂无项目数据</p>
</div>
<table class="table" id="projectTable">
<thead>
<tr>
<th>项目名称</th>
<th>合同编号</th>
<th>进度</th>
<th>费用状态</th>
<th>业主单位</th>
<th>操作</th>
</tr>
</thead>
<tbody id="projectTableBody"></tbody>
</table>
</div>
<div class="pagination" id="pagination">
<span class="pagination-info">共 0 条</span>
<div class="pagination-btns">
<button type="button" class="btn btn-sm" disabled>上一页</button>
<button type="button" class="btn btn-sm">下一页</button>
</div>
</div>
</section>
<!-- 页面3:项目详情/编辑页(交互文档 § 页面3) -->
<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">
<button type="button" class="btn btn-ghost" id="btnViewLog">查看操作日志</button>
<button type="button" class="btn btn-primary" id="btnSave">
<span class="btn-text">保存</span>
<span class="btn-loading" style="display: none;">保存中…</span>
</button>
</div>
</header>
<div class="detail-loading" id="detailLoading">加载中…</div>
<div class="detail-content" id="detailContent" style="display: none;">
<div class="tabs">
<button type="button" class="tab active" data-tab="contract">合同信息</button>
<button type="button" class="tab" data-tab="cost">成本控制</button>
<button type="button" class="tab" data-tab="receivable">应收款</button>
<button type="button" class="tab" data-tab="payable">应付款</button>
<button type="button" class="tab" data-tab="other">其他</button>
</div>
<div class="tab-panels">
<div class="tab-panel active" id="panel-contract">
<div class="form-grid">
<div class="form-group"><label>项目名称</label><input type="text" class="input" name="projectName" /></div>
<div class="form-group"><label>合同编号</label><input type="text" class="input" name="contractCode" /></div>
<div class="form-group"><label>供电局项目合同编号</label><input type="text" class="input" name="powerContractCode" /></div>
<div class="form-group"><label>子项个数</label><input type="number" class="input" name="subCount" /></div>
<div class="form-group"><label>子项编码</label><input type="text" class="input" name="subCode" /></div>
<div class="form-group"><label>项目总投资(万元)</label><input type="text" class="input" name="totalInvest" /></div>
<div class="form-group"><label>中标合同金额(万元)</label><input type="text" class="input" name="bidAmount" /></div>
<div class="form-group"><label>质保金比例</label><input type="text" class="input" name="warrantyRatio" /></div>
<div class="form-group"><label>结算金额(万元)</label><input type="text" class="input" name="settlementAmount" /></div>
<div class="form-group"><label>工程电压等级</label><input type="text" class="input" name="voltageLevel" /></div>
<div class="form-group"><label>工程类别</label><input type="text" class="input" name="projectType" /></div>
<div class="form-group"><label>业主单位</label><input type="text" class="input" name="ownerUnit" /></div>
<div class="form-group"><label>业主联系人及电话</label><input type="text" class="input" name="ownerContact" /></div>
<div class="form-group"><label>中标形式</label><input type="text" class="input" name="bidForm" /></div>
<div class="form-group"><label>签订日期</label><input type="date" class="input" name="signDate" /></div>
<div class="form-group"><label>开工日期</label><input type="date" class="input" name="startDate" /></div>
<div class="form-group"><label>计划竣工日期</label><input type="date" class="input" name="planEndDate" /></div>
<div class="form-group"><label>实际竣工日期</label><input type="date" class="input" name="actualEndDate" /></div>
<div class="form-group"><label>所属项目部</label><input type="text" class="input" name="department" /></div>
<div class="form-group"><label>项目负责人及电话</label><input type="text" class="input" name="managerContact" /></div>
<div class="form-group form-group-full"><label>工程款拨付方式</label><input type="text" class="input" name="paymentMethod" /></div>
</div>
</div>
<div class="tab-panel" id="panel-cost">
<div class="form-grid">
<div class="form-group"><label>总体成本</label><input type="text" class="input" name="totalCost" /></div>
<div class="form-group"><label>是否调整</label><select class="input" name="adjusted"><option value="">请选择</option><option value="yes"></option><option value="no"></option></select></div>
<div class="form-group"><label>农民工工资(按进度计划)</label><input type="text" class="input" name="wagePlan" /></div>
<div class="form-group"><label>农民工工资(实付)</label><input type="text" class="input" name="wageActual" /></div>
<div class="form-group"><label>乙供材料费(控制)</label><input type="text" class="input" name="materialControl" /></div>
<div class="form-group"><label>应付材料费(按收款比例)</label><input type="text" class="input" name="materialPayable" /></div>
<div class="form-group"><label>实际发生材料费</label><input type="text" class="input" name="materialActual" /></div>
<div class="form-group"><label>实际支付材料费</label><input type="text" class="input" name="materialPaid" /></div>
<div class="form-group"><label>其他费用(控制)</label><input type="text" class="input" name="otherControl" /></div>
<div class="form-group"><label>应付其他费</label><input type="text" class="input" name="otherPayable" /></div>
<div class="form-group"><label>实际其他费用</label><input type="text" class="input" name="otherActual" /></div>
<div class="form-group"><label>税金</label><input type="text" class="input" name="tax" /></div>
<div class="form-group"><label>利润(万元)</label><input type="text" class="input" name="profit" /></div>
<div class="form-group"><label>实际利润(万元)</label><input type="text" class="input" name="profitActual" /></div>
<div class="form-group"><label>成本结算金额(万元)</label><input type="text" class="input" name="costSettlement" /></div>
</div>
</div>
<div class="tab-panel" id="panel-receivable">
<div class="form-grid">
<div class="form-group"><label>应收款(完成进度款)</label><input type="text" class="input" name="receivable" /></div>
<div class="form-group"><label>开票金额(万元)</label><input type="text" class="input" name="invoiceAmount" /></div>
<div class="form-group"><label>实际收款金额(万元)</label><input type="text" class="input" name="receivedAmount" /></div>
<div class="form-group"><label>实际收款完成率</label><input type="text" class="input" name="receivedRate" placeholder="%" /></div>
</div>
</div>
<div class="tab-panel" id="panel-payable">
<div class="form-grid">
<div class="form-group"><label>应付款金额(万元)</label><input type="text" class="input" name="payableAmount" /></div>
<div class="form-group"><label>实际付款金额(万元)</label><input type="text" class="input" name="paidAmount" /></div>
<div class="form-group"><label>未收款(万元)</label><input type="text" class="input" name="unreceived" /></div>
<div class="form-group"><label>实际付款完成率</label><input type="text" class="input" name="paidRate" placeholder="%" /></div>
<div class="form-group"><label>民工工资清欠金额(万元)</label><input type="text" class="input" name="wageArrears" /></div>
</div>
</div>
<div class="tab-panel" id="panel-other">
<div class="form-grid">
<div class="form-group"><label>结算后成本测算金额</label><input type="text" class="input" name="afterSettlementCost" /></div>
<div class="form-group"><label>结算人工费</label><input type="text" class="input" name="settlementLabor" /></div>
<div class="form-group"><label>结算材料费</label><input type="text" class="input" name="settlementMaterial" /></div>
<div class="form-group"><label>结算其他费</label><input type="text" class="input" name="settlementOther" /></div>
<div class="form-group"><label>到期应结算项目个数</label><input type="number" class="input" name="dueSettlementCount" /></div>
<div class="form-group"><label>到期未完成结算个数</label><input type="number" class="input" name="dueUnfinishedCount" /></div>
<div class="form-group form-group-full"><label>存在的问题</label><textarea class="input input-area" name="problems" rows="2"></textarea></div>
<div class="form-group form-group-full"><label>建议措施</label><textarea class="input input-area" name="suggestions" rows="2"></textarea></div>
<div class="form-group"><label>累计进度</label><input type="text" class="input" name="totalProgress" placeholder="%" /></div>
<div class="form-group form-group-full"><label>备注</label><textarea class="input input-area" name="remark" rows="2"></textarea></div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<!-- 页面4:操作日志(抽屉形式,交互文档 § 页面4) -->
<div class="drawer-overlay" id="logOverlay" aria-hidden="true"></div>
<div class="drawer" id="logDrawer">
<div class="drawer-header">
<h2 class="drawer-title">操作日志</h2>
<button type="button" class="drawer-close" id="logDrawerClose">×</button>
</div>
<div class="drawer-body">
<div class="drawer-loading" id="logLoading">加载中…</div>
<div class="drawer-empty" id="logEmpty" style="display: none;">
<span class="empty-icon">📜</span>
<p>暂无操作记录</p>
</div>
<ul class="log-list" id="logList"></ul>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast" role="alert" aria-live="polite"></div>
<script src="js/app.js"></script>
</body>
</html>
+410
View File
@@ -0,0 +1,410 @@
(function () {
'use strict';
// ---------- 状态 ----------
var isLoggedIn = false; // 页面1 登录态,静态演示默认未登录
var currentView = 'list'; // list | detail
var currentRole = 'market'; // market | admin | engineer
var currentProjectId = null; // 当前详情/编辑的项目 ID,null 表示新建
var projects = [];
var listFiltered = [];
// ---------- Mock 数据 ----------
function getMockProjects() {
return [
{
id: '1',
name: '城东 110kV 输变电工程',
contractCode: 'HT-2024-001',
progress: '进行中',
costStatus: '正常',
ownerUnit: '市供电公司',
},
{
id: '2',
name: '高新区配电改造项目',
contractCode: 'HT-2024-002',
progress: '已竣工',
costStatus: '正常',
ownerUnit: '高新区管委会',
},
{
id: '3',
name: '西区 35kV 线路迁改',
contractCode: 'HT-2024-003',
progress: '筹备中',
costStatus: '节余',
ownerUnit: '西区建设局',
},
];
}
function getMockLogs(projectId) {
return [
{ operator: '张三', time: '2025-01-28 14:30', summary: '修改合同信息' },
{ operator: '李四', time: '2025-01-27 10:00', summary: '修改成本控制' },
{ operator: '王五', time: '2025-01-26 16:45', summary: '修改应收款' },
];
}
// ---------- DOM ----------
var roleSelect = document.getElementById('roleSelect');
var btnNewProject = document.getElementById('btnNewProject');
var viewList = document.getElementById('view-list');
var viewDetail = document.getElementById('view-detail');
var searchType = document.getElementById('searchType');
var searchInput = document.getElementById('searchInput');
var btnSearch = document.getElementById('btnSearch');
var filterProgress = document.getElementById('filterProgress');
var filterCost = document.getElementById('filterCost');
var listLoading = document.getElementById('listLoading');
var listEmpty = document.getElementById('listEmpty');
var projectTable = document.getElementById('projectTable');
var projectTableBody = document.getElementById('projectTableBody');
var pagination = document.getElementById('pagination');
var btnBack = document.getElementById('btnBack');
var detailTitle = document.getElementById('detailTitle');
var detailLoading = document.getElementById('detailLoading');
var detailContent = document.getElementById('detailContent');
var btnViewLog = document.getElementById('btnViewLog');
var btnSave = document.getElementById('btnSave');
var logOverlay = document.getElementById('logOverlay');
var logDrawer = document.getElementById('logDrawer');
var logDrawerClose = document.getElementById('logDrawerClose');
var logLoading = document.getElementById('logLoading');
var logEmpty = document.getElementById('logEmpty');
var logList = document.getElementById('logList');
var toast = document.getElementById('toast');
var navList = document.getElementById('navList');
var viewLogin = document.getElementById('view-login');
var appMain = document.getElementById('appMain');
var loginForm = document.getElementById('loginForm');
var loginError = document.getElementById('loginError');
var btnLoginSubmit = document.getElementById('btnLoginSubmit');
var btnLogout = document.querySelector('.btn-logout');
// ---------- 页面1:登录 / 退出 ----------
function showLogin() {
isLoggedIn = false;
if (viewLogin) viewLogin.classList.remove('hidden');
if (appMain) appMain.style.display = 'none';
}
function showApp() {
isLoggedIn = true;
if (viewLogin) viewLogin.classList.add('hidden');
if (appMain) appMain.style.display = 'flex';
}
if (loginForm) {
loginForm.addEventListener('submit', function (e) {
e.preventDefault();
var account = document.getElementById('loginAccount');
var password = document.getElementById('loginPassword');
if (!account || !password) return;
if (loginError) loginError.style.display = 'none';
if (btnLoginSubmit) btnLoginSubmit.disabled = true;
// 静态演示:任意非空账号密码视为成功;可改为模拟失败(如 password === 'fail'
setTimeout(function () {
if (btnLoginSubmit) btnLoginSubmit.disabled = false;
if (password.value === 'fail') {
if (loginError) {
loginError.textContent = '账号或密码错误,请重试';
loginError.style.display = 'block';
}
return;
}
showApp();
renderList();
}, 400);
});
}
if (btnLogout) {
btnLogout.addEventListener('click', function () {
showLogin();
});
}
// ---------- 权限:是否市场部 ----------
function isMarketRole() {
return currentRole === 'market';
}
function updateRoleUI() {
if (btnNewProject) btnNewProject.style.display = isMarketRole() ? '' : 'none';
}
if (roleSelect) {
roleSelect.addEventListener('change', function () {
currentRole = roleSelect.value;
updateRoleUI();
});
}
// ---------- 视图切换 ----------
function showList() {
currentView = 'list';
if (viewList) viewList.classList.add('active');
if (viewDetail) viewDetail.classList.remove('active');
document.querySelectorAll('.nav-item').forEach(function (n) { n.classList.remove('active'); });
if (navList) navList.classList.add('active');
}
function showDetail(projectId) {
currentView = 'detail';
currentProjectId = projectId;
if (viewList) viewList.classList.remove('active');
if (viewDetail) viewDetail.classList.add('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';
if (projectId) {
// 模拟加载详情
setTimeout(function () {
if (detailLoading) detailLoading.style.display = 'none';
if (detailContent) detailContent.style.display = 'block';
fillDetailForm(projectId);
}, 400);
} else {
if (detailLoading) detailLoading.style.display = 'none';
if (detailContent) detailContent.style.display = 'block';
clearDetailForm();
}
}
function fillDetailForm(projectId) {
var p = projects.find(function (x) { return x.id === projectId; });
if (!p) return;
var nameInput = detailContent.querySelector('input[name="projectName"]');
if (nameInput) nameInput.value = p.name || '';
var codeInput = detailContent.querySelector('input[name="contractCode"]');
if (codeInput) codeInput.value = p.contractCode || '';
var ownerInput = detailContent.querySelector('input[name="ownerUnit"]');
if (ownerInput) ownerInput.value = p.ownerUnit || '';
}
function clearDetailForm() {
var inputs = detailContent ? detailContent.querySelectorAll('input, select, textarea') : [];
inputs.forEach(function (el) {
if (el.type === 'checkbox' || el.type === 'radio') el.checked = false;
else el.value = '';
});
}
// ---------- Tab 切换 ----------
function bindTabs() {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.tab-panel');
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var t = tab.getAttribute('data-tab');
tabs.forEach(function (x) { x.classList.remove('active'); });
panels.forEach(function (p) {
var pid = p.id;
if (pid === 'panel-' + t) p.classList.add('active');
else p.classList.remove('active');
});
tab.classList.add('active');
});
});
}
// ---------- 列表:渲染 ----------
function filterList() {
var type = searchType && searchType.value ? searchType.value : 'name';
var keyword = (searchInput && searchInput.value || '').trim().toLowerCase();
var progressVal = filterProgress && filterProgress.value ? filterProgress.value : '';
var costVal = filterCost && filterCost.value ? filterCost.value : '';
listFiltered = projects.filter(function (p) {
if (keyword) {
var name = (p.name || '').toLowerCase();
var code = (p.contractCode || '').toLowerCase();
if (type === 'name' && name.indexOf(keyword) === -1) return false;
if (type === 'code' && code.indexOf(keyword) === -1) return false;
}
if (progressVal) {
if ((p.progressKey || '').toLowerCase() !== progressVal.toLowerCase()) return false;
}
if (costVal) {
if ((p.costKey || '').toLowerCase() !== costVal.toLowerCase()) return false;
}
return true;
});
}
function mapProgressToKey(p) {
var map = { '筹备中': 'planning', '进行中': 'ongoing', '已竣工': 'completed', '已结算': 'settled' };
p.progressKey = map[p.progress] || p.progress;
return p;
}
function mapCostToKey(p) {
var map = { '正常': 'normal', '超支': 'over', '节余': 'under' };
p.costKey = map[p.costStatus] || p.costStatus;
return p;
}
function renderList() {
if (listLoading) listLoading.style.display = 'block';
if (listEmpty) listEmpty.style.display = 'none';
if (projectTable) projectTable.style.visibility = 'hidden';
setTimeout(function () {
filterList();
if (listLoading) listLoading.style.display = 'none';
if (listFiltered.length === 0) {
if (listEmpty) listEmpty.style.display = 'flex';
if (projectTable) projectTable.style.visibility = 'hidden';
} else {
if (listEmpty) listEmpty.style.display = 'none';
if (projectTable) projectTable.style.visibility = 'visible';
var html = listFiltered.map(function (p) {
return (
'<tr>' +
'<td>' + (p.name || '—') + '</td>' +
'<td>' + (p.contractCode || '—') + '</td>' +
'<td>' + (p.progress || '—') + '</td>' +
'<td>' + (p.costStatus || '—') + '</td>' +
'<td>' + (p.ownerUnit || '—') + '</td>' +
'<td class="cell-actions">' +
'<button type="button" class="link-action" data-id="' + p.id + '">查看</button> ' +
'<button type="button" class="link-action" data-id="' + p.id + '">编辑</button>' +
'</td>' +
'</tr>'
);
}).join('');
if (projectTableBody) projectTableBody.innerHTML = html;
projectTableBody.querySelectorAll('.link-action').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
showDetail(id);
});
});
}
var totalEl = pagination && pagination.querySelector('.pagination-info');
if (totalEl) totalEl.textContent = '共 ' + listFiltered.length + ' 条';
}, 300);
}
// ---------- 搜索与筛选 ----------
if (btnSearch) btnSearch.addEventListener('click', renderList);
if (searchInput) searchInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') renderList(); });
if (filterProgress) filterProgress.addEventListener('change', renderList);
if (filterCost) filterCost.addEventListener('change', renderList);
// ---------- 新建项目 ----------
if (btnNewProject) {
btnNewProject.addEventListener('click', function () {
showDetail(null);
});
}
// ---------- 返回列表 ----------
if (btnBack) {
btnBack.addEventListener('click', function () {
showList();
});
}
if (navList) {
navList.addEventListener('click', function (e) {
e.preventDefault();
showList();
});
}
// ---------- 保存 ----------
if (btnSave) {
btnSave.addEventListener('click', function () {
var textSpan = btnSave.querySelector('.btn-text');
var loadingSpan = btnSave.querySelector('.btn-loading');
if (loadingSpan && loadingSpan.style.display === 'inline') return;
if (textSpan) textSpan.style.display = 'none';
if (loadingSpan) loadingSpan.style.display = 'inline';
btnSave.disabled = true;
setTimeout(function () {
if (textSpan) textSpan.style.display = 'inline';
if (loadingSpan) loadingSpan.style.display = 'none';
btnSave.disabled = false;
showToast('保存成功', 'success');
}, 800);
});
}
// ---------- 操作日志抽屉 ----------
function openLogDrawer() {
if (logOverlay) logOverlay.classList.add('visible');
if (logDrawer) logDrawer.classList.add('visible');
if (logOverlay) logOverlay.setAttribute('aria-hidden', 'false');
if (logLoading) logLoading.style.display = 'block';
if (logEmpty) logEmpty.style.display = 'none';
if (logList) logList.innerHTML = '';
setTimeout(function () {
if (logLoading) logLoading.style.display = 'none';
var logs = getMockLogs(currentProjectId);
if (logs.length === 0) {
if (logEmpty) logEmpty.style.display = 'flex';
} else {
logList.innerHTML = logs.map(function (l) {
return (
'<li class="log-item">' +
'<div class="log-meta">' + l.operator + ' · ' + l.time + '</div>' +
'<div class="log-desc">' + l.summary + '</div>' +
'</li>'
);
}).join('');
}
}, 400);
}
function closeLogDrawer() {
if (logOverlay) logOverlay.classList.remove('visible');
if (logDrawer) logDrawer.classList.remove('visible');
if (logOverlay) logOverlay.setAttribute('aria-hidden', 'true');
}
if (btnViewLog) btnViewLog.addEventListener('click', openLogDrawer);
if (logDrawerClose) logDrawerClose.addEventListener('click', closeLogDrawer);
if (logOverlay) logOverlay.addEventListener('click', closeLogDrawer);
// ---------- Toast ----------
function showToast(message, type) {
if (!toast) return;
toast.textContent = message;
toast.className = 'toast visible' + (type ? ' ' + type : '');
clearTimeout(toast._tid);
toast._tid = setTimeout(function () {
toast.classList.remove('visible');
}, 2500);
}
// ---------- 初始化 ----------
projects = getMockProjects().map(function (p) {
mapProgressToKey(p);
mapCostToKey(p);
return p;
});
bindTabs();
updateRoleUI();
// 未登录时只显示登录页;已登录则显示主应用(可通过 hash 或 localStorage 模拟,此处默认显示登录页)
if (viewLogin && appMain) {
showLogin();
} else {
renderList();
}
})();