feat: 建筑数字化管理系统静态原型

Vue 3 + Element Plus + Vite,12 页面(PC 7 页 + 移动端 5 页),
暖色亲和视觉风格,手机模拟框小程序预览,完整模拟数据 + CRUD 操作。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
邓文强 2026-05-15 11:48:26 +08:00
commit a6a3747327
30 changed files with 4648 additions and 0 deletions

5
20260515/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.superpowers/
.DS_Store
*.log

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,165 @@
# 建筑数字化管理系统 - 静态页面原型设计
**日期**: 2026-05-15
**来源**: 2026-05-14 AI 会议纪要 (document.txt)
## 概述
面向中小建筑企业的轻量化项目数字化管理系统静态原型。覆盖考勤打卡、工时评分、项目生命周期管理、财务回款追踪四大核心模块。原型包含小程序端(手机模拟视图)和 PC 后台管理端,双端共用一套模拟数据。
## 技术栈
- **框架**: Vue 3 + Vite
- **UI 库**: Element PlusPC 端)
- **路由**: Vue Router 4PC 和移动端独立路由分组
- **移动端展示**: CSS 手机模拟框375x667 视口,移动端样式适配
- **状态管理**: Reactive store模拟数据内嵌无需后端
- **构建产物**: `vite build` 输出纯静态 HTML/CSS/JS可部署至任意 HTTP 服务器
## 视觉风格
暖色亲和风格:
- **主色调**: 琥珀/橙色系
- **背景**: 暖白 / 浅奶油色
- **卡片**: 圆角、柔和阴影
- **字体**: 无衬线体,亲和但不失专业感
- **定位**: 以人为本、工人视角优先,区别于传统冷冰冰的企业软件
## 页面清单12 页)
### 手机端5 页)—— 375x667 手机模拟框展示
| 序号 | 页面 | 核心内容 |
|------|------|----------|
| 1 | 登录页 | 手机号 + 验证码登录 |
| 2 | 项目切换 | 所属项目列表,点击切换当前项目 |
| 3 | 首页 | 项目名称/位置、今日考勤状态、评分环、快捷统计 |
| 4 | 打卡页 | GPS 定位指示、早晚打卡按钮、评分实时反馈 |
| 5 | 个人记录 | 考勤历史列表、每日工时、日薪结算 |
移动端导航:底部 4 个 Tab首页、打卡、记录、我的
### PC 后台端7 页)
| 序号 | 页面 | 核心内容 |
|------|------|----------|
| 1 | 登录页 | 账号密码登录表单 |
| 2 | 仪表盘首页 | 统计卡片(项目数、在岗工人、出勤率、回款汇总)、项目概览表格、近期动态 |
| 3 | 项目管理 | 项目列表(名称、位置、合同金额、回款进度)、新增/编辑弹窗、项目详情抽屉 |
| 4 | 人员管理 | 工人列表(姓名、电话、所属项目)、增删人员、项目分配 |
| 5 | 考勤管理 | 打卡记录表格(按日期/项目/人员筛选)、手动补卡弹窗、工时调整 |
| 6 | 财务管理 | 回款追踪表格(合同金额、预付款到账、下笔款项日期、待收余额)、回款状态标签 |
| 7 | 系统设置 | 管理员账号列表、角色配置、操作日志表、系统参数 |
PC 导航:深色侧边栏菜单(可折叠)+ 顶部栏(用户信息、手机预览按钮)。
## 布局结构
### PC 端布局
```
+-------+------------------------------------+
| | 顶部栏(用户信息、通知、手机预览) |
| 侧边 +------------------------------------+
| 栏 | |
| 菜单 | 内容区(面包屑 + 页面内容) |
| | |
+-------+------------------------------------+
```
### 手机端布局(模拟框)
```
+------------------+
| 状态栏 |
| 页面标题栏 |
| |
| 内容区 |
| |
+------------------+
| 底部 Tab 栏 |
+------------------+
```
### 双端切换方式
PC 端顶部栏提供「手机预览」按钮,点击后在右侧滑出面板内展示 375x667 手机模拟框。手机预览与 PC 端共享同一套模拟数据源,操作联动。
## 模拟数据模型
所有数据以 JS 常量内嵌CRUD 操作在内存中生效,页面刷新后重置。
### 项目
```
id, 项目名称, 项目位置, 合同金额, 预付款已收,
下笔款项日期, 下笔款项金额, 待收余额, 状态
```
### 工人
```
id, 姓名, 手机号, 所属项目ID, 入场日期, 状态
```
### 考勤记录
```
id, 工人ID, 项目ID, 日期, 上班打卡时间, 下班打卡时间,
评分(0-10), 工时(小时), 日薪, 状态(正常/手动调整)
```
### 管理员
```
id, 用户名, 角色(超级管理员/管理员), 最后登录时间
```
## 关键设计决策
1. **无审批流程**: 管理员操作(手动补卡、工时调整、删除人员)直接生效。会议明确要求简化操作,不走多级审批。
2. **10 分制评分可视化**: 手机端首页和打卡页展示评分环。满勤 10 小时 = 10 分 = 100% 日薪。早退每小时扣 1 分,日薪按比例折算(如 100 元/天9 分得 90 元)。
3. **GPS 定位为 UI 状态指示**: 打卡页展示位置状态徽章(绿色 = 在范围内,红色 = 超出范围)。实际定位逻辑用模拟开关替代,方便演示。
4. **项目全生命周期一表展示**: 财务管理页将合同金额、预付款、付款计划、待收余额整合在一张表中,无需多层下钻。
5. **模拟数据力求真实**: 使用真实感的中文建筑项目名称、工人姓名、符合会议提及规模的财务数据(百万级合同额、百人规模工人)。
## 组件结构
```
src/
router/
index.js # Vue RouterPC 和移动端路由分组
stores/
index.js # Reactive store模拟数据 + CRUD 方法
views/
pc/
PcLogin.vue # PC 登录页
Dashboard.vue # 仪表盘首页
ProjectMgmt.vue # 项目管理
WorkerMgmt.vue # 人员管理
AttendanceMgmt.vue # 考勤管理
FinanceMgmt.vue # 财务管理
SystemSettings.vue # 系统设置
mobile/
MobileLogin.vue # 手机登录页
ProjectSwitch.vue # 项目切换
MobileHome.vue # 手机首页
ClockIn.vue # 打卡页
PersonalRecords.vue # 个人记录
layouts/
PcLayout.vue # PC 端侧边栏 + 顶部栏外壳
MobileFrame.vue # 手机模拟框包装器
components/
ScoreRing.vue # 环形评分组件
GpsBadge.vue # GPS 位置状态徽章
StatCard.vue # 仪表盘统计卡片
PhoneSimulator.vue # 375x667 手机框容器
App.vue
main.js
```
## 不包含范围
- 真实登录认证 / Token 管理
- 后端 API 对接
- 真实 GPS 定位
- 文件上传 / 文档管理
- 数据持久化(刷新即重置)
- PC 页面的响应式适配(固定桌面宽度)

12
20260515/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>建筑数字化管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1423
20260515/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
20260515/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "construction-management-prototype",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0"
}
}

