添加 agentation 成功

This commit is contained in:
Your Name
2026-01-31 23:16:21 +08:00
parent c9e75030af
commit 52f540a097
44 changed files with 8034 additions and 4 deletions
+36
View File
@@ -0,0 +1,36 @@
# 项目管理系统 · 前端
依据《产品文档》《API 文档》《交互文档》开发,对接后端 `http://127.0.0.1:8000`
## 目录结构
- `index.html` - 单页:登录、项目列表、项目详情/编辑、操作日志(抽屉)
- `css/style.css` - 样式(参考 ui 目录风格)
- `js/api.js` - API 封装(baseURL、Token、POST JSON、401 处理)
- `js/app.js` - 业务逻辑(登录、列表搜索/筛选/分页、详情加载/保存、新建、操作日志)
- **Agentation**[agentation](https://github.com/benjitaylor/agentation)):可视化反馈工具栏,供 AI 编码代理定位页面元素。需先构建后使用。
## 本地运行
1. 确保后端已启动:`http://127.0.0.1:8000`
2. **若使用 Agentation**:需 **Node 18+**。在 `frontend` 目录下执行
`npm install``npm run build`
会生成 `dist/agentation.js`,页面加载后右下角出现 Agentation 工具栏。若未构建,工具栏不显示,不影响主应用使用。
3. 任选一种方式提供静态资源:
- **推荐(不依赖 Node 版本)**:在 `frontend` 目录下执行
`python3 -m http.server 3000`
然后访问 http://127.0.0.1:3000
- 或直接用浏览器打开 `index.html`(若后端未配置 CORS,跨域请求可能被拦)
- 或使用 Node 14+ 时:`npx serve .`
4. 登录后使用;仅**市场部**角色显示「新建项目」入口
## 功能说明
- **登录**POST `/api/auth/login`,成功后 Token 存 localStorage,后续请求带 `Authorization: Bearer <token>`
- **项目列表**POST `/api/projects/list`,支持按名称/编号搜索、进度/费用筛选、日期筛选、日期异常筛选、分页
- **项目详情**POST `/api/projects/detail`,5 个 Tab(合同信息、成本控制、应收款、应付款、其他)
- **新建项目**POST `/api/projects/create`(仅市场部)
- **保存**POST `/api/projects/update`,后端记操作日志
- **操作日志**POST `/api/projects/logs`,抽屉展示
未登录或 Token 失效(401)时自动退回登录页。
+17
View File
@@ -0,0 +1,17 @@
/**
* Agentation 挂载入口(https://github.com/benjitaylor/agentation
* 在页面底部渲染可视化反馈工具栏,供 AI 编码代理定位元素。
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Agentation } from 'agentation';
const rootEl = document.getElementById('agentation-root');
if (rootEl) {
const root = createRoot(rootEl);
root.render(
<React.StrictMode>
<Agentation />
</React.StrictMode>
);
}
+963
View File
@@ -0,0 +1,963 @@
/* ========== 深色音乐流媒体风格 · 项目管理系统(专辑色 + 鲜明点缀) ========== */
@font-face {
font-family: 'Alibaba PuHuiTi';
font-weight: 300 400;
font-style: normal;
font-display: swap;
src: url('https://at.alicdn.com/t/webfont_jq0x47mjt6a.eot');
src: url('https://at.alicdn.com/t/webfont_jq0x47mjt6a.eot?#iefix') format('embedded-opentype'),
url('https://at.alicdn.com/t/webfont_jq0x47mjt6a.woff2') format('woff2'),
url('https://at.alicdn.com/t/webfont_jq0x47mjt6a.woff') format('woff'),
url('https://at.alicdn.com/t/webfont_jq0x47mjt6a.ttf') format('truetype');
}
:root {
/* 深色背景 · 专辑感 */
--bg-deep: #0d0d0f;
--bg-base: #12121a;
--bg-elevated: #1a1a24;
--bg-panel: #252530;
--bg-hover: #2d2d3a;
/* 鲜明点缀 · 专辑色 */
--accent-violet: #8b5cf6;
--accent-violet-light: #a78bfa;
--accent-cyan: #22d3ee;
--accent-magenta: #ec4899;
--accent-gold: #f59e0b;
--accent-teal: #14b8a6;
/* 渐变(专辑封面感) */
--gradient-hero: linear-gradient(135deg, #8b5cf6 0%, #ec4899 50%, #f59e0b 100%);
--gradient-cta: linear-gradient(135deg, #a78bfa 0%, #ec4899 100%);
--gradient-panel: linear-gradient(180deg, rgba(37, 37, 48, 0.95) 0%, rgba(26, 26, 36, 0.98) 100%);
/* 文字 */
--text: #f1f5f9;
--text-muted: #94a3b8;
--text-dim: #64748b;
/* 边框与阴影 */
--border: rgba(148, 163, 184, 0.12);
--border-accent: rgba(139, 92, 246, 0.35);
--shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 24px rgba(139, 92, 246, 0.25);
--focus-ring: 0 0 0 2px rgba(139, 92, 246, 0.5);
/* 通用 */
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--radius: 10px;
--radius-sm: 6px;
--space-16: 16px;
--space-24: 24px;
--space-32: 32px;
--font-en: "IBM Plex Sans", sans-serif;
--font-cn: "Alibaba PuHuiTi", "PingFang SC", "Noto Sans SC", sans-serif;
--font-head: var(--font-en), var(--font-cn);
--font-body: var(--font-cn), var(--font-en);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
font-family: var(--font-body);
font-weight: 400;
color: var(--text);
background: var(--bg-base);
line-height: 1.5;
}
/* ---------- 登录页 · 类似 Premium CTA 卡片 ---------- */
.view-login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-32);
background: var(--bg-deep);
background-image: radial-gradient(ellipse 80% 50% at 50% 0%, rgba(139, 92, 246, 0.15) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 80% 60%, rgba(236, 72, 153, 0.08) 0%, transparent 50%);
}
.view-login.hidden {
display: none;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--gradient-panel);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-accent);
border-radius: var(--radius);
box-shadow: var(--shadow-soft), var(--shadow-glow);
padding: var(--space-32) var(--space-24);
}
.login-brand {
text-align: center;
margin-bottom: var(--space-24);
}
.login-brand .brand-icon {
font-size: 1.75rem;
background: var(--gradient-hero);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: block;
margin-bottom: var(--space-16);
font-weight: 300;
}
.login-title {
font-family: var(--font-head);
font-weight: 400;
font-size: 1.35rem;
color: var(--text);
margin: 0 0 0.35rem 0;
}
.login-desc {
font-size: 0.875rem;
font-weight: 300;
color: var(--text-muted);
margin: 0;
}
.login-form .form-group {
margin-bottom: var(--space-16);
}
.login-form .form-group label {
display: block;
font-size: 0.8125rem;
font-weight: 400;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.login-form .input {
width: 100%;
padding: 0.6rem 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.login-form .input:focus {
outline: none;
border-color: var(--accent-violet);
box-shadow: var(--focus-ring);
}
.login-form .input::placeholder {
color: var(--text-dim);
}
.login-error {
font-size: 0.8125rem;
color: #fca5a5;
margin-bottom: var(--space-16);
padding: 0.5rem 0.75rem;
background: rgba(236, 72, 153, 0.1);
border: 1px solid rgba(236, 72, 153, 0.3);
border-radius: var(--radius-sm);
}
.btn-login-submit {
width: 100%;
padding: 0.65rem 1rem;
margin-top: var(--space-16);
font-weight: 400;
background: var(--gradient-cta);
border: none;
color: #fff;
border-radius: var(--radius-sm);
transition: opacity 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.btn-login-submit:hover {
opacity: 0.95;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
/* ---------- 主应用 · 侧栏(类似播放器/歌单区) ---------- */
.app {
display: flex;
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 {
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);
}
.brand-icon {
font-size: 1.25rem;
color: var(--accent-violet-light);
font-weight: 300;
}
.brand-text {
font-family: var(--font-head);
font-weight: 400;
font-size: 0.95rem;
letter-spacing: 0.02em;
color: var(--text);
}
.sidebar-nav {
flex: 1;
}
.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);
}
.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;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.875rem;
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 {
color: var(--text);
border-color: var(--accent-violet);
background: var(--bg-hover);
}
/* ---------- 主内容区 ---------- */
.main {
flex: 1;
overflow: auto;
padding: var(--space-24) var(--space-32) var(--space-32);
background: var(--bg-base);
}
.view {
display: none;
}
.view.active {
display: block;
}
.page-header {
display: flex;
align-items: center;
gap: var(--space-16);
margin-bottom: var(--space-24);
flex-wrap: wrap;
}
.page-title {
font-family: var(--font-head);
font-weight: 400;
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: var(--space-16);
margin-left: auto;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.75rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 400;
cursor: pointer;
transition: color 0.2s var(--ease), border-color 0.2s var(--ease), background 0.2s var(--ease);
}
.btn-back:hover {
color: var(--accent-violet-light);
border-color: var(--accent-violet);
background: var(--bg-hover);
}
.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: 400;
font-family: inherit;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.2s var(--ease), color 0.2s var(--ease), box-shadow 0.2s var(--ease), border-color 0.2s var(--ease);
}
.btn-primary {
background: var(--gradient-cta);
border-color: transparent;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
}
.btn-ghost:hover {
color: var(--accent-violet-light);
border-color: var(--accent-violet);
box-shadow: var(--focus-ring);
}
.btn-icon {
font-size: 1rem;
line-height: 1;
font-weight: 300;
color: var(--text-muted);
}
.btn-sm {
padding: 0.35rem 0.75rem;
font-size: 0.8125rem;
}
.btn-new-project .btn-icon {
font-size: 1.1rem;
font-weight: 400;
color: inherit;
}
/* ---------- 工具栏(类似播放控制区) ---------- */
.toolbar {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
padding: var(--space-24);
margin-bottom: var(--space-24);
}
.search-row {
display: flex;
gap: var(--space-16);
margin-bottom: var(--space-16);
}
.search-row .input-select {
width: 120px;
flex-shrink: 0;
}
.search-row .input-search {
flex: 1;
min-width: 160px;
}
.btn-search {
padding: 0.5rem var(--space-24);
background: var(--accent-violet);
border: none;
color: #fff;
font-weight: 400;
border-radius: var(--radius-sm);
transition: background 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.btn-search:hover {
background: var(--accent-violet-light);
box-shadow: var(--shadow-glow);
}
.filter-row {
display: flex;
gap: var(--space-24);
flex-wrap: wrap;
align-items: flex-end;
}
.filter-group {
display: flex;
align-items: center;
gap: var(--space-16);
}
.filter-group label {
font-size: 0.8125rem;
font-weight: 300;
color: var(--text-muted);
white-space: nowrap;
}
.filter-check label {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
font-weight: 300;
}
.input-filter {
min-width: 120px;
}
.input-date {
min-width: 130px;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 400;
font-family: inherit;
color: var(--text);
background: var(--bg-panel);
transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.input::placeholder {
color: var(--text-dim);
}
.input:focus {
outline: none;
border-color: var(--accent-violet);
box-shadow: var(--focus-ring);
}
.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='%2394a3b8' fill-opacity='0.9' 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;
}
.form-group em {
color: var(--accent-magenta);
font-style: normal;
}
.form-group label {
font-weight: 400;
}
/* ---------- 表格 · 歌单列表感 ---------- */
.table-wrap {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
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: var(--space-16);
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 300;
background: var(--bg-elevated);
}
.table-empty .empty-icon {
font-size: 2rem;
opacity: 0.5;
color: var(--text-dim);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
font-weight: 400;
}
.table thead {
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.table th {
text-align: left;
padding: var(--space-16) 1rem;
font-weight: 400;
color: var(--text-muted);
}
.table td {
padding: var(--space-16) 1rem;
border-bottom: 1px solid var(--border);
color: var(--text);
}
.table tbody tr {
transition: background 0.2s var(--ease);
}
.table tbody tr:hover {
background: var(--bg-hover);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table .cell-actions {
display: flex;
gap: var(--space-16);
}
.table .link-action {
color: var(--accent-cyan);
cursor: pointer;
background: none;
border: none;
font-size: 0.875rem;
font-weight: 400;
padding: 0;
text-decoration: none;
transition: color 0.2s var(--ease);
}
.table .link-action:hover {
color: var(--accent-violet-light);
text-decoration: underline;
}
/* ---------- 分页 ---------- */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-24);
padding: 0 var(--space-16);
}
.pagination-info {
font-size: 0.8125rem;
font-weight: 300;
color: var(--text-muted);
}
.pagination-btns {
display: flex;
gap: var(--space-16);
}
.pagination .btn-sm {
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-muted);
}
.pagination .btn-sm:hover:not(:disabled) {
color: var(--accent-violet-light);
border-color: var(--accent-violet);
box-shadow: var(--focus-ring);
}
.pagination .btn-sm:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- 详情 · 艺术家/专辑信息区感 ---------- */
.detail-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 300;
}
.detail-content {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.tabs {
display: flex;
gap: 0.25rem;
padding: 0 var(--space-24);
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tab {
padding: var(--space-16) 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 0.875rem;
font-weight: 400;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--accent-violet-light);
border-bottom-color: var(--accent-violet);
}
.tab-panels {
padding: var(--space-24) var(--space-24) var(--space-32);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-24) var(--space-24);
}
.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: 400;
color: var(--text-muted);
}
/* ---------- 抽屉(操作日志) ---------- */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s var(--ease), visibility 0.25s var(--ease);
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(--bg-elevated);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-soft);
transform: translateX(100%);
transition: transform 0.25s var(--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: var(--space-24) var(--space-24);
border-bottom: 1px solid var(--border);
}
.drawer-title {
font-family: var(--font-head);
font-weight: 400;
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: color 0.2s var(--ease), background 0.2s var(--ease);
}
.drawer-close:hover {
color: var(--text);
background: var(--bg-hover);
}
.drawer-body {
flex: 1;
overflow: auto;
padding: var(--space-24);
position: relative;
}
.drawer-loading,
.drawer-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-16);
min-height: 160px;
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 300;
}
.drawer-empty .empty-icon {
font-size: 2rem;
opacity: 0.5;
color: var(--text-dim);
}
.log-list {
list-style: none;
margin: 0;
padding: 0;
}
.log-item {
padding: var(--space-16) 0;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
font-weight: 400;
}
.log-item:last-child {
border-bottom: none;
}
.log-item .log-meta {
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 300;
margin-bottom: 0.25rem;
}
.log-item .log-desc {
color: var(--text);
}
/* ---------- ToastPremium 提示感) ---------- */
.toast {
position: fixed;
bottom: var(--space-32);
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 0.65rem var(--space-24);
background: var(--bg-panel);
border: 1px solid var(--border-accent);
color: var(--text);
font-size: 0.875rem;
font-weight: 400;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-soft), var(--shadow-glow);
opacity: 0;
visibility: hidden;
transition: transform 0.25s var(--ease), opacity 0.25s var(--ease), visibility 0.25s var(--ease);
z-index: 1000;
}
.toast.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
visibility: visible;
}
.toast.success {
border-color: var(--accent-teal);
box-shadow: var(--shadow-soft), 0 0 20px rgba(20, 184, 166, 0.25);
}
.toast.error {
border-color: var(--accent-magenta);
box-shadow: var(--shadow-soft), 0 0 20px rgba(236, 72, 153, 0.2);
}
@media (max-width: 900px) {
.form-grid {
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%;
}
}
+277
View File
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<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=IBM+Plex+Sans:wght@300;400&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<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="loginUsername">账号</label>
<input type="text" id="loginUsername" class="input" name="username" 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>
<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">
<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="searchKeyword" 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 class="filter-group">
<label>日期筛选</label>
<select id="dateFilterType" class="input input-filter">
<option value="">不按日期</option>
<option value="signDate">签订日期</option>
<option value="startDate">开工日期</option>
<option value="plannedCompletionDate">计划竣工日期</option>
<option value="actualCompletionDate">实际竣工日期</option>
</select>
</div>
<div class="filter-group">
<label></label>
<input type="date" id="dateFrom" class="input input-date" />
</div>
<div class="filter-group">
<label></label>
<input type="date" id="dateTo" class="input input-date" />
</div>
<div class="filter-group filter-check">
<label><input type="checkbox" id="dateAbnormal" /> 仅日期异常</label>
</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" id="paginationInfo">共 0 条</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>
</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">
<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="costControl">成本控制</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" data-section="contract">
<div class="form-group"><label>序号</label><input type="text" class="input" data-field="serialNo" /></div>
<div class="form-group"><label>合同编号 <em>*</em></label><input type="text" class="input" data-field="contractCode" required /></div>
<div class="form-group"><label>供电局项目合同编号</label><input type="text" class="input" data-field="powerBureauContractCode" /></div>
<div class="form-group"><label>项目名称 <em>*</em></label><input type="text" class="input" data-field="projectName" required /></div>
<div class="form-group"><label>子项个数</label><input type="number" class="input" data-field="subItemCount" /></div>
<div class="form-group"><label>子项编码</label><input type="text" class="input" data-field="subItemCode" /></div>
<div class="form-group"><label>项目总投资(万元)</label><input type="number" class="input" data-field="totalInvestment" /></div>
<div class="form-group"><label>中标合同金额(万元)</label><input type="text" class="input" data-field="bidContractAmount" /></div>
<div class="form-group"><label>质保金比例</label><input type="text" class="input" data-field="warrantyRatio" /></div>
<div class="form-group"><label>结算金额(万元)</label><input type="number" class="input" data-field="settlementAmount" /></div>
<div class="form-group"><label>总成本测算</label><input type="number" class="input" data-field="totalCostEstimate" /></div>
<div class="form-group"><label>工程电压等级</label><input type="text" class="input" data-field="voltageLevel" /></div>
<div class="form-group"><label>工程类别</label><input type="text" class="input" data-field="projectCategory" /></div>
<div class="form-group"><label>业主单位</label><input type="text" class="input" data-field="ownerUnit" /></div>
<div class="form-group"><label>业主联系人及电话</label><input type="text" class="input" data-field="ownerContact" /></div>
<div class="form-group"><label>中标形式</label><input type="text" class="input" data-field="bidType" /></div>
<div class="form-group"><label>签订日期</label><input type="text" class="input input-date-or-text" data-field="signDate" placeholder="日期或文字" /></div>
<div class="form-group"><label>开工日期</label><input type="text" class="input input-date-or-text" data-field="startDate" placeholder="日期或文字" /></div>
<div class="form-group"><label>计划竣工日期</label><input type="text" class="input input-date-or-text" data-field="plannedCompletionDate" placeholder="日期或文字" /></div>
<div class="form-group"><label>实际竣工日期</label><input type="text" class="input input-date-or-text" data-field="actualCompletionDate" placeholder="日期或文字" /></div>
<div class="form-group"><label>质保金(万元)</label><input type="number" class="input" data-field="warrantyAmount" /></div>
<div class="form-group"><label>质保期截止日</label><input type="text" class="input" data-field="warrantyEndDate" /></div>
<div class="form-group"><label>实际退质保金日期</label><input type="text" class="input" data-field="actualWarrantyRefundDate" /></div>
<div class="form-group"><label>所属项目部</label><input type="text" class="input" data-field="projectDepartment" /></div>
<div class="form-group"><label>项目负责人及电话</label><input type="text" class="input" data-field="projectLeaderContact" /></div>
<div class="form-group form-group-full"><label>工程款拨付方式</label><input type="text" class="input" data-field="paymentMethod" /></div>
</div>
</div>
<div class="tab-panel" id="panel-costControl">
<div class="form-grid" data-section="costControl">
<div class="form-group"><label>总体成本</label><input type="number" class="input" data-field="totalCost" /></div>
<div class="form-group"><label>是否调整</label><select class="input" data-field="isAdjusted"><option value="">请选择</option><option value="是"></option><option value="否"></option></select></div>
<div class="form-group"><label>农民工工资(按进度计划)</label><input type="text" class="input" data-field="migrantWorkerPlan" /></div>
<div class="form-group"><label>农民工工资(实付)</label><input type="number" class="input" data-field="migrantWorkerActual" /></div>
<div class="form-group"><label>乙供材料费(控制)</label><input type="number" class="input" data-field="selfSupplyMaterialControl" /></div>
<div class="form-group"><label>应付材料费(按收款比例)</label><input type="number" class="input" data-field="materialPayableByRatio" /></div>
<div class="form-group"><label>实际发生材料费</label><input type="number" class="input" data-field="materialActualOccurred" /></div>
<div class="form-group"><label>实际支付材料费</label><input type="number" class="input" data-field="materialActualPaid" /></div>
<div class="form-group"><label>其他费用(控制)</label><input type="number" class="input" data-field="otherCostControl" /></div>
<div class="form-group"><label>应付其他费</label><input type="number" class="input" data-field="otherPayable" /></div>
<div class="form-group"><label>实际其他费用</label><input type="number" class="input" data-field="otherActual" /></div>
<div class="form-group"><label>税金</label><input type="number" class="input" data-field="tax" /></div>
<div class="form-group"><label>利润(万元)</label><input type="number" class="input" data-field="profit" /></div>
<div class="form-group"><label>实际利润(万元)</label><input type="number" class="input" data-field="actualProfit" /></div>
<div class="form-group"><label>成本结算金额(万元)</label><input type="number" class="input" data-field="costSettlementAmount" /></div>
</div>
</div>
<div class="tab-panel" id="panel-receivable">
<div class="form-grid" data-section="receivable">
<div class="form-group"><label>应收款(完成进度款)</label><input type="number" class="input" data-field="receivableProgress" /></div>
<div class="form-group"><label>开票金额(万元)</label><input type="number" class="input" data-field="invoiceAmount" /></div>
<div class="form-group"><label>实际收款金额(万元)</label><input type="number" class="input" data-field="actualReceiptAmount" /></div>
<div class="form-group"><label>实际收款完成率</label><input type="text" class="input" data-field="actualReceiptRate" placeholder="%" /></div>
</div>
</div>
<div class="tab-panel" id="panel-payable">
<div class="form-grid" data-section="payable">
<div class="form-group"><label>应付款金额(万元)</label><input type="number" class="input" data-field="payableAmount" /></div>
<div class="form-group"><label>实际付款金额(万元)</label><input type="number" class="input" data-field="actualPaymentAmount" /></div>
<div class="form-group"><label>未收款(万元)</label><input type="number" class="input" data-field="unreceivedAmount" /></div>
<div class="form-group"><label>实际付款完成率</label><input type="text" class="input" data-field="actualPaymentRate" placeholder="%" /></div>
<div class="form-group"><label>民工工资清欠金额(万元)</label><input type="number" class="input" data-field="migrantWorkerArrears" /></div>
</div>
</div>
<div class="tab-panel" id="panel-other">
<div class="form-grid" data-section="other">
<div class="form-group"><label>结算后成本测算金额</label><input type="number" class="input" data-field="settlementCostEstimate" /></div>
<div class="form-group"><label>结算人工费</label><input type="number" class="input" data-field="settlementLaborCost" /></div>
<div class="form-group"><label>结算材料费</label><input type="number" class="input" data-field="settlementMaterialCost" /></div>
<div class="form-group"><label>结算其他费</label><input type="number" class="input" data-field="settlementOtherCost" /></div>
<div class="form-group"><label>到期应结算项目个数</label><input type="number" class="input" data-field="dueSettlementCount" /></div>
<div class="form-group"><label>到期未完成结算个数</label><input type="number" class="input" data-field="overdueSettlementCount" /></div>
<div class="form-group form-group-full"><label>存在的问题</label><textarea class="input input-area" data-field="existingProblems" rows="2"></textarea></div>
<div class="form-group form-group-full"><label>建议措施</label><textarea class="input input-area" data-field="suggestions" rows="2"></textarea></div>
<div class="form-group"><label>累计进度</label><input type="text" class="input" data-field="cumulativeProgress" placeholder="%" /></div>
<div class="form-group form-group-full"><label>备注</label><textarea class="input input-area" data-field="remark" rows="2"></textarea></div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<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>
<div id="toast" class="toast" role="alert" aria-live="polite"></div>
<!-- Agentation 可视化反馈工具栏(https://github.com/benjitaylor/agentation -->
<div id="agentation-root"></div>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<script type="module" src="dist/agentation.js"></script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
/**
* API 封装:baseURL、Token、POST JSON、统一错误与 401 处理
* 依据:backend/API.md,全部 POSTJSONBearer Token
*/
(function (global) {
'use strict';
var BASE_URL = 'http://127.0.0.1:8000';
var TOKEN_KEY = 'pm_token';
var USER_KEY = 'pm_user';
function getToken() {
try {
return localStorage.getItem(TOKEN_KEY) || '';
} catch (e) {
return '';
}
}
function setToken(token) {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch (e) {}
}
function getUser() {
try {
var raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
function setUser(user) {
try {
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
else localStorage.removeItem(USER_KEY);
} catch (e) {}
}
function clearAuth() {
setToken('');
setUser(null);
}
/**
* POST /api/xxx 请求
* @param {string} path - 如 '/api/auth/login'
* @param {object} body - 请求体
* @param {boolean} [requireAuth=true] - 是否携带 Token
* @returns {Promise<{ ok: boolean, data?: object, status?: number, message?: string }>}
*/
function post(path, body, requireAuth) {
if (requireAuth === undefined) requireAuth = true;
var url = BASE_URL + path;
var headers = {
'Content-Type': 'application/json',
};
var token = getToken();
if (requireAuth && token) {
headers['Authorization'] = 'Bearer ' + token;
}
return fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(body || {}),
})
.then(function (res) {
return res.text().then(function (text) {
var data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
data = null;
}
if (res.status === 401) {
clearAuth();
if (typeof global.onAuthRequired === 'function') {
global.onAuthRequired();
}
return { ok: false, status: 401, message: data && data.message ? data.message : '未登录' };
}
if (!res.ok) {
var msg = (data && data.message) || res.statusText || '请求失败';
return { ok: false, status: res.status, data: data, message: msg };
}
return { ok: true, data: data };
});
})
.catch(function (err) {
return { ok: false, message: err && err.message ? err.message : '网络错误' };
});
}
global.API = {
BASE_URL: BASE_URL,
getToken: getToken,
setToken: setToken,
getUser: getUser,
setUser: setUser,
clearAuth: clearAuth,
post: post,
};
})(typeof window !== 'undefined' ? window : this);
+407
View File
@@ -0,0 +1,407 @@
/**
* 项目管理系统前端(依据产品文档、API 文档、交互文档)
* 对接 http://127.0.0.1:8000,登录 / 列表 / 详情 / 新建 / 保存 / 操作日志
*/
(function () {
'use strict';
var PAGE_SIZE = 20;
var currentPage = 1;
var totalCount = 0;
var currentProjectId = null;
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.getElementById('btnLogout');
var userInfo = document.getElementById('userInfo');
var btnNewProject = document.getElementById('btnNewProject');
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');
var dateAbnormal = document.getElementById('dateAbnormal');
var listLoading = document.getElementById('listLoading');
var listEmpty = document.getElementById('listEmpty');
var projectTable = document.getElementById('projectTable');
var projectTableBody = document.getElementById('projectTableBody');
var paginationInfo = document.getElementById('paginationInfo');
var btnPrev = document.getElementById('btnPrev');
var btnNext = document.getElementById('btnNext');
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');
function showLogin() {
if (viewLogin) viewLogin.classList.remove('hidden');
if (appMain) appMain.style.display = 'none';
}
function showApp() {
if (viewLogin) viewLogin.classList.add('hidden');
if (appMain) appMain.style.display = 'flex';
var u = window.API && window.API.getUser ? window.API.getUser() : null;
if (userInfo && u) userInfo.textContent = (u.displayName || u.username || '') + ' · ' + (u.role || '');
updateNewProjectVisibility();
loadList();
}
window.onAuthRequired = function () {
showLogin();
};
function isMarketRole() {
var u = window.API && window.API.getUser ? window.API.getUser() : null;
return u && u.role === '市场部';
}
function updateNewProjectVisibility() {
if (btnNewProject) btnNewProject.style.display = isMarketRole() ? '' : 'none';
}
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);
}
function showList() {
if (viewList) viewList.classList.add('active');
if (viewDetail) viewDetail.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');
}
function showDetail(projectId) {
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) {
window.API.post('/api/projects/detail', { id: projectId }).then(function (res) {
if (detailLoading) detailLoading.style.display = 'none';
if (detailContent) detailContent.style.display = 'block';
if (res.ok && res.data) {
fillDetailForm(res.data);
} else {
showToast(res.message || '加载失败', 'error');
}
});
} else {
if (detailLoading) detailLoading.style.display = 'none';
if (detailContent) detailContent.style.display = 'block';
clearDetailForm();
}
}
function getFormDataBySection(sectionName) {
var panel = document.querySelector('.tab-panel[id="panel-' + sectionName + '"]');
if (!panel) return {};
var grid = panel.querySelector('[data-section="' + sectionName + '"]');
if (!grid) return {};
var obj = {};
grid.querySelectorAll('[data-field]').forEach(function (el) {
var key = el.getAttribute('data-field');
if (!key) return;
var val = el.value;
if (el.type === 'number' && val !== '') val = Number(val);
if (val !== '' && val !== undefined) obj[key] = val;
});
return obj;
}
function fillDetailForm(project) {
function setSection(sectionName, data) {
if (!data) return;
var panel = document.querySelector('.tab-panel[id="panel-' + sectionName + '"]');
if (!panel) return;
var grid = panel.querySelector('[data-section="' + sectionName + '"]');
if (!grid) return;
grid.querySelectorAll('[data-field]').forEach(function (el) {
var key = el.getAttribute('data-field');
if (!key || data[key] === undefined) return;
var v = data[key];
if (v !== null && v !== undefined) el.value = v;
else el.value = '';
});
}
setSection('contract', project.contract);
setSection('costControl', project.costControl);
setSection('receivable', project.receivable);
setSection('payable', project.payable);
setSection('other', project.other);
}
function clearDetailForm() {
['contract', 'costControl', 'receivable', 'payable', 'other'].forEach(function (sectionName) {
var panel = document.querySelector('.tab-panel[id="panel-' + sectionName + '"]');
if (!panel) return;
var grid = panel.querySelector('[data-section="' + sectionName + '"]');
if (!grid) return;
grid.querySelectorAll('input, select, textarea').forEach(function (el) {
if (el.type === 'checkbox' || el.type === 'radio') el.checked = false;
else el.value = '';
});
});
}
function collectPayload() {
var contract = getFormDataBySection('contract');
var costControl = getFormDataBySection('costControl');
var receivable = getFormDataBySection('receivable');
var payable = getFormDataBySection('payable');
var other = getFormDataBySection('other');
return { contract: contract, costControl: costControl, receivable: receivable, payable: payable, other: other };
}
function loadList() {
if (listLoading) listLoading.style.display = 'block';
if (listEmpty) listEmpty.style.display = 'none';
if (projectTable) projectTable.style.visibility = 'hidden';
var body = {
page: currentPage,
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,
dateAbnormal: dateAbnormal && dateAbnormal.checked ? true : undefined,
};
if (!body.keyword) body.searchType = undefined;
if (!body.dateFilterType) body.dateFrom = body.dateTo = undefined;
window.API.post('/api/projects/list', body).then(function (res) {
if (listLoading) listLoading.style.display = 'none';
if (!res.ok) {
showToast(res.message || '加载失败', 'error');
if (listEmpty) listEmpty.style.display = 'flex';
if (listEmpty) listEmpty.querySelector('p').textContent = res.message || '加载失败';
return;
}
var list = (res.data && res.data.list) || [];
totalCount = (res.data && res.data.total) || 0;
if (list.length === 0) {
if (listEmpty) listEmpty.style.display = 'flex';
if (listEmpty) listEmpty.querySelector('p').textContent = '暂无项目数据';
if (projectTable) projectTable.style.visibility = 'hidden';
} else {
if (listEmpty) listEmpty.style.display = 'none';
if (projectTable) projectTable.style.visibility = 'visible';
var html = list
.map(function (p) {
var updatedAt = p.updatedAt ? p.updatedAt.slice(0, 10) : '—';
return (
'<tr>' +
'<td>' + (p.projectName || '—') + '</td>' +
'<td>' + (p.contractCode || '—') + '</td>' +
'<td>' + (p.progress || '—') + '</td>' +
'<td>' + (p.cost || '—') + '</td>' +
'<td>' + updatedAt + '</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);
});
});
}
if (paginationInfo) paginationInfo.textContent = '共 ' + totalCount + ' 条';
if (btnPrev) btnPrev.disabled = currentPage <= 1;
if (btnNext) btnNext.disabled = currentPage * PAGE_SIZE >= totalCount;
});
}
function bindTabs() {
document.querySelectorAll('.tab').forEach(function (tab) {
tab.addEventListener('click', function () {
var t = tab.getAttribute('data-tab');
document.querySelectorAll('.tab').forEach(function (x) { x.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function (p) {
if (p.id === 'panel-' + t) p.classList.add('active');
else p.classList.remove('active');
});
tab.classList.add('active');
});
});
}
if (loginForm) {
loginForm.addEventListener('submit', function (e) {
e.preventDefault();
var username = document.getElementById('loginUsername');
var password = document.getElementById('loginPassword');
if (!username || !password) return;
if (loginError) loginError.style.display = 'none';
if (btnLoginSubmit) btnLoginSubmit.disabled = true;
window.API.post('/api/auth/login', { username: username.value.trim(), password: password.value }, false).then(function (res) {
if (btnLoginSubmit) btnLoginSubmit.disabled = false;
if (res.ok && res.data) {
window.API.setToken(res.data.token || '');
window.API.setUser(res.data.user || null);
showApp();
} else {
if (loginError) {
loginError.textContent = res.message || '账号或密码错误';
loginError.style.display = 'block';
}
}
});
});
}
if (btnLogout) {
btnLogout.addEventListener('click', function () {
window.API.clearAuth();
showLogin();
});
}
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 (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(); });
if (dateAbnormal) dateAbnormal.addEventListener('change', function () { currentPage = 1; loadList(); });
if (btnPrev) btnPrev.addEventListener('click', function () { if (currentPage > 1) { currentPage--; loadList(); } });
if (btnNext) btnNext.addEventListener('click', function () { if (currentPage * PAGE_SIZE < totalCount) { currentPage++; loadList(); } });
if (btnNewProject) btnNewProject.addEventListener('click', function () { showDetail(null); });
if (btnBack) btnBack.addEventListener('click', function () { showList(); loadList(); });
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;
var payload = collectPayload();
if (!payload.contract.contractCode || !payload.contract.projectName) {
showToast('请填写合同编号和项目名称', 'error');
return;
}
if (textSpan) textSpan.style.display = 'none';
if (loadingSpan) loadingSpan.style.display = 'inline';
btnSave.disabled = true;
if (currentProjectId) {
window.API.post('/api/projects/update', { id: currentProjectId, contract: payload.contract, costControl: payload.costControl, receivable: payload.receivable, payable: payload.payable, other: payload.other }).then(function (res) {
if (textSpan) textSpan.style.display = 'inline';
if (loadingSpan) loadingSpan.style.display = 'none';
btnSave.disabled = false;
if (res.ok) {
showToast('保存成功', 'success');
} else {
showToast(res.message || '保存失败', 'error');
}
});
} else {
window.API.post('/api/projects/create', { contract: payload.contract, costControl: payload.costControl, receivable: payload.receivable, payable: payload.payable, other: payload.other }).then(function (res) {
if (textSpan) textSpan.style.display = 'inline';
if (loadingSpan) loadingSpan.style.display = 'none';
btnSave.disabled = false;
if (res.ok && res.data && res.data.id) {
showToast('创建成功', 'success');
currentProjectId = res.data.id;
if (detailTitle) detailTitle.textContent = '项目详情';
} else {
showToast(res.message || '创建失败', 'error');
}
});
}
});
}
function openLogDrawer() {
if (!currentProjectId) return;
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 = '';
window.API.post('/api/projects/logs', { id: currentProjectId, page: 1, pageSize: 20 }).then(function (res) {
if (logLoading) logLoading.style.display = 'none';
if (!res.ok) {
if (logEmpty) logEmpty.querySelector('p').textContent = res.message || '加载失败';
if (logEmpty) logEmpty.style.display = 'flex';
return;
}
var list = (res.data && res.data.list) || [];
if (list.length === 0) {
if (logEmpty) logEmpty.style.display = 'flex';
} else {
logList.innerHTML = list
.map(function (l) {
var time = l.operatedAt ? l.operatedAt.replace('T', ' ').slice(0, 19) : '';
return '<li class="log-item"><div class="log-meta">' + (l.operatorName || '') + ' · ' + time + '</div><div class="log-desc">' + (l.summary || '') + '</div></li>';
})
.join('');
}
});
}
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);
bindTabs();
if (window.API && window.API.getToken && window.API.getToken()) {
showApp();
} else {
showLogin();
}
})();
+28
View File
@@ -0,0 +1,28 @@
/**
* Music Stream landing — minimal interaction
* Smooth scroll, play button state, CTA focus
*/
(function () {
'use strict';
document.querySelectorAll('a[href^="#"]').forEach(function (a) {
a.addEventListener('click', function (e) {
var id = this.getAttribute('href');
if (id === '#') return;
var el = document.querySelector(id);
if (el) {
e.preventDefault();
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
var playBtn = document.querySelector('.player-btn-play');
if (playBtn) {
playBtn.addEventListener('click', function () {
this.classList.toggle('is-playing');
var label = this.getAttribute('aria-label');
this.setAttribute('aria-label', label === 'Play' ? 'Pause' : 'Play');
});
}
})();
+2650
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "ocean-frontend",
"private": true,
"engines": {
"node": ">=18"
},
"scripts": {
"build": "vite build",
"build:agentation": "vite build"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"agentation": "^1.3.2",
"vite": "^7.3.1"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: 'agentation-entry.jsx',
output: {
entryFileNames: 'agentation.js',
format: 'es',
},
},
},
});