From 588e3bb4784f377d04d41fe73abce38df2f92c50 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 31 Jan 2026 00:01:19 +0800 Subject: [PATCH] =?UTF-8?q?API=20=E7=AC=AC=E4=B8=80=E7=89=88=E5=AE=9A?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/API.md | 396 ++++++++++++++++++++++ docs/交互文档.md | 137 ++++++++ docs/产品文档.md | 30 ++ docs/初始需求.md | 89 +++++ ui/README.md | 63 ++++ ui/css/style.css | 849 +++++++++++++++++++++++++++++++++++++++++++++++ ui/index.html | 268 +++++++++++++++ ui/js/app.js | 410 +++++++++++++++++++++++ 8 files changed, 2242 insertions(+) create mode 100644 backend/API.md create mode 100644 docs/交互文档.md create mode 100644 docs/产品文档.md create mode 100644 docs/初始需求.md create mode 100644 ui/README.md create mode 100644 ui/css/style.css create mode 100644 ui/index.html create mode 100644 ui/js/app.js diff --git a/backend/API.md b/backend/API.md new file mode 100644 index 0000000..063f615 --- /dev/null +++ b/backend/API.md @@ -0,0 +1,396 @@ +# 项目管理系统 API 文档 + +**依据**:《产品文档》《交互文档》《初始需求》 +**读者**:后端/前端工程师 +**版本**:v1 + +--- + +## 1. 概述 + +- **基础路径**:`/api`(按实际部署约定) +- **请求方式**:**全部采用 POST** +- **认证方式**:登录后使用 Token(如 Bearer Token 或 Session),请求头携带,具体与现有账号体系对齐 +- **数据格式**:请求体与响应体均为 JSON,编码 UTF-8,Content-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`,前后端约定一致。 diff --git a/docs/交互文档.md b/docs/交互文档.md new file mode 100644 index 0000000..d4fb90a --- /dev/null +++ b/docs/交互文档.md @@ -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 +**依据**:产品文档 diff --git a/docs/产品文档.md b/docs/产品文档.md new file mode 100644 index 0000000..595d30d --- /dev/null +++ b/docs/产品文档.md @@ -0,0 +1,30 @@ +# 产品文档 + +## 1. 概述 + +项目管理系统,多部门协作维护项目信息,支持按名称/编号/进度/费用检索与筛选。 + +## 2. 用户与权限 + +**角色**:管理员、市场部、工程部、技经部、财务部、物贸部。 + +- **创建项目**:仅市场部。 +- **查看与修改**:所有角色(含市场部)均可查看和修改项目信息。 +- **修改日志**:对项目信息的修改需记录操作日志。 + +## 3. 项目信息(5 个分类) + +**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订/开工/计划竣工/实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。 + +**成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。 + +**应收款**:应收款(完成进度款)、开票金额、实际收款金额、实际收款完成率。 + +**应付款**:应付款金额、实际付款金额、未收款、实际付款完成率、民工工资清欠金额。 + +**其他**:结算后成本测算金额、结算人工费/材料费/其他费、到期应结算项目个数、到期未完成结算个数、存在的问题、建议措施、累计进度、备注。 + +## 4. 项目统计 + +- **搜索**:按项目名称、项目编号。 +- **筛选**:按项目进度、按项目费用。 diff --git a/docs/初始需求.md b/docs/初始需求.md new file mode 100644 index 0000000..0882810 --- /dev/null +++ b/docs/初始需求.md @@ -0,0 +1,89 @@ +- 用户系统 +每个用归属于这些 + 管理员 + 市场部 + 工程部 + 技经部 + 财务部 + 物贸部 +只有市场部门能创建项目信息,其他用户可以查看和修改项目信息(修改项目信息需要记录日志)。 + +- 项目信息 + 有"合同信息","成本控制", "应收款", "应付款", "其他" 这5个分类。 +"合同信息" 这个分类有以下这些字段 +序号 +合同编号 +供电局项目合同编号 +项目名称 +子项个数 +子项编码 +项目总投资(万元) +中标合同金额(万元) +质保金比例 +结算金额(万元) +总成本测算 +工程电压等级 +工程类别 +业主单位 +业主联系人及电话 +中标形式 +签订日期 +开工日期 +计划竣工日期 +实际竣工日期 +质保金(万元) +质保期截止日 +实际退质保金日期 +所属项目部 +项目负责人及电话 +工程款拨付方式 + +"成本控制"这个分类有以下这些字段 +总体成本 +是否调整(是/否) +农民工工资(按进度计划) +农民工工资(实付) +乙供材料费(控制) +应付材料费(按收款比例) +实际发生材料费 +实际支付材料费 +其他费用(控制) +应付其他费 +实际其他费用 +税金 +利润(万元) +实际利润(万元) +成本结算金额(万元) + +"应收款"这个分类有以下这些字段 +应收款(完成进度款) +开票金额(万元) +实际收款金额(万元) +实际收款完成率 + +"应付款"这个分类有以下这些字段 +应付款金额(万元) +实际付款金额(万元) +未收款(万元) +实际付款完成率 +民工工资清欠金额(万元) + +"其他"这个分类有以下这些字段 +结算后成本测算金额 +结算人工费 +结算材料费 +结算其他费 +到期应结算项目个数 +到期未完成结算个数 +存在的问题 +建议措施 +累计进度 +备注 + +- 项目统计 +可以根据,项目名称和项目编号搜索项目 +可以根据,项目精度筛选项目 +可以根据,项目费用筛选项目 + + + diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..76bf6a7 --- /dev/null +++ b/ui/README.md @@ -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,无后端接口。 diff --git a/ui/css/style.css b/ui/css/style.css new file mode 100644 index 0000000..c572bfc --- /dev/null +++ b/ui/css/style.css @@ -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%; + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..fe95471 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,268 @@ + + + + + + + 项目管理系统 + + + + + + + +
+ +
+ + + + + + +
+
+

操作日志

+ +
+
+
加载中…
+ +
    +
    +
    + + + + + + + diff --git a/ui/js/app.js b/ui/js/app.js new file mode 100644 index 0000000..610424f --- /dev/null +++ b/ui/js/app.js @@ -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 ( + '' + + '' + (p.name || '—') + '' + + '' + (p.contractCode || '—') + '' + + '' + (p.progress || '—') + '' + + '' + (p.costStatus || '—') + '' + + '' + (p.ownerUnit || '—') + '' + + '' + + ' ' + + '' + + '' + + '' + ); + }).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 ( + '
  • ' + + '
    ' + l.operator + ' · ' + l.time + '
    ' + + '
    ' + l.summary + '
    ' + + '
  • ' + ); + }).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(); + } +})();