所有需求实现完成

This commit is contained in:
Your Name
2026-02-01 15:57:42 +08:00
parent e651b92d7c
commit 6e1174a59a
16 changed files with 2354 additions and 173 deletions
+302 -108
View File
@@ -193,116 +193,163 @@ body {
/* ---------- 主应用 · 侧栏(类似播放器/歌单区) ---------- */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-base);
}
.sidebar {
width: 240px;
min-width: 240px;
background: var(--bg-elevated);
border-right: 1px solid var(--border);
color: var(--text);
display: flex;
flex-direction: column;
padding: var(--space-24) 0;
}
.sidebar-brand {
/* ---------- 页面右上角用户栏(集成在 header-actions 内) ---------- */
.topbar-user {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 var(--space-24) var(--space-24);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-16);
gap: var(--space-16);
margin-left: var(--space-16);
padding-left: var(--space-16);
border-left: 1px solid var(--border);
}
.brand-icon {
font-size: 1.25rem;
color: var(--accent-violet-light);
font-weight: 300;
}
.brand-text {
font-family: var(--font-head);
.topbar-user .user-info {
font-size: 0.875rem;
font-weight: 400;
font-size: 0.95rem;
letter-spacing: 0.02em;
color: var(--text);
}
.sidebar-nav {
flex: 0 0 auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem var(--space-24);
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s var(--ease), background 0.2s var(--ease);
margin: 0;
padding: 0;
}
.nav-item:hover {
color: var(--text);
background: var(--bg-hover);
}
.nav-item.active {
color: var(--accent-violet-light);
background: rgba(139, 92, 246, 0.12);
font-weight: 400;
}
.nav-icon {
font-size: 1rem;
opacity: 0.85;
color: var(--text-dim);
}
.nav-item.active .nav-icon {
color: var(--accent-violet-light);
}
.sidebar-footer {
padding: var(--space-16) var(--space-24) 0;
border-top: 1px solid var(--border);
}
.user-info {
display: block;
font-size: 0.75rem;
font-weight: 300;
color: var(--text-muted);
margin-bottom: var(--space-16);
padding: 0 var(--space-24);
}
.btn-logout {
width: calc(100% - var(--space-24) * 2);
margin: 0 var(--space-24);
padding: 0.5rem;
.topbar-user .btn-logout {
width: auto;
margin: 0;
padding: 0.4rem 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 400;
cursor: pointer;
transition: color 0.2s var(--ease), border-color 0.2s var(--ease), background 0.2s var(--ease);
}
.btn-logout:hover {
.topbar-user .btn-logout:hover {
color: var(--text);
border-color: var(--accent-violet);
background: var(--bg-hover);
}
.topbar-user .topbar-link {
color: var(--accent-cyan);
text-decoration: none;
font-size: 0.875rem;
font-weight: 400;
transition: color 0.2s var(--ease);
}
.topbar-user .topbar-link:hover {
color: var(--accent-violet-light);
}
/* ---------- 用户弹窗 ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s var(--ease), visibility 0.25s var(--ease);
z-index: 200;
}
.modal-overlay.visible {
opacity: 1;
visibility: visible;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 400px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
z-index: 201;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s var(--ease), visibility 0.25s var(--ease);
}
.modal.visible {
opacity: 1;
visibility: visible;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-24);
border-bottom: 1px solid var(--border);
}
.modal-title {
font-family: var(--font-head);
font-size: 1.125rem;
font-weight: 400;
margin: 0;
color: var(--text);
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius-sm);
font-size: 1.25rem;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s var(--ease), background 0.2s var(--ease);
}
.modal-close:hover {
color: var(--text);
background: var(--bg-hover);
}
.modal-form {
padding: var(--space-24);
}
.modal-form .form-group {
margin-bottom: var(--space-16);
}
.modal-form .form-group label {
display: block;
font-size: 0.8125rem;
margin-bottom: 0.35rem;
color: var(--text-muted);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-16);
margin-top: var(--space-24);
padding-top: var(--space-16);
border-top: 1px solid var(--border);
}
/* ---------- 主内容区 ---------- */
.main {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-24) var(--space-32) var(--space-32);
background: var(--bg-base);
@@ -316,6 +363,157 @@ body {
display: block;
}
/* ---------- 统计页 ---------- */
.view-statistics .page-header {
margin-bottom: var(--space-24);
}
.stats-loading,
.stats-empty {
text-align: center;
padding: var(--space-32);
color: var(--text-muted);
}
.stats-empty .empty-icon {
font-size: 3rem;
display: block;
margin-bottom: var(--space-16);
}
.stats-desc {
margin: 0 0 var(--space-24);
font-size: 0.9375rem;
color: var(--text-muted);
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-24);
}
.stats-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-24);
box-shadow: var(--shadow-soft);
}
.stats-card-wide {
grid-column: 1 / -1;
max-width: 560px;
justify-self: start;
}
.stats-card-title {
font-family: var(--font-head);
font-size: 0.9375rem;
font-weight: 400;
color: var(--text-muted);
margin: 0 0 var(--space-16);
}
.stats-card-value {
font-family: var(--font-head);
font-size: 1.75rem;
font-weight: 400;
color: var(--accent-cyan);
margin-bottom: var(--space-16);
}
.stats-chart {
display: block;
max-width: 100%;
height: auto;
}
.stats-chart-pie {
max-height: 260px;
}
/* ---------- 全局统计页 ---------- */
.view-global-stats .page-header {
margin-bottom: var(--space-16);
}
.global-stats-desc {
margin: 0 0 var(--space-24);
font-size: 0.9375rem;
color: var(--text-muted);
}
.global-stats-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: var(--space-24);
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
}
.global-stats-tab {
padding: 0.5rem 1rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.875rem;
cursor: pointer;
transition: color 0.2s var(--ease), border-color 0.2s var(--ease), background 0.2s var(--ease);
}
.global-stats-tab:hover {
color: var(--text);
border-color: var(--accent-violet);
background: var(--bg-hover);
}
.global-stats-tab.active {
color: var(--accent-cyan);
border-color: var(--accent-violet);
background: var(--bg-hover);
}
.global-stats-content {
min-height: 200px;
}
.global-stats-loading,
.global-stats-empty {
text-align: center;
padding: var(--space-32);
color: var(--text-muted);
}
.global-stats-empty .empty-icon {
font-size: 2.5rem;
display: block;
margin-bottom: var(--space-16);
}
.global-stats-total {
margin: 0 0 var(--space-16);
font-size: 1rem;
color: var(--accent-cyan);
}
.global-stats-charts-wrap {
margin-bottom: var(--space-24);
}
.global-stats-charts-loading {
text-align: center;
padding: var(--space-16);
color: var(--text-muted);
font-size: 0.9375rem;
}
.global-stats-cards {
margin-top: 0;
}
.page-header {
display: flex;
align-items: center;
@@ -394,6 +592,22 @@ body {
cursor: not-allowed;
}
.btn-secondary {
background: var(--accent-teal);
border-color: transparent;
color: #fff;
}
.btn-secondary:hover:not(:disabled) {
background: #0d9488;
box-shadow: 0 0 20px rgba(20, 184, 166, 0.35);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
@@ -662,6 +876,13 @@ body {
color: var(--text-muted);
}
.pagination-page {
font-size: 0.8125rem;
font-weight: 300;
color: var(--text-muted);
margin-left: var(--space-16);
}
.pagination-btns {
display: flex;
gap: var(--space-16);
@@ -938,33 +1159,6 @@ body {
grid-template-columns: 1fr;
}
.sidebar {
width: 64px;
min-width: 64px;
padding: var(--space-16) 0;
}
.brand-text,
.nav-item span:not(.nav-icon),
.user-info,
.btn-logout {
display: none;
}
.sidebar-brand {
justify-content: center;
padding: 0 var(--space-16) var(--space-16);
}
.nav-item {
justify-content: center;
padding: 0.75rem;
}
.sidebar-footer {
padding: var(--space-16);
}
.drawer {
width: 100%;
}
+204 -39
View File
@@ -33,31 +33,28 @@
</section>
<div class="app" id="appMain" style="display: none;">
<aside class="sidebar">
<div class="sidebar-brand">
<span class="brand-icon"></span>
<span class="brand-text">项目管理系统</span>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" id="navList" data-nav="list">
<span class="nav-icon"></span>
<span>项目列表</span>
</a>
</nav>
<div class="sidebar-footer">
<span class="user-info" id="userInfo"></span>
<button type="button" class="btn-logout" id="btnLogout">退出</button>
</div>
</aside>
<main class="main">
<section id="view-list" class="view view-list active">
<header class="page-header">
<h1 class="page-title">项目列表</h1>
<div class="header-actions">
<div class="header-actions" id="listHeaderActions">
<button type="button" class="btn btn-secondary" id="btnExportXls" title="导出当前列表为 XLS 文件">
<span class="btn-icon"></span> 导出 XLS
</button>
<button type="button" class="btn btn-secondary" id="btnStats" title="按当前搜索条件统计并图表展示">
<span class="btn-icon">📊</span> 统计
</button>
<button type="button" class="btn btn-secondary" id="btnGlobalStats" title="调用后端统计 API 查看各类型项目">
<span class="btn-icon">📈</span> 全局统计
</button>
<button type="button" class="btn btn-primary btn-new-project" id="btnNewProject" style="display: none;">
<span class="btn-icon">+</span> 新建项目
</button>
<div id="topbarUserWrap" class="topbar-user">
<a href="#" class="topbar-link" id="btnUserManage" style="display: none;">用户管理</a>
<span class="user-info" id="userInfo"></span>
<button type="button" class="btn-logout" id="btnLogout">退出</button>
</div>
</div>
</header>
<div class="toolbar">
@@ -70,25 +67,6 @@
<button type="button" class="btn btn-search" id="btnSearch">搜索</button>
</div>
<div class="filter-row">
<div class="filter-group">
<label>项目进度</label>
<select id="filterProgress" class="input input-filter">
<option value="">全部</option>
<option value="planning">筹备中</option>
<option value="ongoing">进行中</option>
<option value="completed">已竣工</option>
<option value="settled">已结算</option>
</select>
</div>
<div class="filter-group">
<label>项目费用</label>
<select id="filterCost" class="input input-filter">
<option value="">全部</option>
<option value="normal">正常</option>
<option value="over">超支</option>
<option value="under">节余</option>
</select>
</div>
<div class="filter-group">
<label>日期筛选</label>
<select id="dateFilterType" class="input input-filter">
@@ -113,7 +91,7 @@
</div>
<div class="filter-row filter-row-amount">
<div class="filter-group">
<label>合同金额(万元)</label>
<label>中标合同金额(万元)</label>
<input type="number" id="amountMin" class="input input-amount" placeholder="最小" step="0.01" min="0" />
</div>
<div class="filter-group">
@@ -147,6 +125,7 @@
</div>
<div class="pagination" id="pagination">
<span class="pagination-info" id="paginationInfo">共 0 条</span>
<span class="pagination-page" id="paginationPage">第 1 / 1 页</span>
<div class="pagination-btns">
<button type="button" class="btn btn-sm" id="btnPrev" disabled>上一页</button>
<button type="button" class="btn btn-sm" id="btnNext">下一页</button>
@@ -154,11 +133,128 @@
</div>
</section>
<section id="view-statistics" class="view view-statistics">
<header class="page-header">
<button type="button" class="btn-back" id="btnBackFromStats">← 返回列表</button>
<h1 class="page-title">统计</h1>
<div class="header-actions" id="statsHeaderActions"></div>
</header>
<div class="stats-loading" id="statsLoading">正在加载符合当前搜索条件的项目数据…</div>
<div class="stats-content" id="statsContent" style="display: none;">
<p class="stats-desc" id="statsDesc">以下为当前搜索条件下的统计结果</p>
<div class="stats-cards">
<div class="stats-card">
<h3 class="stats-card-title">项目个数</h3>
<div class="stats-card-value" id="statsCount">0</div>
<canvas id="chartCount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">合计合同金额(万元)</h3>
<div class="stats-card-value" id="statsAmount">0</div>
<canvas id="chartAmount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card stats-card-wide">
<h3 class="stats-card-title">工程进度分布</h3>
<canvas id="chartProgress" class="stats-chart stats-chart-pie" width="360" height="240"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应收账款总额(万元)</h3>
<div class="stats-card-value" id="statsReceivable">0</div>
<canvas id="chartReceivable" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应付账款总额(万元)</h3>
<div class="stats-card-value" id="statsPayable">0</div>
<canvas id="chartPayable" class="stats-chart" width="280" height="120"></canvas>
</div>
</div>
</div>
<div class="stats-empty" id="statsEmpty" style="display: none;">
<span class="empty-icon">📊</span>
<p>当前搜索条件下暂无项目数据</p>
</div>
</section>
<section id="view-global-stats" class="view view-global-stats">
<header class="page-header">
<button type="button" class="btn-back" id="btnBackFromGlobalStats">← 返回列表</button>
<h1 class="page-title">全局统计</h1>
<div class="header-actions" id="globalStatsHeaderActions"></div>
</header>
<p class="global-stats-desc">按统计类型查看项目,数据来自后端统计 API。</p>
<div class="global-stats-tabs" id="globalStatsTabs" role="tablist">
<button type="button" class="tab global-stats-tab active" data-type="基建工程" role="tab">基建工程</button>
<button type="button" class="tab global-stats-tab" data-type="业扩项目" role="tab">业扩项目</button>
<button type="button" class="tab global-stats-tab" data-type="户表" role="tab">户表</button>
<button type="button" class="tab global-stats-tab" data-type="客户工程" role="tab">客户工程</button>
<button type="button" class="tab global-stats-tab" data-type="营销项目" role="tab">营销项目</button>
<button type="button" class="tab global-stats-tab" data-type="检修-技改-抢修" role="tab">检修-技改-抢修</button>
<button type="button" class="tab global-stats-tab" data-type="其他" role="tab">其他</button>
</div>
<div class="global-stats-content">
<div class="global-stats-loading" id="globalStatsLoading">加载中…</div>
<div class="global-stats-body" id="globalStatsBody" style="display: none;">
<p class="global-stats-total" id="globalStatsTotal">项目数:0</p>
<div class="global-stats-charts-wrap" id="globalStatsChartsWrap">
<div class="global-stats-charts-loading" id="globalStatsChartsLoading">加载图表数据…</div>
<div class="stats-cards global-stats-cards" id="globalStatsCards" style="display: none;">
<div class="stats-card">
<h3 class="stats-card-title">项目个数</h3>
<div class="stats-card-value" id="globalStatsCount">0</div>
<canvas id="chartGlobalCount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">合计合同金额(万元)</h3>
<div class="stats-card-value" id="globalStatsAmount">0</div>
<canvas id="chartGlobalAmount" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card stats-card-wide">
<h3 class="stats-card-title">工程进度分布</h3>
<canvas id="chartGlobalProgress" class="stats-chart stats-chart-pie" width="360" height="240"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应收账款总额(万元)</h3>
<div class="stats-card-value" id="globalStatsReceivable">0</div>
<canvas id="chartGlobalReceivable" class="stats-chart" width="280" height="120"></canvas>
</div>
<div class="stats-card">
<h3 class="stats-card-title">应付账款总额(万元)</h3>
<div class="stats-card-value" id="globalStatsPayable">0</div>
<canvas id="chartGlobalPayable" class="stats-chart" width="280" height="120"></canvas>
</div>
</div>
</div>
<div class="table-wrap">
<table class="table" id="globalStatsTable">
<thead>
<tr>
<th>合同编号</th>
<th>项目名称</th>
<th>合同金额</th>
<th>业主单位</th>
<th>签订日期</th>
<th>所属项目部</th>
<th>总体成本</th>
<th>项目负责人及电话</th>
<th>操作</th>
</tr>
</thead>
<tbody id="globalStatsTableBody"></tbody>
</table>
</div>
</div>
<div class="global-stats-empty" id="globalStatsEmpty" style="display: none;">
<span class="empty-icon">📋</span>
<p>该类型暂无项目</p>
</div>
</div>
</section>
<section id="view-detail" class="view view-detail">
<header class="page-header">
<button type="button" class="btn-back" id="btnBack">← 返回列表</button>
<h1 class="page-title detail-title" id="detailTitle">项目详情</h1>
<div class="header-actions">
<div class="header-actions" id="detailHeaderActions">
<button type="button" class="btn btn-ghost" id="btnViewLog">查看操作日志</button>
<button type="button" class="btn btn-primary" id="btnSave">
<span class="btn-text">保存</span>
@@ -259,9 +355,77 @@
</div>
</div>
</section>
<section id="view-users" class="view view-users">
<header class="page-header">
<button type="button" class="btn-back" id="btnBackFromUsers">← 返回列表</button>
<h1 class="page-title">用户管理</h1>
<div class="header-actions" id="usersHeaderActions">
<button type="button" class="btn btn-primary" id="btnAddUser">+ 新增用户</button>
</div>
</header>
<div class="table-wrap">
<div class="table-loading" id="usersLoading">加载中…</div>
<div class="table-empty" id="usersEmpty" style="display: none;">
<span class="empty-icon">👤</span>
<p>暂无用户</p>
</div>
<table class="table" id="usersTable">
<thead>
<tr>
<th>账号</th>
<th>显示名称</th>
<th>角色</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="usersTableBody"></tbody>
</table>
</div>
</section>
</main>
</div>
<div class="modal-overlay" id="userModalOverlay" aria-hidden="true"></div>
<div class="modal" id="userModal">
<div class="modal-header">
<h2 class="modal-title" id="userModalTitle">新增用户</h2>
<button type="button" class="modal-close" id="userModalClose">×</button>
</div>
<form id="userForm" class="modal-form">
<input type="hidden" id="userFormId" value="" />
<div class="form-group">
<label for="userFormUsername">账号 <em>*</em></label>
<input type="text" id="userFormUsername" class="input" required placeholder="唯一账号" />
</div>
<div class="form-group">
<label for="userFormPassword">密码 <em>*</em></label>
<input type="password" id="userFormPassword" class="input" placeholder="留空则不修改(编辑时)" />
</div>
<div class="form-group">
<label for="userFormRole">角色 <em>*</em></label>
<select id="userFormRole" class="input" required>
<option value="">请选择</option>
<option value="管理员">管理员</option>
<option value="市场部">市场部</option>
<option value="工程部">工程部</option>
<option value="技经部">技经部</option>
<option value="财务部">财务部</option>
<option value="物贸部">物贸部</option>
</select>
</div>
<div class="form-group">
<label for="userFormDisplayName">显示名称</label>
<input type="text" id="userFormDisplayName" class="input" placeholder="可选" />
</div>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" id="userModalCancel">取消</button>
<button type="submit" class="btn btn-primary" id="userFormSubmit">保存</button>
</div>
</form>
</div>
<div class="drawer-overlay" id="logOverlay" aria-hidden="true"></div>
<div class="drawer" id="logDrawer">
<div class="drawer-header">
@@ -283,6 +447,7 @@
<!-- Agentation 可视化反馈工具栏(https://github.com/benjitaylor/agentation -->
<div id="agentation-root"></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script type="module" src="dist/agentation.js"></script>
+783 -7
View File
@@ -5,10 +5,11 @@
(function () {
'use strict';
var PAGE_SIZE = 20;
var PAGE_SIZE = 50;
var currentPage = 1;
var totalCount = 0;
var currentProjectId = null;
var currentList = [];
var viewLogin = document.getElementById('view-login');
var appMain = document.getElementById('appMain');
@@ -18,14 +19,13 @@
var btnLogout = document.getElementById('btnLogout');
var userInfo = document.getElementById('userInfo');
var btnNewProject = document.getElementById('btnNewProject');
var btnExportXls = document.getElementById('btnExportXls');
var navList = document.getElementById('navList');
var viewList = document.getElementById('view-list');
var viewDetail = document.getElementById('view-detail');
var searchType = document.getElementById('searchType');
var searchKeyword = document.getElementById('searchKeyword');
var btnSearch = document.getElementById('btnSearch');
var filterProgress = document.getElementById('filterProgress');
var filterCost = document.getElementById('filterCost');
var dateFilterType = document.getElementById('dateFilterType');
var dateFrom = document.getElementById('dateFrom');
var dateTo = document.getElementById('dateTo');
@@ -37,6 +37,7 @@
var projectTable = document.getElementById('projectTable');
var projectTableBody = document.getElementById('projectTableBody');
var paginationInfo = document.getElementById('paginationInfo');
var paginationPage = document.getElementById('paginationPage');
var btnPrev = document.getElementById('btnPrev');
var btnNext = document.getElementById('btnNext');
var btnBack = document.getElementById('btnBack');
@@ -52,6 +53,52 @@
var logEmpty = document.getElementById('logEmpty');
var logList = document.getElementById('logList');
var toast = document.getElementById('toast');
var topbarUserWrap = document.getElementById('topbarUserWrap');
var listHeaderActions = document.getElementById('listHeaderActions');
var detailHeaderActions = document.getElementById('detailHeaderActions');
var viewUsers = document.getElementById('view-users');
var usersHeaderActions = document.getElementById('usersHeaderActions');
var btnUserManage = document.getElementById('btnUserManage');
var btnBackFromUsers = document.getElementById('btnBackFromUsers');
var btnAddUser = document.getElementById('btnAddUser');
var usersLoading = document.getElementById('usersLoading');
var usersEmpty = document.getElementById('usersEmpty');
var usersTable = document.getElementById('usersTable');
var usersTableBody = document.getElementById('usersTableBody');
var userModalOverlay = document.getElementById('userModalOverlay');
var userModal = document.getElementById('userModal');
var userModalTitle = document.getElementById('userModalTitle');
var userModalClose = document.getElementById('userModalClose');
var userForm = document.getElementById('userForm');
var userFormId = document.getElementById('userFormId');
var userFormUsername = document.getElementById('userFormUsername');
var userFormPassword = document.getElementById('userFormPassword');
var userFormRole = document.getElementById('userFormRole');
var userFormDisplayName = document.getElementById('userFormDisplayName');
var userModalCancel = document.getElementById('userModalCancel');
var btnStats = document.getElementById('btnStats');
var btnBackFromStats = document.getElementById('btnBackFromStats');
var viewStatistics = document.getElementById('view-statistics');
var statsHeaderActions = document.getElementById('statsHeaderActions');
var statsLoading = document.getElementById('statsLoading');
var statsContent = document.getElementById('statsContent');
var statsEmpty = document.getElementById('statsEmpty');
var statsDesc = document.getElementById('statsDesc');
var statsCount = document.getElementById('statsCount');
var statsAmount = document.getElementById('statsAmount');
var statsReceivable = document.getElementById('statsReceivable');
var statsPayable = document.getElementById('statsPayable');
var btnGlobalStats = document.getElementById('btnGlobalStats');
var btnBackFromGlobalStats = document.getElementById('btnBackFromGlobalStats');
var viewGlobalStats = document.getElementById('view-global-stats');
var globalStatsHeaderActions = document.getElementById('globalStatsHeaderActions');
var globalStatsTabs = document.getElementById('globalStatsTabs');
var globalStatsLoading = document.getElementById('globalStatsLoading');
var globalStatsBody = document.getElementById('globalStatsBody');
var globalStatsTotal = document.getElementById('globalStatsTotal');
var globalStatsTableBody = document.getElementById('globalStatsTableBody');
var globalStatsEmpty = document.getElementById('globalStatsEmpty');
var currentGlobalStatsType = '基建工程';
function showLogin() {
if (viewLogin) viewLogin.classList.remove('hidden');
@@ -64,9 +111,23 @@
var u = window.API && window.API.getUser ? window.API.getUser() : null;
if (userInfo && u) userInfo.textContent = (u.displayName || u.username || '') + ' · ' + (u.role || '');
updateNewProjectVisibility();
updateAdminEntryVisibility();
loadList();
}
function isAdminRole() {
var u = window.API && window.API.getUser ? window.API.getUser() : null;
if (!u) return false;
var role = (u.role && typeof u.role === 'string') ? u.role.trim() : '';
var username = (u.username && typeof u.username === 'string') ? u.username.trim().toLowerCase() : '';
return role === '管理员' || username === 'admin';
}
function updateAdminEntryVisibility() {
if (!btnUserManage) return;
btnUserManage.style.display = isAdminRole() ? 'inline' : 'none';
}
window.onAuthRequired = function () {
showLogin();
};
@@ -93,9 +154,219 @@
function showList() {
if (viewList) viewList.classList.add('active');
if (viewDetail) viewDetail.classList.remove('active');
if (viewUsers) viewUsers.classList.remove('active');
if (viewStatistics) viewStatistics.classList.remove('active');
if (viewGlobalStats) viewGlobalStats.classList.remove('active');
if (navList) navList.classList.add('active');
document.querySelectorAll('.nav-item').forEach(function (n) { n.classList.remove('active'); });
if (navList) navList.classList.add('active');
if (topbarUserWrap && listHeaderActions) listHeaderActions.appendChild(topbarUserWrap);
}
function showUsers() {
if (viewList) viewList.classList.remove('active');
if (viewDetail) viewDetail.classList.remove('active');
if (viewUsers) viewUsers.classList.add('active');
if (viewStatistics) viewStatistics.classList.remove('active');
if (viewGlobalStats) viewGlobalStats.classList.remove('active');
if (navList) navList.classList.remove('active');
if (topbarUserWrap && usersHeaderActions) usersHeaderActions.appendChild(topbarUserWrap);
loadUsers();
}
function showGlobalStats() {
if (viewList) viewList.classList.remove('active');
if (viewDetail) viewDetail.classList.remove('active');
if (viewUsers) viewUsers.classList.remove('active');
if (viewStatistics) viewStatistics.classList.remove('active');
if (viewGlobalStats) viewGlobalStats.classList.add('active');
if (navList) navList.classList.remove('active');
if (topbarUserWrap && globalStatsHeaderActions) globalStatsHeaderActions.appendChild(topbarUserWrap);
currentGlobalStatsType = '基建工程';
if (globalStatsTabs) {
globalStatsTabs.querySelectorAll('.global-stats-tab').forEach(function (t) {
t.classList.toggle('active', t.getAttribute('data-type') === currentGlobalStatsType);
});
}
loadGlobalStatsTab(currentGlobalStatsType);
}
function loadGlobalStatsTab(type) {
currentGlobalStatsType = type;
destroyGlobalStatsCharts();
if (globalStatsLoading) globalStatsLoading.style.display = 'block';
if (globalStatsBody) globalStatsBody.style.display = 'none';
if (globalStatsEmpty) globalStatsEmpty.style.display = 'none';
var globalStatsChartsLoading = document.getElementById('globalStatsChartsLoading');
var globalStatsCards = document.getElementById('globalStatsCards');
if (globalStatsChartsLoading) globalStatsChartsLoading.style.display = 'none';
if (globalStatsCards) globalStatsCards.style.display = 'none';
if (globalStatsTabs) {
globalStatsTabs.querySelectorAll('.global-stats-tab').forEach(function (t) {
t.classList.toggle('active', t.getAttribute('data-type') === type);
});
}
window.API.post('/api/projects/statistics', { statisticsType: type }).then(function (res) {
if (globalStatsLoading) globalStatsLoading.style.display = 'none';
if (!res.ok) {
showToast(res.message || '加载失败', 'error');
if (globalStatsEmpty) globalStatsEmpty.querySelector('p').textContent = res.message || '加载失败';
if (globalStatsEmpty) globalStatsEmpty.style.display = 'block';
return;
}
var total = (res.data && res.data.total) != null ? res.data.total : 0;
var list = (res.data && res.data.list) || [];
if (total === 0 && list.length === 0) {
if (globalStatsBody) globalStatsBody.style.display = 'none';
if (globalStatsEmpty) globalStatsEmpty.style.display = 'block';
return;
}
if (globalStatsTotal) globalStatsTotal.textContent = '项目数:' + (total != null ? total : list.length);
if (globalStatsBody) globalStatsBody.style.display = 'block';
if (globalStatsEmpty) globalStatsEmpty.style.display = 'none';
if (globalStatsTableBody) {
var rows = (list.length ? list : []).map(function (p) {
return (
'<tr>' +
'<td>' + (p.contractCode || '—') + '</td>' +
'<td>' + (p.projectName || '—') + '</td>' +
'<td>' + (p.bidContractAmount != null && p.bidContractAmount !== '' ? p.bidContractAmount : '—') + '</td>' +
'<td>' + (p.ownerUnit || '—') + '</td>' +
'<td>' + (p.signDate || '—') + '</td>' +
'<td>' + (p.projectDepartment || '—') + '</td>' +
'<td>' + (p.totalCost != null && p.totalCost !== '' ? p.totalCost : '—') + '</td>' +
'<td>' + (p.projectLeaderContact || '—') + '</td>' +
'<td class="cell-actions">' +
'<button type="button" class="link-action" data-id="' + p.id + '" data-mode="view">查看</button> ' +
'<button type="button" class="link-action" data-id="' + p.id + '" data-mode="edit">编辑</button>' +
'</td>' +
'</tr>'
);
}).join('');
globalStatsTableBody.innerHTML = rows;
globalStatsTableBody.querySelectorAll('.link-action').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
var mode = btn.getAttribute('data-mode') || 'edit';
showDetail(id, mode);
});
});
}
if (list.length === 0) return;
if (globalStatsChartsLoading) globalStatsChartsLoading.style.display = 'block';
var chain = Promise.resolve([]);
list.forEach(function (p) {
chain = chain.then(function (acc) {
return window.API.post('/api/projects/detail', { id: p.id }).then(function (detailRes) {
if (detailRes.ok && detailRes.data) acc.push(detailRes.data);
return acc;
});
});
});
return chain;
}).then(function (fullList) {
if (!fullList || fullList.length === 0) return;
var globalStatsChartsLoading = document.getElementById('globalStatsChartsLoading');
var globalStatsCards = document.getElementById('globalStatsCards');
var globalStatsCount = document.getElementById('globalStatsCount');
var globalStatsAmount = document.getElementById('globalStatsAmount');
var globalStatsReceivable = document.getElementById('globalStatsReceivable');
var globalStatsPayable = document.getElementById('globalStatsPayable');
if (globalStatsChartsLoading) globalStatsChartsLoading.style.display = 'none';
if (globalStatsCards) globalStatsCards.style.display = ''; /* .stats-cards 已是 grid */
var count = fullList.length;
var totalAmount = 0;
var totalReceivable = 0;
var totalPayable = 0;
var progressMap = {};
fullList.forEach(function (d) {
var amt = d.contract && d.contract.bidContractAmount != null && d.contract.bidContractAmount !== ''
? parseFloat(String(d.contract.bidContractAmount)) : 0;
if (!isNaN(amt)) totalAmount += amt;
var recv = d.receivable && d.receivable.receivableProgress != null && d.receivable.receivableProgress !== ''
? parseFloat(String(d.receivable.receivableProgress)) : 0;
if (!isNaN(recv)) totalReceivable += recv;
var pay = d.payable && d.payable.payableAmount != null && d.payable.payableAmount !== ''
? parseFloat(String(d.payable.payableAmount)) : 0;
if (!isNaN(pay)) totalPayable += pay;
var prog = (d.other && d.other.cumulativeProgress) || (d.contract && d.contract.cumulativeProgress) || d.progress || '';
if (typeof prog !== 'string') prog = String(prog);
prog = prog.trim() || '未填';
progressMap[prog] = (progressMap[prog] || 0) + 1;
});
if (globalStatsCount) globalStatsCount.textContent = count;
if (globalStatsAmount) globalStatsAmount.textContent = totalAmount.toFixed(2);
if (globalStatsReceivable) globalStatsReceivable.textContent = totalReceivable.toFixed(2);
if (globalStatsPayable) globalStatsPayable.textContent = totalPayable.toFixed(2);
var ChartLib = typeof Chart !== 'undefined' ? Chart : null;
if (!ChartLib) return;
var gridColor = 'rgba(148, 163, 184, 0.2)';
var fontColor = '#94a3b8';
var barColors = ['#8b5cf6', '#22d3ee', '#ec4899', '#f59e0b', '#14b8a6', '#a78bfa', '#06b6d4', '#f472b6'];
var elCount = document.getElementById('chartGlobalCount');
if (elCount) {
new ChartLib(elCount, {
type: 'bar',
data: { labels: ['项目个数'], datasets: [{ label: '个数', data: [count], backgroundColor: barColors[0] }] },
options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } }
});
}
var elAmount = document.getElementById('chartGlobalAmount');
if (elAmount) {
new ChartLib(elAmount, {
type: 'bar',
data: { labels: ['合同金额(万元)'], datasets: [{ label: '合计', data: [totalAmount], backgroundColor: barColors[1] }] },
options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } }
});
}
var progressLabels = Object.keys(progressMap);
var progressData = progressLabels.map(function (k) { return progressMap[k]; });
var pieColors = progressLabels.map(function (_, i) { return barColors[i % barColors.length]; });
var elProgress = document.getElementById('chartGlobalProgress');
if (elProgress && progressLabels.length > 0) {
new ChartLib(elProgress, {
type: 'pie',
data: { labels: progressLabels, datasets: [{ data: progressData, backgroundColor: pieColors }] },
options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { position: 'right', labels: { color: fontColor } } } }
});
}
var elReceivable = document.getElementById('chartGlobalReceivable');
if (elReceivable) {
new ChartLib(elReceivable, {
type: 'bar',
data: { labels: ['应收账款(万元)'], datasets: [{ label: '合计', data: [totalReceivable], backgroundColor: barColors[2] }] },
options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } }
});
}
var elPayable = document.getElementById('chartGlobalPayable');
if (elPayable) {
new ChartLib(elPayable, {
type: 'bar',
data: { labels: ['应付账款(万元)'], datasets: [{ label: '合计', data: [totalPayable], backgroundColor: barColors[3] }] },
options: { indexAxis: 'y', responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } }, scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } } }
});
}
}).catch(function () {
if (globalStatsLoading) globalStatsLoading.style.display = 'none';
showToast('加载失败', 'error');
if (globalStatsEmpty) globalStatsEmpty.querySelector('p').textContent = '加载失败';
if (globalStatsEmpty) globalStatsEmpty.style.display = 'block';
});
}
function showStatistics() {
if (viewList) viewList.classList.remove('active');
if (viewDetail) viewDetail.classList.remove('active');
if (viewUsers) viewUsers.classList.remove('active');
if (viewStatistics) viewStatistics.classList.add('active');
if (navList) navList.classList.remove('active');
if (topbarUserWrap && statsHeaderActions) statsHeaderActions.appendChild(topbarUserWrap);
loadStatistics();
}
var detailMode = 'edit'; // 'view' | 'edit'
@@ -110,11 +381,15 @@
detailMode = mode != null ? mode : (projectId ? 'edit' : 'edit');
if (viewList) viewList.classList.remove('active');
if (viewDetail) viewDetail.classList.add('active');
if (viewUsers) viewUsers.classList.remove('active');
if (viewStatistics) viewStatistics.classList.remove('active');
if (viewGlobalStats) viewGlobalStats.classList.remove('active');
document.querySelectorAll('.nav-item').forEach(function (n) { n.classList.remove('active'); });
if (detailTitle) detailTitle.textContent = projectId ? '项目详情' : '新建项目';
if (detailLoading) detailLoading.style.display = 'block';
if (detailContent) detailContent.style.display = 'none';
updateDetailHeaderVisibility(detailMode);
if (topbarUserWrap && detailHeaderActions) detailHeaderActions.appendChild(topbarUserWrap);
if (projectId) {
window.API.post('/api/projects/detail', { id: projectId }).then(function (res) {
@@ -203,8 +478,6 @@
pageSize: PAGE_SIZE,
searchType: searchType && searchType.value ? searchType.value : undefined,
keyword: searchKeyword && searchKeyword.value ? searchKeyword.value.trim() : undefined,
progress: filterProgress && filterProgress.value ? filterProgress.value : undefined,
cost: filterCost && filterCost.value ? filterCost.value : undefined,
dateFilterType: dateFilterType && dateFilterType.value ? dateFilterType.value : undefined,
dateFrom: dateFrom && dateFrom.value ? dateFrom.value : undefined,
dateTo: dateTo && dateTo.value ? dateTo.value : undefined,
@@ -224,6 +497,7 @@
return;
}
var list = (res.data && res.data.list) || [];
currentList = list;
totalCount = (res.data && res.data.total) || 0;
if (list.length === 0) {
if (listEmpty) listEmpty.style.display = 'flex';
@@ -262,11 +536,470 @@
});
}
if (paginationInfo) paginationInfo.textContent = '共 ' + totalCount + ' 条';
var totalPages = totalCount > 0 ? Math.ceil(totalCount / PAGE_SIZE) : 1;
if (paginationPage) paginationPage.textContent = '第 ' + currentPage + ' / ' + totalPages + ' 页';
if (btnPrev) btnPrev.disabled = currentPage <= 1;
if (btnNext) btnNext.disabled = currentPage * PAGE_SIZE >= totalCount;
});
}
function escapeHtml(s) {
if (s == null || s === '') return '';
var str = String(s);
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
var EXPORT_COLUMNS = [
{ section: 'contract', key: 'contractCode', label: '合同编号' },
{ section: 'contract', key: 'powerBureauContractCode', label: '供电局项目合同编号' },
{ section: 'contract', key: 'projectName', label: '项目名称' },
{ section: 'contract', key: 'subItemCount', label: '子项个数' },
{ section: 'contract', key: 'subItemCode', label: '子项编码' },
{ section: 'contract', key: 'totalInvestment', label: '项目总投资(万元)' },
{ section: 'contract', key: 'bidContractAmount', label: '中标合同金额(万元)' },
{ section: 'contract', key: 'warrantyRatio', label: '质保金比例' },
{ section: 'contract', key: 'settlementAmount', label: '结算金额(万元)' },
{ section: 'contract', key: 'totalCostEstimate', label: '总成本测算' },
{ section: 'contract', key: 'voltageLevel', label: '工程电压等级' },
{ section: 'contract', key: 'projectCategory', label: '工程类别' },
{ section: 'contract', key: 'ownerUnit', label: '业主单位' },
{ section: 'contract', key: 'ownerContact', label: '业主联系人及电话' },
{ section: 'contract', key: 'bidType', label: '中标形式' },
{ section: 'contract', key: 'signDate', label: '签订日期' },
{ section: 'contract', key: 'startDate', label: '开工日期' },
{ section: 'contract', key: 'plannedCompletionDate', label: '计划竣工日期' },
{ section: 'contract', key: 'actualCompletionDate', label: '实际竣工日期' },
{ section: 'contract', key: 'warrantyAmount', label: '质保金(万元)' },
{ section: 'contract', key: 'warrantyEndDate', label: '质保期截止日' },
{ section: 'contract', key: 'actualWarrantyRefundDate', label: '实际退质保金日期' },
{ section: 'contract', key: 'projectDepartment', label: '所属项目部' },
{ section: 'contract', key: 'projectLeaderContact', label: '项目负责人及电话' },
{ section: 'contract', key: 'paymentMethod', label: '工程款拨付方式' },
{ section: 'costControl', key: 'totalCost', label: '总体成本' },
{ section: 'costControl', key: 'isAdjusted', label: '是否调整' },
{ section: 'costControl', key: 'migrantWorkerPlan', label: '农民工工资(按进度计划)' },
{ section: 'costControl', key: 'migrantWorkerActual', label: '农民工工资(实付)' },
{ section: 'costControl', key: 'selfSupplyMaterialControl', label: '乙供材料费(控制)' },
{ section: 'costControl', key: 'materialPayableByRatio', label: '应付材料费(按收款比例)' },
{ section: 'costControl', key: 'materialActualOccurred', label: '实际发生材料费' },
{ section: 'costControl', key: 'materialActualPaid', label: '实际支付材料费' },
{ section: 'costControl', key: 'otherCostControl', label: '其他费用(控制)' },
{ section: 'costControl', key: 'otherPayable', label: '应付其他费' },
{ section: 'costControl', key: 'otherActual', label: '实际其他费用' },
{ section: 'costControl', key: 'tax', label: '税金' },
{ section: 'costControl', key: 'profit', label: '利润(万元)' },
{ section: 'costControl', key: 'actualProfit', label: '实际利润(万元)' },
{ section: 'costControl', key: 'costSettlementAmount', label: '成本结算金额(万元)' },
{ section: 'receivable', key: 'receivableProgress', label: '应收款(完成进度款)' },
{ section: 'receivable', key: 'invoiceAmount', label: '开票金额(万元)' },
{ section: 'receivable', key: 'actualReceiptAmount', label: '实际收款金额(万元)' },
{ section: 'receivable', key: 'actualReceiptRate', label: '实际收款完成率' },
{ section: 'payable', key: 'payableAmount', label: '应付款金额(万元)' },
{ section: 'payable', key: 'actualPaymentAmount', label: '实际付款金额(万元)' },
{ section: 'payable', key: 'unreceivedAmount', label: '未收款(万元)' },
{ section: 'payable', key: 'actualPaymentRate', label: '实际付款完成率' },
{ section: 'payable', key: 'migrantWorkerArrears', label: '民工工资清欠金额(万元)' },
{ section: 'other', key: 'settlementCostEstimate', label: '结算后成本测算金额' },
{ section: 'other', key: 'settlementLaborCost', label: '结算人工费' },
{ section: 'other', key: 'settlementMaterialCost', label: '结算材料费' },
{ section: 'other', key: 'settlementOtherCost', label: '结算其他费' },
{ section: 'other', key: 'dueSettlementCount', label: '到期应结算项目个数' },
{ section: 'other', key: 'overdueSettlementCount', label: '到期未完成结算个数' },
{ section: 'other', key: 'existingProblems', label: '存在的问题' },
{ section: 'other', key: 'suggestions', label: '建议措施' },
{ section: 'other', key: 'cumulativeProgress', label: '累计进度' },
{ section: 'other', key: 'remark', label: '备注' },
];
function getExportCellValue(project, col) {
var v;
if (col.section) {
var sec = project[col.section];
v = sec && sec[col.key] !== undefined && sec[col.key] !== null ? sec[col.key] : '';
} else {
v = project[col.key] !== undefined && project[col.key] !== null ? project[col.key] : '';
}
return v === null || v === undefined ? '' : String(v);
}
function exportToXls() {
showToast('正在导出…', '');
// 导出 = 当前搜索条件下的全部项目,不限于当前页
var listBody = {
page: 1,
pageSize: 99999,
searchType: searchType && searchType.value ? searchType.value : undefined,
keyword: searchKeyword && searchKeyword.value ? searchKeyword.value.trim() : undefined,
dateFilterType: dateFilterType && dateFilterType.value ? dateFilterType.value : undefined,
dateFrom: dateFrom && dateFrom.value ? dateFrom.value : undefined,
dateTo: dateTo && dateTo.value ? dateTo.value : undefined,
dateAbnormal: dateAbnormal && dateAbnormal.checked ? true : undefined,
amountMin: amountMin && amountMin.value !== '' ? parseFloat(amountMin.value) : undefined,
amountMax: amountMax && amountMax.value !== '' ? parseFloat(amountMax.value) : undefined,
};
if (!listBody.keyword) listBody.searchType = undefined;
if (!listBody.dateFilterType) listBody.dateFrom = listBody.dateTo = undefined;
window.API.post('/api/projects/list', listBody).then(function (res) {
if (!res.ok) {
showToast(res.message || '获取列表失败', 'error');
return;
}
var list = (res.data && res.data.list) || [];
if (list.length === 0) {
showToast('当前搜索条件下无数据可导出', 'error');
return;
}
var chain = Promise.resolve([]);
list.forEach(function (p) {
chain = chain.then(function (acc) {
return window.API.post('/api/projects/detail', { id: p.id }).then(function (detailRes) {
if (detailRes.ok && detailRes.data) acc.push(detailRes.data);
return acc;
});
});
});
return chain;
}).then(function (fullList) {
if (!fullList || fullList.length === 0) return;
var headers = EXPORT_COLUMNS.map(function (c) { return c.label; });
var rows = fullList.map(function (project) {
return EXPORT_COLUMNS.map(function (col) {
return getExportCellValue(project, col);
});
});
var tableRows = rows
.map(function (row) {
return (
'<tr>' +
row.map(function (cell) {
return '<td>' + escapeHtml(cell) + '</td>';
}).join('') +
'</tr>'
);
})
.join('');
var headerRow =
'<tr>' +
headers.map(function (h) {
return '<th>' + escapeHtml(h) + '</th>';
}).join('') +
'</tr>';
var html =
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">' +
'<head><meta http-equiv="Content-Type" content="text/html; charset=utf-16"/><style>table{border-collapse:collapse;} th,td{border:1px solid #333; padding:4px 8px;}</style></head>' +
'<body><table>' +
headerRow +
tableRows +
'</table></body></html>';
var str = html;
var bom = new Uint8Array([0xFF, 0xFE]);
var len = str.length;
var arr = new Uint8Array(bom.length + len * 2);
arr.set(bom, 0);
for (var i = 0; i < len; i++) {
var c = str.charCodeAt(i);
arr[bom.length + i * 2] = c & 0xFF;
arr[bom.length + i * 2 + 1] = (c >> 8) & 0xFF;
}
var blob = new Blob([arr], { type: 'application/vnd.ms-excel' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = '项目列表_' + new Date().toISOString().slice(0, 10) + '.xls';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('已导出 ' + fullList.length + ' 条', 'success');
}).catch(function () {
showToast('导出失败', 'error');
});
}
function getStatsListBody() {
var body = {
page: 1,
pageSize: 99999,
searchType: searchType && searchType.value ? searchType.value : undefined,
keyword: searchKeyword && searchKeyword.value ? searchKeyword.value.trim() : undefined,
dateFilterType: dateFilterType && dateFilterType.value ? dateFilterType.value : undefined,
dateFrom: dateFrom && dateFrom.value ? dateFrom.value : undefined,
dateTo: dateTo && dateTo.value ? dateTo.value : undefined,
dateAbnormal: dateAbnormal && dateAbnormal.checked ? true : undefined,
amountMin: amountMin && amountMin.value !== '' ? parseFloat(amountMin.value) : undefined,
amountMax: amountMax && amountMax.value !== '' ? parseFloat(amountMax.value) : undefined,
};
if (!body.keyword) body.searchType = undefined;
if (!body.dateFilterType) body.dateFrom = body.dateTo = undefined;
return body;
}
function destroyStatsCharts() {
['chartCount', 'chartAmount', 'chartProgress', 'chartReceivable', 'chartPayable'].forEach(function (id) {
var el = document.getElementById(id);
if (el && typeof Chart !== 'undefined' && Chart.getChart) {
var c = Chart.getChart(el);
if (c) c.destroy();
}
});
}
function destroyGlobalStatsCharts() {
['chartGlobalCount', 'chartGlobalAmount', 'chartGlobalProgress', 'chartGlobalReceivable', 'chartGlobalPayable'].forEach(function (id) {
var el = document.getElementById(id);
if (el && typeof Chart !== 'undefined' && Chart.getChart) {
var c = Chart.getChart(el);
if (c) c.destroy();
}
});
}
function loadStatistics() {
if (statsLoading) statsLoading.style.display = 'block';
if (statsContent) statsContent.style.display = 'none';
if (statsEmpty) statsEmpty.style.display = 'none';
destroyStatsCharts();
var listBody = getStatsListBody();
window.API.post('/api/projects/list', listBody).then(function (res) {
if (!res.ok) {
if (statsLoading) statsLoading.style.display = 'none';
showToast(res.message || '获取列表失败', 'error');
if (statsEmpty) statsEmpty.querySelector('p').textContent = res.message || '获取列表失败';
if (statsEmpty) statsEmpty.style.display = 'block';
return;
}
var list = (res.data && res.data.list) || [];
if (list.length === 0) {
if (statsLoading) statsLoading.style.display = 'none';
if (statsEmpty) statsEmpty.style.display = 'block';
return;
}
var chain = Promise.resolve([]);
list.forEach(function (p) {
chain = chain.then(function (acc) {
return window.API.post('/api/projects/detail', { id: p.id }).then(function (detailRes) {
if (detailRes.ok && detailRes.data) acc.push(detailRes.data);
return acc;
});
});
});
return chain;
}).then(function (fullList) {
if (statsLoading) statsLoading.style.display = 'none';
if (!fullList || fullList.length === 0) return;
var count = fullList.length;
var totalAmount = 0;
var totalReceivable = 0;
var totalPayable = 0;
var progressMap = {};
fullList.forEach(function (d) {
var amt = d.contract && d.contract.bidContractAmount != null && d.contract.bidContractAmount !== ''
? parseFloat(String(d.contract.bidContractAmount)) : 0;
if (!isNaN(amt)) totalAmount += amt;
var recv = d.receivable && d.receivable.receivableProgress != null && d.receivable.receivableProgress !== ''
? parseFloat(String(d.receivable.receivableProgress)) : 0;
if (!isNaN(recv)) totalReceivable += recv;
var pay = d.payable && d.payable.payableAmount != null && d.payable.payableAmount !== ''
? parseFloat(String(d.payable.payableAmount)) : 0;
if (!isNaN(pay)) totalPayable += pay;
var prog = (d.other && d.other.cumulativeProgress) || (d.contract && d.contract.cumulativeProgress) || d.progress || '';
if (typeof prog !== 'string') prog = String(prog);
prog = prog.trim() || '未填';
progressMap[prog] = (progressMap[prog] || 0) + 1;
});
if (statsCount) statsCount.textContent = count;
if (statsAmount) statsAmount.textContent = totalAmount.toFixed(2);
if (statsReceivable) statsReceivable.textContent = totalReceivable.toFixed(2);
if (statsPayable) statsPayable.textContent = totalPayable.toFixed(2);
if (statsContent) statsContent.style.display = 'block';
var ChartLib = typeof Chart !== 'undefined' ? Chart : null;
if (!ChartLib) return;
var gridColor = 'rgba(148, 163, 184, 0.2)';
var fontColor = '#94a3b8';
var barColors = ['#8b5cf6', '#22d3ee', '#ec4899', '#f59e0b', '#14b8a6', '#a78bfa', '#06b6d4', '#f472b6'];
var elCount = document.getElementById('chartCount');
if (elCount) {
new ChartLib(elCount, {
type: 'bar',
data: { labels: ['项目个数'], datasets: [{ label: '个数', data: [count], backgroundColor: barColors[0] }] },
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } }
}
});
}
var elAmount = document.getElementById('chartAmount');
if (elAmount) {
new ChartLib(elAmount, {
type: 'bar',
data: { labels: ['合同金额(万元)'], datasets: [{ label: '合计', data: [totalAmount], backgroundColor: barColors[1] }] },
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } }
}
});
}
var progressLabels = Object.keys(progressMap);
var progressData = progressLabels.map(function (k) { return progressMap[k]; });
var pieColors = progressLabels.map(function (_, i) { return barColors[i % barColors.length]; });
var elProgress = document.getElementById('chartProgress');
if (elProgress && progressLabels.length > 0) {
new ChartLib(elProgress, {
type: 'pie',
data: { labels: progressLabels, datasets: [{ data: progressData, backgroundColor: pieColors }] },
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'right', labels: { color: fontColor } } }
}
});
}
var elReceivable = document.getElementById('chartReceivable');
if (elReceivable) {
new ChartLib(elReceivable, {
type: 'bar',
data: { labels: ['应收账款(万元)'], datasets: [{ label: '合计', data: [totalReceivable], backgroundColor: barColors[2] }] },
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } }
}
});
}
var elPayable = document.getElementById('chartPayable');
if (elPayable) {
new ChartLib(elPayable, {
type: 'bar',
data: { labels: ['应付账款(万元)'], datasets: [{ label: '合计', data: [totalPayable], backgroundColor: barColors[3] }] },
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: { x: { grid: { color: gridColor }, ticks: { color: fontColor } }, y: { grid: { color: gridColor }, ticks: { color: fontColor } } }
}
});
}
}).catch(function () {
if (statsLoading) statsLoading.style.display = 'none';
showToast('加载统计失败', 'error');
if (statsEmpty) statsEmpty.querySelector('p').textContent = '加载统计失败';
if (statsEmpty) statsEmpty.style.display = 'block';
});
}
function loadUsers() {
if (usersLoading) usersLoading.style.display = 'block';
if (usersEmpty) usersEmpty.style.display = 'none';
if (usersTable) usersTable.style.visibility = 'hidden';
window.API.post('/api/users/list', { page: 1, pageSize: 100 }).then(function (res) {
if (usersLoading) usersLoading.style.display = 'none';
if (!res.ok) {
showToast(res.message || '加载失败', 'error');
if (usersEmpty) { usersEmpty.style.display = 'flex'; usersEmpty.querySelector('p').textContent = res.message || '加载失败'; }
return;
}
var list = (res.data && res.data.list) || [];
if (list.length === 0) {
if (usersEmpty) { usersEmpty.style.display = 'flex'; usersEmpty.querySelector('p').textContent = '暂无用户'; }
if (usersTable) usersTable.style.visibility = 'hidden';
} else {
if (usersEmpty) usersEmpty.style.display = 'none';
if (usersTable) usersTable.style.visibility = 'visible';
var html = list
.map(function (u) {
var updatedAt = u.updatedAt ? u.updatedAt.slice(0, 10) : '—';
return (
'<tr>' +
'<td>' + (u.username || '—') + '</td>' +
'<td>' + (u.displayName || '—') + '</td>' +
'<td>' + (u.role || '—') + '</td>' +
'<td>' + updatedAt + '</td>' +
'<td class="cell-actions">' +
'<button type="button" class="link-action user-edit" data-id="' + u.id + '">编辑</button> ' +
'<button type="button" class="link-action user-delete" data-id="' + u.id + '" data-username="' + (u.username || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</td>' +
'</tr>'
);
})
.join('');
if (usersTableBody) usersTableBody.innerHTML = html;
if (usersTableBody) {
usersTableBody.querySelectorAll('.user-edit').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
var row = btn.closest('tr');
var username = row && row.cells[0] ? row.cells[0].textContent : '';
var displayName = row && row.cells[1] ? row.cells[1].textContent : '';
var role = row && row.cells[2] ? row.cells[2].textContent : '';
openUserModal(true, { id: id, username: username, displayName: displayName, role: role });
});
});
usersTableBody.querySelectorAll('.user-delete').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
var username = btn.getAttribute('data-username') || id;
if (!window.confirm('确定要删除用户「' + username + '」吗?')) return;
window.API.post('/api/users/delete', { id: id }).then(function (res) {
if (res.ok) {
showToast('删除成功', 'success');
loadUsers();
} else {
showToast(res.message || '删除失败', 'error');
}
});
});
});
}
}
});
}
function openUserModal(isEdit, user) {
if (userModalTitle) userModalTitle.textContent = isEdit ? '修改用户' : '新增用户';
if (userFormId) userFormId.value = user && user.id ? user.id : '';
if (userFormUsername) {
userFormUsername.value = user && user.username ? user.username : '';
userFormUsername.readOnly = !!isEdit;
}
if (userFormPassword) {
userFormPassword.value = '';
userFormPassword.placeholder = isEdit ? '留空则不修改' : '请输入密码';
userFormPassword.required = !isEdit;
}
if (userFormRole) {
userFormRole.value = user && user.role ? user.role : '';
}
if (userFormDisplayName) userFormDisplayName.value = (user && user.displayName && user.displayName !== '—') ? user.displayName : '';
if (userModalOverlay) { userModalOverlay.classList.add('visible'); userModalOverlay.setAttribute('aria-hidden', 'false'); }
if (userModal) userModal.classList.add('visible');
}
function closeUserModal() {
if (userModalOverlay) { userModalOverlay.classList.remove('visible'); userModalOverlay.setAttribute('aria-hidden', 'true'); }
if (userModal) userModal.classList.remove('visible');
}
function bindTabs() {
document.querySelectorAll('.tab').forEach(function (tab) {
tab.addEventListener('click', function () {
@@ -315,8 +1048,19 @@
if (btnSearch) btnSearch.addEventListener('click', function () { currentPage = 1; loadList(); });
if (searchKeyword) searchKeyword.addEventListener('keydown', function (e) { if (e.key === 'Enter') { currentPage = 1; loadList(); } });
if (filterProgress) filterProgress.addEventListener('change', function () { currentPage = 1; loadList(); });
if (filterCost) filterCost.addEventListener('change', function () { currentPage = 1; loadList(); });
if (btnExportXls) btnExportXls.addEventListener('click', exportToXls);
if (btnStats) btnStats.addEventListener('click', showStatistics);
if (btnGlobalStats) btnGlobalStats.addEventListener('click', showGlobalStats);
if (btnBackFromStats) btnBackFromStats.addEventListener('click', function () { showList(); loadList(); });
if (btnBackFromGlobalStats) btnBackFromGlobalStats.addEventListener('click', function () { showList(); loadList(); });
if (globalStatsTabs) {
globalStatsTabs.querySelectorAll('.global-stats-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
var type = tab.getAttribute('data-type');
if (type) loadGlobalStatsTab(type);
});
});
}
if (dateFilterType) dateFilterType.addEventListener('change', function () { currentPage = 1; loadList(); });
if (dateFrom) dateFrom.addEventListener('change', function () { currentPage = 1; loadList(); });
if (dateTo) dateTo.addEventListener('change', function () { currentPage = 1; loadList(); });
@@ -328,6 +1072,38 @@
if (btnNewProject) btnNewProject.addEventListener('click', function () { showDetail(null, 'edit'); });
if (btnBack) btnBack.addEventListener('click', function () { showList(); loadList(); });
if (navList) navList.addEventListener('click', function (e) { e.preventDefault(); showList(); });
if (btnUserManage) btnUserManage.addEventListener('click', function (e) { e.preventDefault(); showUsers(); });
if (btnBackFromUsers) btnBackFromUsers.addEventListener('click', function () { showList(); loadList(); });
if (btnAddUser) btnAddUser.addEventListener('click', function () { openUserModal(false); });
if (userModalClose) userModalClose.addEventListener('click', closeUserModal);
if (userModalCancel) userModalCancel.addEventListener('click', closeUserModal);
if (userModalOverlay) userModalOverlay.addEventListener('click', closeUserModal);
if (userForm) {
userForm.addEventListener('submit', function (e) {
e.preventDefault();
var id = userFormId && userFormId.value ? userFormId.value.trim() : '';
var username = userFormUsername && userFormUsername.value ? userFormUsername.value.trim() : '';
var password = userFormPassword && userFormPassword.value ? userFormPassword.value : '';
var role = userFormRole && userFormRole.value ? userFormRole.value : '';
var displayName = userFormDisplayName && userFormDisplayName.value ? userFormDisplayName.value.trim() : '';
if (!username) { showToast('请输入账号', 'error'); return; }
if (!id && !password) { showToast('请输入密码', 'error'); return; }
if (!role) { showToast('请选择角色', 'error'); return; }
if (id) {
var body = { id: id, role: role, displayName: displayName || undefined };
if (password) body.password = password;
window.API.post('/api/users/update', body).then(function (res) {
if (res.ok) { showToast('保存成功', 'success'); closeUserModal(); loadUsers(); }
else showToast(res.message || '保存失败', 'error');
});
} else {
window.API.post('/api/users/create', { username: username, password: password, role: role, displayName: displayName || undefined }).then(function (res) {
if (res.ok) { showToast('创建成功', 'success'); closeUserModal(); loadUsers(); }
else showToast(res.message || '创建失败', 'error');
});
}
});
}
if (btnSave) {
btnSave.addEventListener('click', function () {