3
20260515/src/App.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@ -0,0 +1,23 @@
<template>
<span :style="{
display:'inline-flex', alignItems:'center', gap:'4px', padding:'4px 12px',
borderRadius:'20px', fontSize:'12px',
background: inRange ? '#f0f9eb' : '#fef0f0',
color: inRange ? '#67c23a' : '#f56c6c',
border: `1px solid ${inRange ? '#e1f3d8' : '#fde2e2'}`
}">
<span :style="{ width:'8px',height:'8px',borderRadius:'50%',background: inRange ? '#67c23a' : '#f56c6c',animation: inRange ? 'pulse 2s infinite' : 'none' }"></span>
{{ inRange ? 'GPS 定位就绪' : '超出打卡范围' }}
</span>
</template>
<script setup>
defineProps({ inRange: Boolean })
</script>
<style scoped>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<div class="phone-simulator">
<div class="phone-status-bar">9:41</div>
<div class="phone-header">建筑管理系统</div>
<div class="phone-content">
<component :is="currentComponent" />
</div>
<div class="phone-tab-bar">
<div v-for="tab in tabs" :key="tab.key"
class="phone-tab-item" :class="{ active: activeTab === tab.key }"
@click="switchTab(tab.key)">
<span class="tab-icon"><component :is="tab.icon" style="width:18px;height:18px;" /></span>
{{ tab.label }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, markRaw } from 'vue'
import { HomeFilled, Camera, List, UserFilled } from '@element-plus/icons-vue'
import MobileLogin from '../views/mobile/MobileLogin.vue'
import ProjectSwitch from '../views/mobile/ProjectSwitch.vue'
import MobileHome from '../views/mobile/MobileHome.vue'
import ClockIn from '../views/mobile/ClockIn.vue'
import PersonalRecords from '../views/mobile/PersonalRecords.vue'
const props = defineProps({ initialPage: { type: String, default: 'home' } })
const emit = defineEmits(['tab-change'])
const activeTab = ref(props.initialPage)
const tabs = [
{ key: 'home', label: '首页', icon: markRaw(HomeFilled) },
{ key: 'clockin', label: '打卡', icon: markRaw(Camera) },
{ key: 'records', label: '记录', icon: markRaw(List) },
{ key: 'profile', label: '我的', icon: markRaw(UserFilled) },
]
const componentMap = {
'home': markRaw(MobileHome),
'clockin': markRaw(ClockIn),
'records': markRaw(PersonalRecords),
'profile': markRaw(ProjectSwitch),
}
const currentComponent = computed(() => componentMap[activeTab.value] || MobileHome)
function switchTab(key) {
activeTab.value = key
emit('tab-change', key)
}
</script>

View File

@ -0,0 +1,31 @@
<template>
<div style="position:relative;display:inline-flex;align-items:center;justify-content:center;">
<svg width="100" height="100" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke="#f0e8df" stroke-width="8" />
<circle cx="50" cy="50" r="42" fill="none" :stroke="color" stroke-width="8"
stroke-linecap="round" :stroke-dasharray="circumference"
:stroke-dashoffset="offset"
transform="rotate(-90 50 50)"
style="transition: stroke-dashoffset 0.6s ease;" />
</svg>
<div style="position:absolute;text-align:center;">
<div style="font-size:28px;font-weight:700;" :style="{ color }">{{ score }}</div>
<div style="font-size:11px;color:#999;">/ 10 </div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ score: { type: Number, default: 0 } })
const circumference = 2 * Math.PI * 42
const color = computed(() => {
if (props.score >= 10) return '#67c23a'
if (props.score >= 7) return '#e6a23c'
return '#f56c6c'
})
const offset = computed(() => circumference * (1 - props.score / 10))
</script>

View File

@ -0,0 +1,21 @@
<template>
<div class="card-soft" style="display:flex;align-items:center;gap:16px;min-width:200px;">
<div :style="{ width:'48px',height:'48px',borderRadius:'12px',background:bgColor,display:'flex',alignItems:'center',justifyContent:'center',fontSize:'22px',color:'#fff' }">
<component :is="icon" />
</div>
<div>
<div style="font-size:13px;color:#999;">{{ label }}</div>
<div style="font-size:24px;font-weight:700;color:#303133;">{{ value }}<span v-if="unit" style="font-size:14px;font-weight:400;color:#999;margin-left:2px;">{{ unit }}</span></div>
</div>
</div>
</template>
<script setup>
defineProps({
icon: { type: [String, Object, Function], default: null },
label: String,
value: [String, Number],
unit: { type: String, default: '' },
bgColor: { type: String, default: '#e67e22' }
})
</script>

View File

@ -0,0 +1,10 @@
<template>
<div style="display:flex;justify-content:center;align-items:center;min-height:100vh;background:#f0e8df;">
<PhoneSimulator :initialPage="page" />
</div>
</template>
<script setup>
import PhoneSimulator from '../components/PhoneSimulator.vue'
defineProps({ page: { type: String, default: 'home' } })
</script>

View File

@ -0,0 +1,63 @@
<template>
<div style="display:flex;min-height:100vh;">
<div class="sidebar" style="width:220px;flex-shrink:0;display:flex;flex-direction:column;">
<div style="padding:20px;color:#fff;font-size:16px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.1);letter-spacing:1px;">
建筑管理系统
</div>
<div style="flex:1;padding:8px 0;">
<div v-for="item in menuItems" :key="item.path"
@click="$router.push(item.path)"
:style="{
padding:'12px 24px', cursor:'pointer', fontSize:'14px',
color: isActive(item.path) ? '#fff' : '#b0a89a',
background: isActive(item.path) ? 'rgba(255,255,255,0.08)' : 'transparent',
borderLeft: isActive(item.path) ? '3px solid #e67e22' : '3px solid transparent',
transition:'all 0.2s'
}">
<el-icon style="margin-right:8px;"><component :is="item.icon" /></el-icon>{{ item.label }}
</div>
</div>
</div>
<div style="flex:1;display:flex;flex-direction:column;">
<div style="height:56px;background:#fff;border-bottom:1px solid #f0e8df;display:flex;align-items:center;justify-content:flex-end;padding:0 24px;gap:16px;flex-shrink:0;">
<el-button @click="mobileDrawer = true" size="small" round :icon="Iphone">手机预览</el-button>
<span style="font-size:14px;color:#606266;display:flex;align-items:center;gap:6px;"><el-icon><UserFilled /></el-icon></span>
</div>
<div style="flex:1;padding:20px;overflow-y:auto;background:#fdf8f3;">
<router-view />
</div>
</div>
<el-drawer v-model="mobileDrawer" title="小程序预览" size="420px" direction="rtl">
<div style="display:flex;justify-content:center;">
<PhoneSimulator :initialPage="mobileTab" @tab-change="mobileTab = $event" />
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, markRaw } from 'vue'
import { useRoute } from 'vue-router'
import { Monitor, FolderOpened, User, Calendar, Money, Setting, Iphone, UserFilled } from '@element-plus/icons-vue'
import PhoneSimulator from '../components/PhoneSimulator.vue'
const route = useRoute()
const mobileDrawer = ref(false)
const mobileTab = ref('home')
const menuItems = [
{ path: '/pc/dashboard', label: '仪表盘', icon: markRaw(Monitor) },
{ path: '/pc/projects', label: '项目管理', icon: markRaw(FolderOpened) },
{ path: '/pc/workers', label: '人员管理', icon: markRaw(User) },
{ path: '/pc/attendance', label: '考勤管理', icon: markRaw(Calendar) },
{ path: '/pc/finance', label: '财务管理', icon: markRaw(Money) },
{ path: '/pc/settings', label: '系统设置', icon: markRaw(Setting) },
]
function isActive(path) {
return route.path === path
}
</script>

