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
+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();
}
})();