15
20260515/src/main.js Normal file
View File

@ -0,0 +1,15 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/theme.css'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

View File

@ -0,0 +1,47 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/pc/dashboard'
},
{
path: '/pc/login',
name: 'PcLogin',
component: () => import('../views/pc/PcLogin.vue')
},
{
path: '/pc',
component: () => import('../layouts/PcLayout.vue'),
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/pc/Dashboard.vue') },
{ path: 'projects', name: 'Projects', component: () => import('../views/pc/ProjectMgmt.vue') },
{ path: 'workers', name: 'Workers', component: () => import('../views/pc/WorkerMgmt.vue') },
{ path: 'attendance', name: 'Attendance', component: () => import('../views/pc/AttendanceMgmt.vue') },
{ path: 'finance', name: 'Finance', component: () => import('../views/pc/FinanceMgmt.vue') },
{ path: 'settings', name: 'Settings', component: () => import('../views/pc/SystemSettings.vue') },
]
},
{
path: '/mobile/login',
name: 'MobileLogin',
component: () => import('../views/mobile/MobileLogin.vue')
},
{
path: '/mobile/home',
name: 'MobileHome',
component: () => import('../layouts/MobileFrame.vue'),
props: { page: 'home' }
},
{
path: '/:pathMatch(.*)*',
redirect: '/pc/dashboard'
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,155 @@
import { reactive } from 'vue'
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const initialProjects = [
{ id: 1, name: '长沙万科金域国际二期', location: '湖南省长沙市岳麓区', contractAmount: 1200000, prepaymentReceived: 360000, nextPaymentDate: '2026-06-15', nextPaymentAmount: 300000, outstandingBalance: 540000, status: '进行中' },
{ id: 2, name: '南昌绿地国际博览城', location: '江西省南昌市红谷滩区', contractAmount: 980000, prepaymentReceived: 300000, nextPaymentDate: '2026-06-30', nextPaymentAmount: 250000, outstandingBalance: 430000, status: '进行中' },
{ id: 3, name: '九江中梁首府', location: '江西省九江市濂溪区', contractAmount: 750000, prepaymentReceived: 750000, nextPaymentDate: '-', nextPaymentAmount: 0, outstandingBalance: 0, status: '已完工' },
{ id: 4, name: '西宁碧桂园御湖', location: '青海省西宁市城西区', contractAmount: 1500000, prepaymentReceived: 500000, nextPaymentDate: '2026-05-20', nextPaymentAmount: 400000, outstandingBalance: 600000, status: '进行中' },
{ id: 5, name: '衡阳美的梧桐庄园', location: '湖南省衡阳市蒸湘区', contractAmount: 860000, prepaymentReceived: 200000, nextPaymentDate: '2026-07-01', nextPaymentAmount: 300000, outstandingBalance: 360000, status: '筹备中' },
]
const initialWorkers = [
{ id: 1, name: '张建国', phone: '13812345601', assignedProjectId: 1, joinDate: '2025-03-15', status: '在岗' },
{ id: 2, name: '李大军', phone: '13812345602', assignedProjectId: 1, joinDate: '2025-04-20', status: '在岗' },
{ id: 3, name: '王永强', phone: '13812345603', assignedProjectId: 1, joinDate: '2025-05-10', status: '在岗' },
{ id: 4, name: '刘志明', phone: '13812345604', assignedProjectId: 2, joinDate: '2025-06-01', status: '在岗' },
{ id: 5, name: '陈海峰', phone: '13812345605', assignedProjectId: 2, joinDate: '2025-07-15', status: '在岗' },
{ id: 6, name: '赵大勇', phone: '13812345606', assignedProjectId: 4, joinDate: '2025-08-01', status: '在岗' },
{ id: 7, name: '周文斌', phone: '13812345607', assignedProjectId: 4, joinDate: '2025-09-10', status: '在岗' },
{ id: 8, name: '吴国栋', phone: '13812345608', assignedProjectId: 4, joinDate: '2025-10-20', status: '休假' },
{ id: 9, name: '郑小军', phone: '13812345609', assignedProjectId: 5, joinDate: '2026-01-05', status: '在岗' },
{ id: 10, name: '马洪涛', phone: '13812345610', assignedProjectId: 5, joinDate: '2026-02-15', status: '在岗' },
]
const initialAttendance = [
{ id: 1, workerId: 1, projectId: 1, date: '2026-05-14', morningClockIn: '07:45', eveningClockOut: '18:05', score: 10, hoursWorked: 10, dailyWage: 100, status: '正常' },
{ id: 2, workerId: 2, projectId: 1, date: '2026-05-14', morningClockIn: '07:50', eveningClockOut: '17:00', score: 9, hoursWorked: 9, dailyWage: 90, status: '正常' },
{ id: 3, workerId: 3, projectId: 1, date: '2026-05-14', morningClockIn: '08:10', eveningClockOut: '18:00', score: 8, hoursWorked: 8, dailyWage: 80, status: '迟到' },
{ id: 4, workerId: 1, projectId: 1, date: '2026-05-15', morningClockIn: '07:30', eveningClockOut: '18:10', score: 10, hoursWorked: 10, dailyWage: 100, status: '正常' },
{ id: 5, workerId: 4, projectId: 2, date: '2026-05-14', morningClockIn: '07:55', eveningClockOut: '18:00', score: 10, hoursWorked: 10, dailyWage: 100, status: '正常' },
{ id: 6, workerId: 5, projectId: 2, date: '2026-05-14', morningClockIn: '-', eveningClockOut: '-', score: 0, hoursWorked: 0, dailyWage: 0, status: '缺勤' },
{ id: 7, workerId: 6, projectId: 4, date: '2026-05-14', morningClockIn: '07:40', eveningClockOut: '18:05', score: 10, hoursWorked: 10, dailyWage: 100, status: '正常' },
]
const initialAdmins = [
{ id: 1, username: 'admin', role: '超级管理员', lastLogin: '2026-05-15 08:30' },
{ id: 2, username: 'zhangsan', role: '管理员', lastLogin: '2026-05-14 17:45' },
]
const initialLogs = [
{ id: 1, adminId: 1, action: '手动补卡', target: '李大军', detail: '2026-05-14 下班打卡补录 18:00', time: '2026-05-14 19:30' },
{ id: 2, adminId: 1, action: '删除人员', target: '临时工-孙小伟', detail: '项目完工,人员清退', time: '2026-05-13 16:00' },
{ id: 3, adminId: 2, action: '修改工时', target: '王永强', detail: '5月13日工时从8小时调整为10小时', time: '2026-05-14 09:15' },
{ id: 4, adminId: 1, action: '新增项目', target: '衡阳美的梧桐庄园', detail: '合同金额 860,000 元', time: '2026-05-10 14:00' },
]
let nextId = { projects: 6, workers: 11, attendance: 8, admins: 3, logs: 5 }
export const store = reactive({
projects: [...initialProjects],
workers: [...initialWorkers],
attendance: [...initialAttendance],
admins: [...initialAdmins],
logs: [...initialLogs],
currentProjectId: 1,
currentWorkerId: 1,
gpsInRange: true,
addProject(project) {
this.projects.push({ ...project, id: nextId.projects++ })
this.addLog('新增项目', project.name, `合同金额 ${project.contractAmount.toLocaleString()}`)
},
updateProject(id, data) {
const idx = this.projects.findIndex(p => p.id === id)
if (idx !== -1) Object.assign(this.projects[idx], data)
},
deleteProject(id) {
const p = this.projects.find(p => p.id === id)
this.projects = this.projects.filter(p => p.id !== id)
if (p) this.addLog('删除项目', p.name, '')
},
addWorker(worker) {
this.workers.push({ ...worker, id: nextId.workers++ })
this.addLog('新增人员', worker.name, `分配到项目ID: ${worker.assignedProjectId}`)
},
deleteWorker(id) {
const w = this.workers.find(w => w.id === id)
this.workers = this.workers.filter(w => w.id !== id)
if (w) this.addLog('删除人员', w.name, '人员清退')
},
assignProject(workerId, projectId) {
const w = this.workers.find(w => w.id === workerId)
if (w) w.assignedProjectId = projectId
},
addAttendance(record) {
this.attendance.push({ ...record, id: nextId.attendance++ })
},
updateAttendance(id, data) {
const idx = this.attendance.findIndex(a => a.id === id)
if (idx !== -1) {
Object.assign(this.attendance[idx], data, { status: '手动调整' })
this.addLog('修改工时', `记录#${id}`, `调整为 ${data.hoursWorked} 小时`)
}
},
manualClockIn(workerId, projectId, date, morning, evening) {
const hours = calculateHours(morning, evening)
const score = calculateScore(morning, evening)
this.attendance.push({
id: nextId.attendance++, workerId, projectId, date,
morningClockIn: morning, eveningClockOut: evening,
score, hoursWorked: hours, dailyWage: score * 10,
status: '手动调整'
})
const worker = this.workers.find(w => w.id === workerId)
this.addLog('手动补卡', worker ? worker.name : `工人#${workerId}`, `${date} ${morning}-${evening}`)
},
addLog(action, target, detail) {
this.logs.unshift({
id: nextId.logs++, adminId: 1, action, target, detail,
time: new Date().toLocaleString('zh-CN', { hour12: false })
})
},
})
function calculateHours(morning, evening) {
if (morning === '-' || evening === '-') return 0
const mparts = morning.split(':').map(Number)
const eparts = evening.split(':').map(Number)
return Math.round((eparts[0] - mparts[0] + (eparts[1] - mparts[1]) / 60) * 10) / 10
}
function calculateScore(morning, evening) {
const hours = calculateHours(morning, evening)
if (hours >= 10) return 10
return Math.max(0, Math.round(hours))
}
export function getTodayAttendance(workerId) {
return store.attendance.find(a => a.workerId === workerId && a.date === today)
}
export function getWorkerAttendance(workerId) {
return store.attendance.filter(a => a.workerId === workerId).sort((a, b) => b.date.localeCompare(a.date))
}
export function getProjectWorkerCount(projectId) {
return store.workers.filter(w => w.assignedProjectId === projectId).length
}
export function getTodayStats() {
const totalInPost = store.workers.filter(w => w.status === '在岗').length
const todayRecords = store.attendance.filter(a => a.date === today)
const present = todayRecords.filter(r => r.status !== '缺勤').length
return {
total: totalInPost,
present,
rate: totalInPost > 0 ? Math.round(present / totalInPost * 100) : 0
}
}

View File

@ -0,0 +1,108 @@
:root {
--el-color-primary: #e67e22;
--el-color-primary-light-1: #e98e3f;
--el-color-primary-light-2: #ed9e5c;
--el-color-primary-light-3: #f0ae79;
--el-color-primary-light-4: #f4be96;
--el-color-primary-light-5: #f7ceb3;
--el-color-primary-light-6: #fadecf;
--el-color-primary-light-7: #fceeeb;
--el-color-primary-light-8: #fef8f5;
--el-color-primary-light-9: #fefcfa;
--el-color-primary-dark-1: #cf711f;
--el-color-success: #67c23a;
--el-color-warning: #e6a23c;
--el-color-danger: #f56c6c;
--el-bg-color-page: #fdf8f3;
--el-bg-color: #ffffff;
--el-border-radius-base: 8px;
--el-border-radius-small: 6px;
}
body {
margin: 0;
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: var(--el-bg-color-page);
color: #303133;
}
.sidebar {
background: linear-gradient(180deg, #3a3226 0%, #4a4033 100%);
}
.card-soft {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.phone-simulator {
width: 375px;
height: 667px;
border: 3px solid #333;
border-radius: 28px;
overflow: hidden;
background: #fff;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
.phone-status-bar {
height: 24px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #333;
}
.phone-header {
height: 42px;
background: linear-gradient(135deg, #e67e22, #f0a04b);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
letter-spacing: 1px;
}
.phone-content {
height: calc(667px - 24px - 42px - 50px);
overflow-y: auto;
background: #fdf8f3;
}
.phone-tab-bar {
height: 50px;
background: #fff;
border-top: 1px solid #f0e8df;
display: flex;
align-items: center;
}
.phone-tab-item {
flex: 1;
text-align: center;
font-size: 10px;
color: #b0a090;
padding: 4px 0;
cursor: pointer;
transition: color 0.2s;
}
.phone-tab-item.active {
color: #e67e22;
}
.phone-tab-item .tab-icon {
display: block;
margin-bottom: 2px;
line-height: 1;
}
.phone-tab-item .tab-icon svg {
width: 18px;
height: 18px;
}

View File

@ -0,0 +1,67 @@
<template>
<div style="padding:24px 16px;text-align:center;">
<GpsBadge :inRange="store.gpsInRange" style="margin-bottom:24px;" />
<div style="font-size:13px;color:#999;margin-bottom:8px;">今日考勤</div>
<div style="margin:20px 0;">
<div @click="doClockIn"
:style="{
width:'120px',height:'120px',borderRadius:'50%',display:'inline-flex',
alignItems:'center',justifyContent:'center',cursor:'pointer',
background: clockedIn ? '#f0e8df' : 'linear-gradient(135deg, #e67e22, #f0a04b)',
color: clockedIn ? '#999' : '#fff', fontSize:'18px', fontWeight:700,
transition:'all 0.3s', boxShadow: clockedIn ? 'none' : '0 4px 20px rgba(230,126,34,0.4)'
}">
{{ clockedIn ? '已打卡' : '打卡' }}
</div>
</div>
<div style="font-size:12px;color:#999;">
<div v-if="todayRecord">
<div>上班: {{ todayRecord.morningClockIn }} | 下班: {{ todayRecord.eveningClockOut }}</div>
<div style="margin-top:4px;font-weight:600;" :style="{ color: todayRecord.score >= 10 ? '#67c23a' : '#e6a23c' }">
评分 {{ todayRecord.score }} - 日薪 {{ todayRecord.dailyWage }}
</div>
</div>
<div v-else>尚未打卡</div>
</div>
<div style="margin-top:32px;font-size:12px;color:#999;">
<div style="margin-bottom:6px;">演示: GPS 范围切换</div>
<el-switch v-model="store.gpsInRange" active-text="范围内" inactive-text="超出" size="small" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { store, getTodayAttendance } from '../../stores/index.js'
import GpsBadge from '../../components/GpsBadge.vue'
const todayRecord = computed(() => getTodayAttendance(store.currentWorkerId))
const clockedIn = computed(() => !!todayRecord.value && todayRecord.value.morningClockIn !== '-')
function doClockIn() {
if (!store.gpsInRange) return
const now = new Date()
const time = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`
const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
if (!todayRecord.value) {
store.attendance.push({
id: Date.now(), workerId: store.currentWorkerId, projectId: store.currentProjectId,
date: today, morningClockIn: time, eveningClockOut: '-', score: 0, hoursWorked: 0, dailyWage: 0, status: '正常'
})
} else if (todayRecord.value.eveningClockOut === '-') {
const mparts = todayRecord.value.morningClockIn.split(':').map(Number)
const eparts = time.split(':').map(Number)
const hours = Math.max(0, eparts[0] - mparts[0] + (eparts[1] - mparts[1]) / 60)
const score = Math.min(10, Math.max(0, Math.round(hours)))
Object.assign(todayRecord.value, {
eveningClockOut: time, hoursWorked: Math.round(hours * 10) / 10,
score, dailyWage: score * 10
})
}
}
</script>

View File

@ -0,0 +1,37 @@
<template>
<div style="padding:16px;">
<div style="background:#fff;border-radius:12px;padding:16px;margin-bottom:12px;box-shadow:0 1px 6px rgba(0,0,0,0.04);">
<div style="font-size:14px;font-weight:600;color:#303133;">{{ currentProject?.name || '-' }}</div>
<div style="font-size:11px;color:#999;margin-top:4px;">{{ currentProject?.location || '-' }}</div>
</div>
<div style="background:#fff;border-radius:12px;padding:20px;margin-bottom:12px;box-shadow:0 1px 6px rgba(0,0,0,0.04);text-align:center;">
<div style="font-size:13px;color:#999;margin-bottom:12px;">今日考勤评分</div>
<ScoreRing :score="todayRecord?.score || 0" />
<div style="margin-top:12px;">
<div style="font-size:12px;color:#666;">上班: {{ todayRecord?.morningClockIn || '-' }} | 下班: {{ todayRecord?.eveningClockOut || '-' }}</div>
<div style="font-size:12px;color:#e67e22;font-weight:600;margin-top:4px;">预计日薪: {{ (todayRecord?.dailyWage || 0) }} </div>
</div>
</div>
<div style="display:flex;gap:10px;">
<div style="flex:1;background:#fff;border-radius:10px;padding:14px;text-align:center;box-shadow:0 1px 6px rgba(0,0,0,0.04);">
<div style="font-size:20px;font-weight:700;color:#e67e22;">{{ store.workers.filter(w=>w.status==='在岗').length }}</div>
<div style="font-size:11px;color:#999;">在岗工人</div>
</div>
<div style="flex:1;background:#fff;border-radius:10px;padding:14px;text-align:center;box-shadow:0 1px 6px rgba(0,0,0,0.04);">
<div style="font-size:20px;font-weight:700;color:#67c23a;">{{ store.projects.filter(p=>p.status==='进行中').length }}</div>
<div style="font-size:11px;color:#999;">进行中项目</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { store, getTodayAttendance } from '../../stores/index.js'
import ScoreRing from '../../components/ScoreRing.vue'
const currentProject = computed(() => store.projects.find(p => p.id === store.currentProjectId))
const todayRecord = computed(() => getTodayAttendance(store.currentWorkerId))
</script>

View File

@ -0,0 +1,32 @@
<template>
<div style="padding:40px 24px;text-align:center;">
<div style="font-size:20px;font-weight:700;color:#303133;margin-bottom:8px;">建筑管理系统</div>
<div style="font-size:12px;color:#999;margin-bottom:32px;">微信小程序端</div>
<div style="font-size:13px;color:#666;margin-bottom:16px;">手机号登录</div>
<input class="mobile-input" v-model="phone" placeholder="请输入手机号" />
<input class="mobile-input" v-model="code" placeholder="验证码" style="margin-bottom:24px;" />
<button class="mobile-btn" @click="doLogin"> </button>
<div style="font-size:11px;color:#ccc;margin-top:20px;">首次登录自动注册</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const phone = ref('')
const code = ref('')
function doLogin() { /* 原型演示,直接切换 */ }
</script>
<style scoped>
.mobile-input {
width: 100%; padding: 10px 12px; border: 1px solid #e0d6cc;
border-radius: 8px; font-size: 14px; margin-bottom: 12px;
background: #fff; box-sizing: border-box; outline: none;
}
.mobile-input:focus { border-color: #e67e22; }
.mobile-btn {
width: 100%; padding: 12px; background: linear-gradient(135deg, #e67e22, #f0a04b);
border: none; border-radius: 24px; color: #fff; font-size: 15px;
font-weight: 600; cursor: pointer; letter-spacing: 2px;
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<div style="padding:16px;">
<div style="background:#fff;border-radius:12px;padding:16px;margin-bottom:12px;box-shadow:0 1px 6px rgba(0,0,0,0.04);">
<div style="display:flex;justify-content:space-around;text-align:center;">
<div>
<div style="font-size:22px;font-weight:700;color:#e67e22;">{{ monthStats.totalDays }}</div>
<div style="font-size:11px;color:#999;">出勤天数</div>
</div>
<div>
<div style="font-size:22px;font-weight:700;color:#303133;">{{ monthStats.totalHours }}</div>
<div style="font-size:11px;color:#999;">总工时</div>
</div>
<div>
<div style="font-size:22px;font-weight:700;color:#67c23a;">{{ monthStats.totalWage }}</div>
<div style="font-size:11px;color:#999;">月薪合计</div>
</div>
</div>
</div>
<div style="font-size:14px;font-weight:600;color:#303133;margin-bottom:10px;">考勤明细</div>
<div v-for="r in workerRecords" :key="r.id"
style="background:#fff;border-radius:10px;padding:14px;margin-bottom:8px;box-shadow:0 1px 6px rgba(0,0,0,0.04);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:13px;color:#303133;">{{ r.date }}</div>
<div style="font-size:11px;color:#999;margin-top:2px;">{{ r.morningClockIn }} - {{ r.eveningClockOut }}</div>
</div>
<div style="text-align:right;">
<div :style="{ fontSize:'16px',fontWeight:700, color: r.score>=10?'#67c23a':r.score>=7?'#e6a23c':'#f56c6c' }">{{ r.score }} </div>
<div style="font-size:11px;color:#999;">{{ r.dailyWage }} </div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { store, getWorkerAttendance } from '../../stores/index.js'
const workerRecords = computed(() => getWorkerAttendance(store.currentWorkerId))
const monthStats = computed(() => {
const records = workerRecords.value
return {
totalDays: records.filter(r => r.status !== '缺勤').length,
totalHours: records.reduce((s, r) => s + r.hoursWorked, 0),
totalWage: records.reduce((s, r) => s + r.dailyWage, 0)
}
})
</script>

View File

@ -0,0 +1,19 @@
<template>
<div style="padding:20px 16px;">
<div style="font-size:14px;font-weight:600;color:#303133;margin-bottom:16px;">切换项目</div>
<div v-for="p in store.projects" :key="p.id"
@click="store.currentProjectId = p.id"
:style="{
padding:'14px', borderRadius:'10px', marginBottom:'8px', cursor:'pointer',
background: store.currentProjectId === p.id ? '#fef5ed' : '#fff',
border: store.currentProjectId === p.id ? '2px solid #e67e22' : '1px solid #f0e8df'
}">
<div style="font-size:14px;font-weight:600;color:#303133;">{{ p.name }}</div>
<div style="font-size:11px;color:#999;margin-top:4px;">{{ p.location }}</div>
</div>
</div>
</template>
<script setup>
import { store } from '../../stores/index.js'
</script>

View File

@ -0,0 +1,145 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="margin:0;font-size:20px;">考勤管理</h2>
<el-button type="primary" :icon="Clock" @click="openPatchDialog" round>手动补卡</el-button>
</div>
<div class="card-soft" style="margin-bottom:16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<el-select v-model="filterProject" placeholder="按项目筛选" clearable style="width:200px;" size="small">
<el-option v-for="p in store.projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-date-picker v-model="filterDate" type="date" placeholder="按日期筛选" size="small" value-format="YYYY-MM-DD" />
<el-button size="small" @click="filterProject=null;filterDate=null;">重置</el-button>
</div>
<div class="card-soft">
<el-table :data="filteredAttendance" stripe style="width:100%;" max-height="450">
<el-table-column label="工人" width="100">
<template #default="{ row }">{{ getWorkerName(row.workerId) }}</template>
</el-table-column>
<el-table-column label="项目" min-width="160">
<template #default="{ row }">{{ getProjectName(row.projectId) }}</template>
</el-table-column>
<el-table-column prop="date" label="日期" width="110" />
<el-table-column prop="morningClockIn" label="上班打卡" width="90" />
<el-table-column prop="eveningClockOut" label="下班打卡" width="90" />
<el-table-column label="评分" width="80">
<template #default="{ row }">
<span :style="{ color: row.score>=10?'#67c23a':row.score>=7?'#e6a23c':'#f56c6c', fontWeight:600 }">{{ row.score }} </span>
</template>
</el-table-column>
<el-table-column prop="hoursWorked" label="工时" width="70" />
<el-table-column prop="dailyWage" label="日薪" width="80">
<template #default="{ row }">{{ row.dailyWage }} </template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag v-if="row.status==='正常'" type="success" size="small">正常</el-tag>
<el-tag v-else-if="row.status==='手动调整'" type="warning" size="small">手动调整</el-tag>
<el-tag v-else-if="row.status==='迟到'" type="info" size="small">迟到</el-tag>
<el-tag v-else type="danger" size="small">缺勤</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" :icon="Edit" @click="openEditDialog(row)" link type="primary">调整</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="patchVisible" title="手动补卡" width="480px">
<el-form :model="patchForm" label-width="90px">
<el-form-item label="工人">
<el-select v-model="patchForm.workerId" style="width:100%;">
<el-option v-for="w in store.workers" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
</el-form-item>
<el-form-item label="项目">
<el-select v-model="patchForm.projectId" style="width:100%;">
<el-option v-for="p in store.projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="日期"><el-input v-model="patchForm.date" placeholder="YYYY-MM-DD" /></el-form-item>
<el-form-item label="上班时间"><el-input v-model="patchForm.morning" placeholder="HH:MM 或 -" /></el-form-item>
<el-form-item label="下班时间"><el-input v-model="patchForm.evening" placeholder="HH:MM 或 -" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="patchVisible = false">取消</el-button>
<el-button type="primary" @click="doPatchClockIn">确认补卡</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="调整工时" width="420px">
<el-form :model="editForm" label-width="90px">
<el-form-item label="上班打卡"><el-input v-model="editForm.morningClockIn" /></el-form-item>
<el-form-item label="下班打卡"><el-input v-model="editForm.eveningClockOut" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="doUpdateAttendance">保存调整</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { Clock, Edit } from '@element-plus/icons-vue'
import { store } from '../../stores/index.js'
const filterProject = ref(null)
const filterDate = ref(null)
const filteredAttendance = computed(() => {
let list = [...store.attendance].sort((a, b) => b.date.localeCompare(a.date))
if (filterProject.value) list = list.filter(a => a.projectId === filterProject.value)
if (filterDate.value) {
const ds = typeof filterDate.value === 'string' ? filterDate.value : filterDate.value.toISOString().slice(0, 10)
list = list.filter(a => a.date === ds)
}
return list
})
function getWorkerName(id) { const w = store.workers.find(w => w.id === id); return w ? w.name : `#${id}` }
function getProjectName(id) { const p = store.projects.find(p => p.id === id); return p ? p.name : `#${id}` }
const patchVisible = ref(false)
const patchForm = reactive({ workerId: 1, projectId: 1, date: '', morning: '', evening: '' })
function openPatchDialog() {
patchForm.date = new Date().toISOString().slice(0, 10)
patchForm.morning = '08:00'
patchForm.evening = '18:00'
patchVisible.value = true
}
function doPatchClockIn() {
store.manualClockIn(patchForm.workerId, patchForm.projectId, patchForm.date, patchForm.morning, patchForm.evening)
patchVisible.value = false
}
const editVisible = ref(false)
const editForm = reactive({ id: null, morningClockIn: '', eveningClockOut: '' })
function openEditDialog(row) {
editForm.id = row.id
editForm.morningClockIn = row.morningClockIn
editForm.eveningClockOut = row.eveningClockOut
editVisible.value = true
}
function doUpdateAttendance() {
const h = (() => {
if (editForm.morningClockIn === '-' || editForm.eveningClockOut === '-') return 0
const m = editForm.morningClockIn.split(':').map(Number)
const e = editForm.eveningClockOut.split(':').map(Number)
return Math.max(0, e[0] - m[0] + (e[1] - m[1]) / 60)
})()
store.updateAttendance(editForm.id, {
morningClockIn: editForm.morningClockIn,
eveningClockOut: editForm.eveningClockOut,
hoursWorked: Math.round(h * 10) / 10,
score: Math.min(10, Math.max(0, Math.round(h))),
dailyWage: Math.min(10, Math.max(0, Math.round(h))) * 10
})
editVisible.value = false
}
</script>

View File

@ -0,0 +1,54 @@
<template>
<div>
<h2 style="margin:0 0 20px;font-size:20px;">仪表盘</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px;">
<StatCard :icon="OfficeBuilding" label="项目总数" :value="store.projects.length" unit="个" bgColor="#e67e22" />
<StatCard :icon="User" label="在岗工人" :value="store.workers.filter(w=>w.status==='在岗').length" unit="人" bgColor="#67c23a" />
<StatCard :icon="TrendCharts" label="今日出勤率" :value="todayStats.rate" unit="%" bgColor="#409eff" />
<StatCard :icon="Coin" label="待收总额" :value="(store.projects.reduce((s,p)=>s+p.outstandingBalance,0)/10000).toFixed(0)" unit="万元" bgColor="#e6a23c" />
</div>
<div class="card-soft" style="margin-bottom:20px;">
<h3 style="margin:0 0 16px;font-size:16px;">项目概览</h3>
<el-table :data="store.projects" stripe style="width:100%;">
<el-table-column prop="name" label="项目名称" min-width="180" />
<el-table-column prop="location" label="位置" min-width="160" />
<el-table-column prop="contractAmount" label="合同金额" width="120">
<template #default="{ row }">{{ row.contractAmount.toLocaleString() }} </template>
</el-table-column>
<el-table-column prop="outstandingBalance" label="待收余额" width="120">
<template #default="{ row }">
<span :style="{ color: row.outstandingBalance > 0 ? '#e6a23c' : '#67c23a' }">
{{ row.outstandingBalance.toLocaleString() }}
</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status==='进行中'?'warning':row.status==='已完工'?'success':'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="card-soft">
<h3 style="margin:0 0 16px;font-size:16px;">近期操作</h3>
<el-table :data="store.logs.slice(0, 5)" stripe style="width:100%;" size="small">
<el-table-column prop="time" label="时间" width="160" />
<el-table-column prop="action" label="操作" width="100" />
<el-table-column prop="target" label="对象" width="120" />
<el-table-column prop="detail" label="详情" min-width="200" />
</el-table>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { OfficeBuilding, User, TrendCharts, Coin } from '@element-plus/icons-vue'
import { store, getTodayStats } from '../../stores/index.js'
import StatCard from '../../components/StatCard.vue'
const todayStats = computed(() => getTodayStats())
</script>

View File

@ -0,0 +1,65 @@
<template>
<div>
<h2 style="margin:0 0 20px;font-size:20px;">财务管理</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px;">
<div class="card-soft" style="flex:1;min-width:160px;text-align:center;">
<div style="margin-bottom:6px;color:#e67e22;"><el-icon :size="22"><Money /></el-icon></div>
<div style="font-size:13px;color:#999;">合同总金额</div>
<div style="font-size:22px;font-weight:700;color:#303133;margin-top:4px;">{{ totalContract.toLocaleString() }}</div>
<div style="font-size:12px;color:#999;"></div>
</div>
<div class="card-soft" style="flex:1;min-width:160px;text-align:center;">
<div style="margin-bottom:6px;color:#67c23a;"><el-icon :size="22"><CircleCheckFilled /></el-icon></div>
<div style="font-size:13px;color:#999;">已收预付款</div>
<div style="font-size:22px;font-weight:700;color:#67c23a;margin-top:4px;">{{ totalPrepayment.toLocaleString() }}</div>
<div style="font-size:12px;color:#999;"></div>
</div>
<div class="card-soft" style="flex:1;min-width:160px;text-align:center;">
<div style="margin-bottom:6px;color:#e6a23c;"><el-icon :size="22"><WarningFilled /></el-icon></div>
<div style="font-size:13px;color:#999;">待收余额</div>
<div style="font-size:22px;font-weight:700;color:#e6a23c;margin-top:4px;">{{ totalOutstanding.toLocaleString() }}</div>
<div style="font-size:12px;color:#999;"></div>
</div>
</div>
<div class="card-soft">
<h3 style="margin:0 0 16px;font-size:16px;">回款追踪明细</h3>
<el-table :data="store.projects" stripe style="width:100%;">
<el-table-column prop="name" label="项目名称" min-width="160" />
<el-table-column prop="contractAmount" label="合同金额" width="120">
<template #default="{ row }">{{ row.contractAmount.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="prepaymentReceived" label="预付款已收" width="120">
<template #default="{ row }"><span style="color:#67c23a;">{{ row.prepaymentReceived.toLocaleString() }}</span></template>
</el-table-column>
<el-table-column prop="nextPaymentDate" label="下笔款项日期" width="130" />
<el-table-column prop="nextPaymentAmount" label="下笔款项金额" width="130">
<template #default="{ row }">{{ row.nextPaymentAmount ? row.nextPaymentAmount.toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column label="待收余额" width="130">
<template #default="{ row }">
<span :style="{ color: row.outstandingBalance > 0 ? '#e6a23c' : '#67c23a', fontWeight: 600 }">{{ row.outstandingBalance.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="回款状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.outstandingBalance === 0" type="success" size="small">已结清</el-tag>
<el-tag v-else-if="row.prepaymentReceived > 0" type="warning" size="small">部分回款</el-tag>
<el-tag v-else type="info" size="small">未回款</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Money, CircleCheckFilled, WarningFilled } from '@element-plus/icons-vue'
import { store } from '../../stores/index.js'
const totalContract = computed(() => store.projects.reduce((s, p) => s + p.contractAmount, 0))
const totalPrepayment = computed(() => store.projects.reduce((s, p) => s + p.prepaymentReceived, 0))
const totalOutstanding = computed(() => store.projects.reduce((s, p) => s + p.outstandingBalance, 0))
</script>

View File

@ -0,0 +1,38 @@
<template>
<div style="display:flex;justify-content:center;align-items:center;min-height:100vh;background:linear-gradient(135deg, #fdf8f3 0%, #f0e8df 100%);">
<div style="width:400px;background:#fff;border-radius:16px;padding:48px 40px;box-shadow:0 8px 40px rgba(0,0,0,0.08);">
<div style="text-align:center;margin-bottom:32px;">
<div style="font-size:24px;font-weight:700;color:#303133;">建筑数字化管理系统</div>
<div style="font-size:13px;color:#999;margin-top:8px;">Construction Digital Management</div>
</div>
<el-form @submit.prevent>
<el-form-item>
<el-input placeholder="账号" prefix-icon="User" v-model="username" size="large" />
</el-form-item>
<el-form-item>
<el-input placeholder="密码" prefix-icon="Lock" v-model="password" type="password" size="large" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%;" @click="login" round :icon="SwitchButton"> </el-button>
</el-form-item>
</el-form>
<div style="text-align:center;font-size:12px;color:#ccc;">管理员账号: admin / 密码任意</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { SwitchButton } from '@element-plus/icons-vue'
const router = useRouter()
const username = ref('')
const password = ref('')
function login() {
if (username.value && password.value) {
router.push('/pc/dashboard')
}
}
</script>

View File

@ -0,0 +1,101 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="margin:0;font-size:20px;">项目管理</h2>
<el-button type="primary" :icon="Plus" @click="openDialog(null)" round>新增项目</el-button>
</div>
<div class="card-soft">
<el-table :data="store.projects" stripe style="width:100%;">
<el-table-column prop="name" label="项目名称" min-width="180" />
<el-table-column prop="location" label="地理位置" min-width="160" />
<el-table-column prop="contractAmount" label="合同金额" width="120">
<template #default="{ row }">{{ row.contractAmount.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="prepaymentReceived" label="预付款已收" width="110">
<template #default="{ row }">{{ row.prepaymentReceived.toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="nextPaymentDate" label="下笔款项日期" width="120" />
<el-table-column prop="outstandingBalance" label="待收余额" width="110">
<template #default="{ row }">
<span :style="{ color: row.outstandingBalance > 0 ? '#e6a23c' : '#67c23a', fontWeight:600 }">{{ row.outstandingBalance.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status==='进行中'?'warning':row.status==='已完工'?'success':'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" :icon="Edit" @click="openDialog(row)" link type="primary">编辑</el-button>
<el-popconfirm title="确定删除该项目?" @confirm="store.deleteProject(row.id)">
<template #reference>
<el-button size="small" :icon="Delete" link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑项目' : '新增项目'" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="项目名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="地理位置"><el-input v-model="form.location" /></el-form-item>
<el-form-item label="合同金额"><el-input-number v-model="form.contractAmount" :min="0" :step="10000" style="width:100%;" /></el-form-item>
<el-form-item label="预付款已收"><el-input-number v-model="form.prepaymentReceived" :min="0" :step="10000" style="width:100%;" /></el-form-item>
<el-form-item label="下笔款项日期"><el-input v-model="form.nextPaymentDate" placeholder="YYYY-MM-DD" /></el-form-item>
<el-form-item label="下笔款项金额"><el-input-number v-model="form.nextPaymentAmount" :min="0" :step="10000" style="width:100%;" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%;">
<el-option label="筹备中" value="筹备中" />
<el-option label="进行中" value="进行中" />
<el-option label="已完工" value="已完工" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import { store } from '../../stores/index.js'
const dialogVisible = ref(false)
const editingId = ref(null)
const form = reactive({
name: '', location: '', contractAmount: 0, prepaymentReceived: 0,
nextPaymentDate: '', nextPaymentAmount: 0, outstandingBalance: 0, status: '进行中'
})
function openDialog(row) {
if (row) {
editingId.value = row.id
Object.assign(form, { ...row })
} else {
editingId.value = null
Object.assign(form, {
name: '', location: '', contractAmount: 0, prepaymentReceived: 0,
nextPaymentDate: '', nextPaymentAmount: 0, outstandingBalance: 0, status: '进行中'
})
}
dialogVisible.value = true
}
function submit() {
form.outstandingBalance = form.contractAmount - form.prepaymentReceived
if (editingId.value) {
store.updateProject(editingId.value, { ...form })
} else {
store.addProject({ ...form })
}
dialogVisible.value = false
}
</script>

View File

@ -0,0 +1,56 @@
<template>
<div>
<h2 style="margin:0 0 20px;font-size:20px;">系统设置</h2>
<el-tabs v-model="activeTab">
<el-tab-pane label="管理员账号" name="admins">
<div class="card-soft">
<el-table :data="store.admins" stripe style="width:100%;">
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="role" label="角色" width="150" />
<el-table-column prop="lastLogin" label="最后登录" width="200" />
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="操作日志" name="logs">
<div class="card-soft">
<el-table :data="store.logs" stripe style="width:100%;" max-height="500">
<el-table-column prop="time" label="时间" width="170" />
<el-table-column prop="action" label="操作类型" width="110">
<template #default="{ row }">
<el-tag v-if="row.action.includes('删除')" type="danger" size="small">{{ row.action }}</el-tag>
<el-tag v-else-if="row.action.includes('新增')" type="success" size="small">{{ row.action }}</el-tag>
<el-tag v-else type="warning" size="small">{{ row.action }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="target" label="操作对象" width="140" />
<el-table-column prop="detail" label="详情" min-width="250" />
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="系统参数" name="params">
<div class="card-soft">
<el-descriptions :column="1" border>
<el-descriptions-item label="系统版本">v1.0.0 原型</el-descriptions-item>
<el-descriptions-item label="每日标准工时">10 小时</el-descriptions-item>
<el-descriptions-item label="满分标准">10 </el-descriptions-item>
<el-descriptions-item label="日薪基准">100 /</el-descriptions-item>
<el-descriptions-item label="打卡有效范围">项目位置 500 米内</el-descriptions-item>
<el-descriptions-item label="上班打卡截止">08:00</el-descriptions-item>
<el-descriptions-item label="下班打卡时间">18:00 左右</el-descriptions-item>
<el-descriptions-item label="服务器">阿里云 ECS</el-descriptions-item>
</el-descriptions>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { store } from '../../stores/index.js'
const activeTab = ref('admins')
</script>

View File

@ -0,0 +1,93 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="margin:0;font-size:20px;">人员管理</h2>
<el-button type="primary" :icon="Plus" @click="openDialog(null)" round>新增人员</el-button>
</div>
<div class="card-soft">
<el-table :data="store.workers" stripe style="width:100%;">
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column label="所属项目" min-width="160">
<template #default="{ row }">{{ getProjectName(row.assignedProjectId) }}</template>
</el-table-column>
<el-table-column prop="joinDate" label="入场日期" width="110" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status==='在岗'?'success':'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" :icon="Edit" @click="openDialog(row)" link type="primary">编辑</el-button>
<el-popconfirm title="确定删除该人员?" @confirm="store.deleteWorker(row.id)">
<template #reference>
<el-button size="small" :icon="Delete" link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑人员' : '新增人员'" width="480px">
<el-form :model="form" label-width="80px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="所属项目">
<el-select v-model="form.assignedProjectId" style="width:100%;">
<el-option v-for="p in store.projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%;">
<el-option label="在岗" value="在岗" />
<el-option label="休假" value="休假" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import { store } from '../../stores/index.js'
const dialogVisible = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', phone: '', assignedProjectId: 1, joinDate: '', status: '在岗' })
function getProjectName(id) {
const p = store.projects.find(p => p.id === id)
return p ? p.name : '-'
}
function openDialog(row) {
if (row) {
editingId.value = row.id
Object.assign(form, { ...row })
} else {
editingId.value = null
Object.assign(form, { name: '', phone: '', assignedProjectId: store.projects[0]?.id || 1, joinDate: new Date().toISOString().slice(0, 10), status: '在岗' })
}
dialogVisible.value = true
}
function submit() {
if (editingId.value) {
store.assignProject(editingId.value, form.assignedProjectId)
const w = store.workers.find(w => w.id === editingId.value)
if (w) { w.name = form.name; w.phone = form.phone; w.status = form.status }
} else {
store.addWorker({ ...form, joinDate: new Date().toISOString().slice(0, 10) })
}
dialogVisible.value = false
}
</script>

10
20260515/vite.config.js Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
open: true
}
})