This commit is contained in:
qwq
2026-06-04 11:01:29 +08:00
commit cccb578669
14509 changed files with 2636947 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+115
View File
@@ -0,0 +1,115 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
const api = axios.create({
baseURL: '/api',
timeout: 15000
})
// Request interceptor — attach token
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor — handle errors
api.interceptors.response.use(
response => response.data,
error => {
const msg = error.response?.data?.message || '请求失败'
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else {
ElMessage.error(msg)
}
return Promise.reject(error)
}
)
export default api
// Auth API
export const authApi = {
login: (data) => api.post('/auth/login', data),
me: () => api.get('/auth/me')
}
// Admin API
export const adminApi = {
getUsers: () => api.get('/admin/users'),
createUser: (data) => api.post('/admin/users', data),
updateUser: (id, data) => api.put(`/admin/users/${id}`, data),
deleteUser: (id) => api.delete(`/admin/users/${id}`),
getWorkdays: (year) => api.get('/admin/workdays', { params: { year } }),
setWorkdays: (data) => api.post('/admin/workdays', data),
getSalesStats: (params) => api.get('/admin/stats/sales', { params }),
getSalesDetail: (params) => api.get('/admin/stats/sales-detail', { params }),
getRdStats: (params) => api.get('/admin/stats/rd', { params }),
getProjectStats: (params) => api.get('/admin/stats/project', { params }),
getProjectDetail: (params) => api.get('/admin/stats/project-detail', { params }),
getSummaryMatrix: (params) => api.get('/admin/summary-matrix', { params }),
getSalesUsers: () => api.get('/admin/salesusers'),
getRdUsers: () => api.get('/admin/rdusers'),
getClients: () => api.get('/admin/clients'),
createClient: (data) => api.post('/admin/clients', data),
updateClient: (id, data) => api.put(`/admin/clients/${id}`, data),
deleteClient: (id) => api.delete(`/admin/clients/${id}`),
getProjectTypes: () => api.get('/admin/project-types'),
createProjectType: (data) => api.post('/admin/project-types', data),
deleteProjectType: (id) => api.delete(`/admin/project-types/${id}`),
// Data management
resetData: () => api.post('/admin/reset'),
getBackups: () => api.get('/admin/backups'),
restoreData: (data) => api.post('/admin/restore', data),
exportExcel: (type, params) => {
return axios.get(`/api/admin/export/${type}`, {
params,
responseType: 'blob',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
},
exportAllExcel: (params) => {
return axios.get(`/api/admin/export/all`, {
params,
responseType: 'blob',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
},
exportMatrixExcel: (params) => {
return axios.get(`/api/admin/export/matrix`, {
params,
responseType: 'blob',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
},
downloadBackup: () => {
return axios.get(`/api/admin/backup/download`, {
responseType: 'blob',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
},
downloadAutoBackup: (filename) => {
return axios.get(`/api/admin/backups/${filename}`, {
responseType: 'blob',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
}
}
// R&D API
export const rdApi = {
getClients: () => api.get('/rd/clients'),
getProjectTypes: () => api.get('/rd/project-types'),
getTimesheets: (params) => api.get('/rd/timesheets', { params }),
createTimesheet: (data) => api.post('/rd/timesheets', data),
updateTimesheet: (id, data) => api.put(`/rd/timesheets/${id}`, data),
deleteTimesheet: (id) => api.delete(`/rd/timesheets/${id}`),
getStats: (params) => api.get('/rd/stats', { params })
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
import './styles/index.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+102
View File
@@ -0,0 +1,102 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { public: true }
},
{
path: '/',
component: () => import('../views/Layout.vue'),
redirect: '/dashboard',
children: [
// Admin routes
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/admin/Dashboard.vue'),
meta: { roles: ['admin'] }
},
{
path: 'users',
name: 'UserManage',
component: () => import('../views/admin/UserManage.vue'),
meta: { roles: ['admin'] }
},
{
path: 'workdays',
name: 'WorkdaySetting',
component: () => import('../views/admin/WorkdaySetting.vue'),
meta: { roles: ['admin'] }
},
{
path: 'clients',
name: 'ClientManage',
component: () => import('../views/admin/ClientManage.vue'),
meta: { roles: ['admin'] }
},
{
path: 'data-manage',
name: 'DataManage',
component: () => import('../views/admin/DataManage.vue'),
meta: { roles: ['admin'] }
},
// R&D routes
{
path: 'timesheet',
name: 'TimesheetForm',
component: () => import('../views/rd/TimesheetForm.vue'),
meta: { roles: ['rd'] }
},
{
path: 'rd-stats',
name: 'RdStats',
component: () => import('../views/rd/RdStats.vue'),
meta: { roles: ['rd'] }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Navigation guard
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
const user = JSON.parse(localStorage.getItem('user') || 'null')
if (to.meta.public) {
if (token && to.path === '/login') {
// Already logged in, redirect to appropriate home
return next(getHomeRoute(user?.role))
}
return next()
}
if (!token) {
return next('/login')
}
// Role check
if (to.meta.roles && !to.meta.roles.includes(user?.role)) {
return next(getHomeRoute(user?.role))
}
next()
})
function getHomeRoute(role) {
switch (role) {
case 'admin': return '/dashboard'
case 'rd': return '/timesheet'
default: return '/login'
}
}
export { getHomeRoute }
export default router
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const token = ref(localStorage.getItem('token') || '')
function setLogin(tokenVal, userVal) {
token.value = tokenVal
user.value = userVal
localStorage.setItem('token', tokenVal)
localStorage.setItem('user', JSON.stringify(userVal))
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
const isLoggedIn = () => !!token.value
const getRole = () => user.value?.role || ''
return { user, token, setLogin, logout, isLoggedIn, getRole }
})
+597
View File
@@ -0,0 +1,597 @@
/* ==============================
Global Design System
============================== */
:root {
--primary: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--bg-dark: #0f172a;
--bg-card: #1e293b;
--bg-sidebar: #111827;
--bg-hover: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #334155;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ==============================
Login Page
============================== */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
position: relative;
overflow: hidden;
}
.login-container::before {
content: '';
position: absolute;
width: 500px;
height: 500px;
border-radius: 50%;
background: radial-gradient(circle, rgba(99, 102, 241, 0.15) 0%, transparent 70%);
top: -100px;
right: -100px;
animation: float 8s ease-in-out infinite;
}
.login-container::after {
content: '';
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(16, 185, 129, 0.1) 0%, transparent 70%);
bottom: -100px;
left: -100px;
animation: float 6s ease-in-out infinite reverse;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-30px);
}
}
.login-card {
background: rgba(30, 41, 59, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 20px;
padding: 48px 40px;
width: 420px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 1;
}
.login-card h1 {
text-align: center;
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(135deg, #818cf8, #6366f1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-card .subtitle {
text-align: center;
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 36px;
}
/* ==============================
Layout
============================== */
.layout-container {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: var(--transition);
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, #818cf8, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sidebar-header .user-info {
margin-top: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-header .avatar {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
color: white;
}
.sidebar-header .user-detail {
flex: 1;
}
.sidebar-header .user-detail .name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.sidebar-header .user-detail .role {
font-size: 12px;
color: var(--text-muted);
}
.sidebar-nav {
flex: 1;
padding: 16px 12px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
margin-bottom: 4px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(99, 102, 241, 0.1));
color: var(--primary-light);
border: 1px solid rgba(99, 102, 241, 0.3);
}
.nav-item .icon {
font-size: 18px;
width: 24px;
text-align: center;
}
.sidebar-footer {
padding: 16px 12px;
border-top: 1px solid var(--border-color);
}
.logout-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-sm);
color: var(--danger);
cursor: pointer;
transition: var(--transition);
font-size: 14px;
font-weight: 500;
border: none;
background: none;
width: 100%;
text-align: left;
}
.logout-btn:hover {
background: rgba(239, 68, 68, 0.1);
}
.main-content {
flex: 1;
margin-left: 240px;
padding: 32px;
min-height: 100vh;
background: var(--bg-dark);
}
/* ==============================
Page Header
============================== */
.page-header {
margin-bottom: 28px;
}
.page-header h1 {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.page-header p {
font-size: 14px;
color: var(--text-muted);
}
/* ==============================
Cards
============================== */
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 24px;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--radius) var(--radius) 0 0;
}
.stat-card.purple::before {
background: linear-gradient(90deg, #6366f1, #8b5cf6);
}
.stat-card.green::before {
background: linear-gradient(90deg, #10b981, #34d399);
}
.stat-card.blue::before {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
}
.stat-card.orange::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
border-color: rgba(99, 102, 241, 0.3);
}
.stat-card .label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
}
.stat-card .unit {
font-size: 14px;
color: var(--text-secondary);
font-weight: 400;
margin-left: 4px;
}
/* ==============================
Content Card
============================== */
.content-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 24px;
}
.content-card .card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* ==============================
Element Plus Dark Override
============================== */
.el-table {
--el-table-bg-color: var(--bg-card) !important;
--el-table-tr-bg-color: var(--bg-card) !important;
--el-table-header-bg-color: rgba(51, 65, 85, 0.5) !important;
--el-table-row-hover-bg-color: var(--bg-hover) !important;
--el-table-text-color: var(--text-primary) !important;
--el-table-header-text-color: var(--text-secondary) !important;
--el-table-border-color: var(--border-color) !important;
--el-fill-color-lighter: var(--bg-hover) !important;
}
.el-dialog {
--el-dialog-bg-color: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius) !important;
}
.el-dialog__title {
color: var(--text-primary) !important;
}
.el-form-item__label {
color: var(--text-secondary) !important;
}
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner {
background-color: var(--bg-dark) !important;
box-shadow: 0 0 0 1px var(--border-color) inset !important;
color: var(--text-primary) !important;
}
.el-input__inner,
.el-textarea__inner {
color: var(--text-primary) !important;
}
.el-input__inner::placeholder {
color: var(--text-muted) !important;
}
.el-select-dropdown {
background-color: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
}
.el-select-dropdown__item {
color: var(--text-primary) !important;
}
.el-select-dropdown__item.is-hovering {
background-color: var(--bg-hover) !important;
}
.el-popper.is-light {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
}
/* Date Picker & Date Range Picker Dark Mode Comprehensive Fixes */
.el-picker-panel,
.el-date-picker,
.el-date-range-picker {
--el-datepicker-bg-color: var(--bg-card) !important;
--el-datepicker-text-color: var(--text-primary) !important;
--el-datepicker-off-text-color: var(--text-muted) !important;
--el-datepicker-header-text-color: var(--text-primary) !important;
--el-datepicker-icon-color: var(--text-secondary) !important;
--el-datepicker-border-color: var(--border-color) !important;
--el-datepicker-inner-border-color: var(--border-color) !important;
--el-datepicker-inrange-bg-color: var(--bg-hover) !important;
--el-datepicker-inrange-hover-bg-color: rgba(99, 102, 241, 0.2) !important;
--el-datepicker-active-color: var(--primary) !important;
--el-datepicker-hover-text-color: var(--primary-light) !important;
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
}
/* Explicitly fix text colors that variables might miss */
.el-date-table th {
color: var(--text-secondary) !important;
border-bottom: 1px solid var(--border-color) !important;
}
.el-date-table td .el-date-table-cell__text {
color: var(--text-primary) !important;
}
.el-date-table td.next-month .el-date-table-cell__text,
.el-date-table td.prev-month .el-date-table-cell__text {
color: var(--text-muted) !important;
}
/* Range Start and End */
.el-date-table td.start-date .el-date-table-cell__text,
.el-date-table td.end-date .el-date-table-cell__text,
.el-date-table td.current:not(.disabled) .el-date-table-cell__text {
background: var(--primary) !important;
color: #ffffff !important;
}
/* Today highlight */
.el-date-table td.today .el-date-table-cell__text {
color: var(--primary) !important;
font-weight: bold;
}
/* In-range background override */
.el-date-table td.in-range .el-date-table-cell {
background-color: var(--bg-hover) !important;
}
/* Disabled dates */
.el-date-table td.disabled .el-date-table-cell {
background-color: rgba(30, 41, 59, 0.4) !important;
}
.el-date-table td.disabled .el-date-table-cell__text {
background-color: transparent !important;
color: var(--text-muted) !important;
opacity: 0.6;
}
/* Fix Header Icons and Labels */
.el-picker-panel__icon-btn {
color: var(--text-primary) !important;
}
.el-date-picker__header-label {
color: var(--text-primary) !important;
font-weight: bold;
}
.el-date-range-picker__content.is-left {
border-right: 1px solid var(--border-color) !important;
}
.el-button--primary {
--el-button-bg-color: var(--primary) !important;
--el-button-border-color: var(--primary) !important;
--el-button-hover-bg-color: var(--primary-light) !important;
--el-button-hover-border-color: var(--primary-light) !important;
}
.el-pagination {
--el-pagination-bg-color: transparent !important;
--el-pagination-text-color: var(--text-secondary) !important;
--el-pagination-button-bg-color: var(--bg-hover) !important;
--el-pagination-hover-color: var(--primary) !important;
}
.el-tag {
border-radius: 6px !important;
}
.el-message-box {
--el-messagebox-bg-color: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
}
/* ==============================
Filter Bar
============================== */
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
/* ==============================
Chart Container
============================== */
.chart-container {
width: 100%;
height: 480px;
margin-top: 16px;
}
/* ==============================
El-Tabs Overflow Fix
============================== */
.el-tabs {
overflow: visible !important;
}
.el-tabs__content {
overflow: visible !important;
}
.el-tab-pane {
overflow: visible !important;
}
.el-tabs--border-card {
overflow: visible !important;
}
/* ==============================
Animations
============================== */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
transform: translateX(20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(-20px);
opacity: 0;
}
+77
View File
@@ -0,0 +1,77 @@
<template>
<div class="layout-container">
<aside class="sidebar">
<div class="sidebar-header">
<h2> 工时管理</h2>
<div class="user-info">
<div class="avatar">{{ user?.realname?.charAt(0) }}</div>
<div class="user-detail">
<div class="name">{{ user?.realname }}</div>
<div class="role">{{ roleLabel }}</div>
</div>
</div>
</div>
<nav class="sidebar-nav">
<router-link v-for="item in menuItems" :key="item.path" :to="item.path"
class="nav-item" :class="{ active: $route.path === item.path }">
<span class="icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="sidebar-footer">
<button class="logout-btn" @click="handleLogout">
<span class="icon">🚪</span>
<span>退出登录</span>
</button>
</div>
</aside>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
const router = useRouter()
const userStore = useUserStore()
const user = computed(() => userStore.user)
const roleLabel = computed(() => {
const map = { admin: '管理员', sales: '销售', rd: '研发' }
return map[user.value?.role] || ''
})
const menuItems = computed(() => {
const role = user.value?.role
if (role === 'admin') {
return [
{ path: '/dashboard', label: '统计看板', icon: '📊' },
{ path: '/clients', label: '客户/项目管理', icon: '🏢' },
{ path: '/users', label: '账号管理', icon: '👥' },
{ path: '/workdays', label: '工作日设置', icon: '📅' },
{ path: '/data-manage', label: '数据管理', icon: '🗄️' }
]
}
if (role === 'rd') {
return [
{ path: '/timesheet', label: '工时填报', icon: '⏰' },
{ path: '/rd-stats', label: '个人统计', icon: '📊' }
]
}
return []
})
function handleLogout() {
userStore.logout()
router.push('/login')
}
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="login-container">
<div class="login-card">
<h1> 工时管理系统</h1>
<p class="subtitle">跨部门工时管理与项目协同平台</p>
<el-form :model="form" :rules="rules" ref="formRef" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" placeholder="请输入密码" type="password" show-password prefix-icon="Lock" size="large" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" style="width: 100%; height: 48px; font-size: 16px; border-radius: 10px;">
</el-button>
</el-form-item>
</el-form>
<div style="text-align: center; color: var(--text-muted); font-size: 12px; margin-top: 16px;">
默认管理员账号: admin / admin123
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { authApi } from '../api'
import { useUserStore } from '../stores/user'
import { getHomeRoute } from '../router'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const res = await authApi.login(form)
userStore.setLogin(res.data.token, res.data.user)
ElMessage.success(`欢迎回来,${res.data.user.realname}`)
router.push(getHomeRoute(res.data.user.role))
} catch (e) {
// error handled by interceptor
} finally {
loading.value = false
}
}
</script>
+223
View File
@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h1>客户/项目管理</h1>
<p>管理系统中的客户与项目并指定所属销售</p>
</div>
<div class="content-card">
<div class="card-title">
<span>全部客户/项目 ({{ clients.length }})</span>
<el-button type="primary" @click="showDialog()">+ 新增客户/项目</el-button>
</div>
<el-table :data="clients" stripe v-loading="loading">
<el-table-column prop="name" label="客户/项目名称" min-width="160" />
<el-table-column prop="sales_name" label="所属销售" min-width="100" />
<el-table-column label="子项目预设" min-width="200">
<template #default="{ row }">
<template v-if="row.service_types && row.service_types.length">
<el-tag v-for="st in row.service_types" :key="st" size="small" style="margin: 2px;">{{ st }}</el-tag>
</template>
<span v-else style="color: var(--text-muted); font-size: 12px;">未设置</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="120" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" effect="dark" round>
{{ row.status === 1 ? '进行中' : '已结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_hours" label="总工时(h)" width="100" />
<el-table-column prop="rd_count" label="参与研发" width="90" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="showDialog(row)">编辑</el-button>
<el-button size="small" :type="row.status === 1 ? 'warning' : 'success'"
@click="toggleStatus(row)">{{ row.status === 1 ? '结算' : '激活' }}</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Add/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑客户/项目' : '新增客户/项目'" width="500px" destroy-on-close>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="客户/项目名称" />
</el-form-item>
<el-form-item label="所属销售" prop="sales_id">
<el-select v-model="form.sales_id" placeholder="请选择销售人员" style="width: 100%" filterable>
<el-option v-for="user in salesUsers" :key="user.id" :label="user.realname" :value="user.id" />
</el-select>
</el-form-item>
<el-form-item label="子项目">
<div class="tag-editor">
<el-tag
v-for="(st, idx) in form.service_types"
:key="idx"
closable
effect="dark"
size="default"
class="st-tag"
@close="removeServiceType(idx)"
>{{ st }}</el-tag>
<template v-if="!addingST">
<el-button size="small" class="add-st-btn" @click="startAddST"> 添加</el-button>
</template>
<template v-else>
<el-input
ref="stInputRef"
v-model="newST"
size="small"
class="st-input"
placeholder="类型名,回车确认"
@keyup.enter="confirmST"
@blur="confirmST"
maxlength="20"
/>
</template>
</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 6px;">
研发填报该项目工时时可从这里选择子项目
</div>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="项目描述(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const clients = ref([])
const salesUsers = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const formRef = ref(null)
// Service type inline tag editor
const addingST = ref(false)
const newST = ref('')
const stInputRef = ref(null)
const form = reactive({ name: '', description: '', sales_id: '', service_types: [] })
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
sales_id: [{ required: true, message: '请选择所属销售', trigger: 'change' }]
}
async function loadData() {
loading.value = true
try {
const [clientsRes, salesRes] = await Promise.all([
adminApi.getClients(),
adminApi.getSalesUsers()
])
clients.value = clientsRes.data
salesUsers.value = salesRes.data
} finally { loading.value = false }
}
function showDialog(row) {
editingId.value = row?.id || null
form.name = row?.name || ''
form.description = row?.description || ''
form.sales_id = row?.sales_id || ''
form.service_types = row?.service_types ? [...row.service_types] : []
addingST.value = false
newST.value = ''
dialogVisible.value = true
}
// Tag editor helpers
async function startAddST() {
addingST.value = true
newST.value = ''
await nextTick()
stInputRef.value?.focus()
}
function confirmST() {
const v = newST.value.trim()
if (v && !form.service_types.includes(v)) form.service_types.push(v)
addingST.value = false
newST.value = ''
}
function removeServiceType(idx) {
form.service_types.splice(idx, 1)
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = {
name: form.name,
description: form.description,
sales_id: form.sales_id,
service_types: form.service_types
}
if (editingId.value) {
await adminApi.updateClient(editingId.value, payload)
ElMessage.success('更新成功')
} else {
await adminApi.createClient(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally { saving.value = false }
}
async function toggleStatus(row) {
await adminApi.updateClient(row.id, { status: row.status === 1 ? 0 : 1 })
ElMessage.success('状态已更新')
loadData()
}
async function handleDelete(row) {
await ElMessageBox.confirm(
`确定删除项目「${row.name}」吗?如已有工时记录则无法删除,建议使用“结算”操作。`,
'确认删除', { type: 'warning' }
)
await adminApi.deleteClient(row.id)
ElMessage.success('删除成功')
loadData()
}
onMounted(loadData)
</script>
<style scoped>
.tag-editor {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-height: 36px;
padding: 6px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(0,0,0,0.1);
}
.st-tag { border-radius: 16px; }
.add-st-btn {
border-radius: 16px;
border-style: dashed;
}
.st-input { width: 140px; }
</style>
+736
View File
@@ -0,0 +1,736 @@
<template>
<div class="dashboard-page">
<div class="page-header" style="display: flex; justify-content: space-between; align-items: flex-end;">
<div>
<h1>全局统计看板</h1>
<p>多维度查看工时数据销售视角 / 研发视角 / 项目视角</p>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<el-date-picker v-model="allExportDateRange" type="daterange" range-separator="至"
start-placeholder="全部时间起" end-placeholder="全部时间止" value-format="YYYY-MM-DD" size="large" />
<el-button type="success" @click="exportData('all')" :loading="exportingAll" size="large">
📊 总导出 (All in One)
</el-button>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="activeTab" type="border-card" class="dashboard-tabs">
<!-- ==================== Sales Perspective ==================== -->
<el-tab-pane label="📈 销售视角" name="sales">
<div class="filter-bar">
<el-date-picker v-model="salesFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
<el-button type="primary" @click="loadSalesData">查询</el-button>
<el-button @click="exportData('sales')">📥 导出该页</el-button>
</div>
<div class="two-col-layout">
<!-- Sales Table -->
<div class="table-col">
<div class="section-title">📋 销售人员汇总 (点击展开查看研发明细)</div>
<el-table :data="salesSummary" stripe v-loading="salesLoading" style="width: 100%" row-key="sales_name">
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: rgba(0,0,0,0.2);">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--text-secondary);">
[ {{ props.row.sales_name }} ] 的研发工时消耗明细
</div>
<el-table :data="props.row.rdList" border style="width: 100%" size="small">
<el-table-column prop="rd_name" label="研发人员" width="150" />
<el-table-column prop="total_hours" label="消耗工时(h)" width="120" />
<el-table-column prop="work_days" label="工作天数" width="120" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="sales_name" label="销售人员" min-width="140" />
<el-table-column prop="project_count" label="项目数" min-width="100" />
<el-table-column prop="rd_count" label="涉及研发" min-width="120" />
<el-table-column prop="total_hours" label="总消耗工时(h)" min-width="130" sortable />
</el-table>
</div>
<!-- Sales Charts -->
<div class="chart-col">
<div class="chart-card">
<div class="chart-title">销售占用工时占比饼图</div>
<div ref="salesChartRef" class="big-chart"></div>
</div>
<div class="chart-card" style="margin-top: 16px">
<div class="chart-title">各销售工时对比柱状图</div>
<div ref="salesBarRef" class="big-chart"></div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== R&D Perspective ==================== -->
<el-tab-pane label="👨‍💻 研发视角" name="rd">
<div class="filter-bar">
<el-date-picker v-model="rdFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
<el-select v-model="rdFilter.user_id" placeholder="全部研发" clearable size="default" style="width: 150px">
<el-option v-for="u in rdUsers" :key="u.id" :label="u.realname" :value="u.id" />
</el-select>
<el-button type="primary" @click="loadRdData">查询</el-button>
<el-button @click="exportData('rd')">📥 导出该页</el-button>
</div>
<div class="two-col-layout">
<!-- R&D Table -->
<div class="table-col">
<div class="section-title">📋 研发人员填报明细</div>
<el-table :data="rdData" stripe v-loading="rdLoading" style="width: 100%">
<el-table-column prop="user_name" label="研发人员" min-width="120" />
<el-table-column prop="client_name" label="客户/项目" min-width="180" />
<el-table-column prop="work_date" label="日期" min-width="120" />
<el-table-column prop="hours" label="工时(h)" min-width="100" />
<el-table-column prop="remark" label="备注" min-width="180" />
</el-table>
</div>
<!-- R&D Charts -->
<div class="chart-col">
<div class="chart-card">
<div class="chart-title">研发人员工时产出占比饼图</div>
<div ref="rdChartRef" class="big-chart"></div>
</div>
<div class="chart-card" style="margin-top: 16px">
<div class="chart-title">各研发人员工时对比柱状图</div>
<div ref="rdBarRef" class="big-chart"></div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== Project Perspective ==================== -->
<el-tab-pane label="🏢 项目视角" name="project">
<div class="filter-bar">
<el-date-picker v-model="projFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" style="margin-right: 12px;" />
<el-select v-model="projFilter.sales_id" clearable placeholder="全部销售" style="width: 140px; margin-right: 12px;">
<el-option v-for="u in salesUsers" :key="u.id" :label="u.realname" :value="u.id" />
</el-select>
<el-button type="primary" @click="loadProjectData">查询</el-button>
<el-button @click="exportData('project')">📥 导出该页</el-button>
</div>
<div class="two-col-layout">
<!-- Project Table -->
<div class="table-col">
<div class="section-title">📋 项目总览 (点击展开查看研发明细)</div>
<el-table :data="projectData" stripe v-loading="projLoading" style="width: 100%" row-key="client_id">
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: rgba(0,0,0,0.2);">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--text-secondary);">
[ {{ props.row.client_name }} ] 的参与研发明细
</div>
<el-table :data="props.row.rdList" border style="width: 100%" size="small">
<el-table-column prop="rd_name" label="研发人员" width="150" />
<el-table-column prop="total_hours" label="消耗工时(h)" width="120" sortable />
<el-table-column prop="work_days" label="工作天数" width="120" />
<el-table-column prop="first_date" label="首次介入" width="120" />
<el-table-column prop="last_date" label="最近介入" width="120" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="client_name" label="客户/项目" min-width="200" />
<el-table-column prop="sales_name" label="所属销售" min-width="120" />
<el-table-column prop="client_status" label="状态" min-width="90">
<template #default="{ row }">
<el-tag :type="row.client_status === 1 ? 'success' : 'info'" effect="dark" round>
{{ row.client_status === 1 ? '进行中' : '已结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_hours" label="总工时(h)" min-width="110" sortable />
<el-table-column prop="rd_count" label="研发人数" min-width="100" />
</el-table>
</div>
<!-- Project Charts -->
<div class="chart-col">
<div class="chart-card">
<div class="chart-title">项目工时分布饼图</div>
<div ref="projectChartRef" class="big-chart"></div>
</div>
<div class="chart-card" style="margin-top: 16px">
<div class="chart-title">各项目工时对比柱状图</div>
<div ref="projectBarRef" class="big-chart"></div>
</div>
</div>
</div>
</el-tab-pane>
<!-- ==================== Matrix Summary ==================== -->
<el-tab-pane label="📊 研发工时汇总" name="matrix">
<div class="filter-bar">
<el-date-picker v-model="matrixFilter.dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
<el-button type="primary" @click="loadMatrixData">查询</el-button>
<el-button @click="exportMatrix">📥 导出该页</el-button>
</div>
<div class="content-card" style="margin-top: 16px; overflow-x: auto;" v-loading="matrixLoading">
<table class="matrix-table" v-if="matrixRows.length">
<thead>
<tr>
<th class="row-header">研发人员</th>
<th v-for="col in matrixCols" :key="col">{{ col }}</th>
<th class="total-col">合计</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in matrixRows" :key="ri" :class="{ 'total-row': ri === matrixRows.length - 1 }">
<td class="row-header">{{ row.label }}</td>
<td v-for="col in matrixCols" :key="col">{{ row.cells[col] || '-' }}</td>
<td class="total-col">{{ row.total }}</td>
</tr>
</tbody>
</table>
<el-empty v-else description="暂无工时数据" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, watch, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { adminApi } from '../../api'
import * as echarts from 'echarts'
const activeTab = ref('sales')
const salesUsers = ref([])
const rdUsers = ref([])
const salesChartRef = ref(null)
const rdChartRef = ref(null)
const projectChartRef = ref(null)
const salesBarRef = ref(null)
const rdBarRef = ref(null)
const projectBarRef = ref(null)
let salesChartInstance = null
let rdChartInstance = null
let projectChartInstance = null
let salesBarInstance = null
let rdBarInstance = null
let projectBarInstance = null
const exportingAll = ref(false)
const getRecentMonthRange = () => {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 1)
const pad = (n) => String(n).padStart(2, '0')
const format = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return [format(start), format(end)]
}
const defaultRange = getRecentMonthRange()
const allExportDateRange = ref(defaultRange)
// ====== Sales ======
const salesFilter = reactive({ dateRange: defaultRange })
const salesRawData = ref([])
const salesDetailData = ref([])
const salesLoading = ref(false)
const salesSummary = computed(() => {
const map = {}
// Aggregate detail row by rd_id into the sales object
salesDetailData.value.forEach(row => {
if (!map[row.sales_id]) {
map[row.sales_id] = {
sales_id: row.sales_id,
sales_name: row.sales_name,
projects: new Set(),
rdMap: {},
total_hours: 0
}
}
const sales = map[row.sales_id]
if (!sales.rdMap[row.rd_id]) {
sales.rdMap[row.rd_id] = { rd_name: row.rd_name, total_hours: 0, work_days: 0 }
}
sales.rdMap[row.rd_id].total_hours += row.total_hours
sales.rdMap[row.rd_id].work_days += row.work_days
})
// Add project counts & total hours from raw overviews to base object
salesRawData.value.forEach(row => {
if (!map[row.sales_id]) {
map[row.sales_id] = {
sales_id: row.sales_id,
sales_name: row.sales_name,
projects: new Set(),
rdMap: {},
total_hours: 0
}
}
map[row.sales_id].projects.add(row.client_id)
map[row.sales_id].total_hours += row.total_hours
})
return Object.values(map).map(m => {
return {
sales_name: m.sales_name,
project_count: m.projects.size,
rd_count: Object.keys(m.rdMap).length,
total_hours: Math.round(m.total_hours * 10) / 10,
rdList: Object.values(m.rdMap).sort((a,b)=> b.total_hours - a.total_hours)
}
})
})
async function loadSalesData() {
salesLoading.value = true
try {
const params = {}
if (salesFilter.dateRange) {
params.start_date = salesFilter.dateRange[0]
params.end_date = salesFilter.dateRange[1]
}
const [overview, detail] = await Promise.all([
adminApi.getSalesStats(params),
adminApi.getSalesDetail(params)
])
salesRawData.value = overview.data
salesDetailData.value = detail.data
await nextTick()
const pieData = salesSummary.value.filter(s => s.total_hours > 0).map(s => ({name: s.sales_name, value: s.total_hours}))
renderPieChart(salesChartRef, salesChartInstance, 'sales', pieData)
const salesBarData = salesSummary.value.filter(s => s.total_hours > 0)
renderBarChart(salesBarRef, salesBarInstance, 'sales',
salesBarData.map(s => s.sales_name),
salesBarData.map(s => s.total_hours)
)
} finally { salesLoading.value = false }
}
// ====== R&D ======
const rdFilter = reactive({ dateRange: defaultRange, user_id: '' })
const rdData = ref([])
const rdLoading = ref(false)
const rdSummary = computed(() => {
const map = {}
rdData.value.forEach(row => {
if (!map[row.user_id]) {
map[row.user_id] = { name: row.user_name, value: 0 }
}
map[row.user_id].value += row.hours
})
return Object.values(map).filter(r => r.value > 0)
})
async function loadRdData() {
rdLoading.value = true
try {
const params = {}
if (rdFilter.dateRange) {
params.start_date = rdFilter.dateRange[0]
params.end_date = rdFilter.dateRange[1]
}
if (rdFilter.user_id) params.user_id = rdFilter.user_id
const res = await adminApi.getRdStats(params)
rdData.value = res.data
await nextTick()
renderPieChart(rdChartRef, rdChartInstance, 'rd', rdSummary.value)
renderBarChart(rdBarRef, rdBarInstance, 'rd',
rdSummary.value.map(r => r.name),
rdSummary.value.map(r => r.value)
)
} finally { rdLoading.value = false }
}
// ====== Project ======
const projFilter = reactive({ dateRange: defaultRange, sales_id: '' })
const projectData = ref([])
const projLoading = ref(false)
const projectDetailData = ref([])
async function loadProjectData() {
projLoading.value = true
try {
const params = {}
if (projFilter.dateRange) {
params.start_date = projFilter.dateRange[0]
params.end_date = projFilter.dateRange[1]
}
if (projFilter.sales_id) params.sales_id = projFilter.sales_id
const [overview, detail] = await Promise.all([
adminApi.getProjectStats(params),
adminApi.getProjectDetail(params)
])
// Group details by project
const detailMap = {}
detail.data.forEach(row => {
if (!detailMap[row.client_id]) detailMap[row.client_id] = []
detailMap[row.client_id].push(row)
})
// Attach rdList to each project
projectData.value = overview.data.map(p => {
p.rdList = detailMap[p.client_id] || []
return p
})
await nextTick()
const projPieData = projectData.value.filter(p => p.total_hours > 0).map(p => ({name: p.client_name, value: p.total_hours}))
renderPieChart(projectChartRef, projectChartInstance, 'project', projPieData)
const projBarData = projectData.value.filter(p => p.total_hours > 0)
renderBarChart(projectBarRef, projectBarInstance, 'project',
projBarData.map(p => p.client_name),
projBarData.map(p => p.total_hours)
)
} finally { projLoading.value = false }
}
// ====== Charts ======
function renderPieChart(domRef, instanceRef, type, dataArray) {
if (!domRef.value || domRef.value.clientWidth === 0) return
if (instanceRef) instanceRef.dispose()
let inst = echarts.init(domRef.value, 'dark')
if (type === 'sales') salesChartInstance = inst
if (type === 'rd') rdChartInstance = inst
if (type === 'project') projectChartInstance = inst
inst.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c}h ({d}%)', confine: true },
legend: { type: 'scroll', bottom: 10, textStyle: { color: '#94a3b8' } },
series: [{
type: 'pie', radius: ['35%', '65%'], center: ['50%', '45%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 8, borderColor: '#1e293b', borderWidth: 2 },
label: { color: '#f1f5f9', fontSize: 13 },
emphasis: {
label: { show: true, fontSize: 16, fontWeight: 'bold' },
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' }
},
data: dataArray
}]
})
}
function renderBarChart(domRef, instanceRef, type, categories, values) {
if (!domRef.value || domRef.value.clientWidth === 0) return
if (instanceRef) instanceRef.dispose()
let inst = echarts.init(domRef.value, 'dark')
if (type === 'sales') salesBarInstance = inst
if (type === 'rd') rdBarInstance = inst
if (type === 'project') projectBarInstance = inst
inst.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].name}: ${p[0].value}h`, confine: true },
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
xAxis: {
type: 'category', data: categories,
axisLabel: { color: '#94a3b8', interval: 0, rotate: categories.length > 5 ? 30 : 0, fontSize: 12 }
},
yAxis: { type: 'value', name: '工时(h)', axisLabel: { color: '#94a3b8' } },
series: [{
type: 'bar', barMaxWidth: 50, data: values,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#818cf8' }, { offset: 1, color: '#4f46e5' }
]),
borderRadius: [4, 4, 0, 0]
},
label: { show: true, position: 'top', color: '#f1f5f9', fontSize: 12, formatter: '{c}h' },
markLine: type === 'rd' && values.length > 0 ? {
data: [{ type: 'average', name: '平均值' }],
lineStyle: { type: 'dashed', color: '#f59e0b', width: 2 },
label: { formatter: '平均: {c}h', position: 'end', color: '#f59e0b', fontWeight: 'bold' }
} : undefined
}]
})
}
function handleResize() {
salesChartInstance?.resize()
rdChartInstance?.resize()
projectChartInstance?.resize()
salesBarInstance?.resize()
rdBarInstance?.resize()
projectBarInstance?.resize()
}
// ====== Export ======
async function exportData(type) {
try {
if (type === 'all') {
exportingAll.value = true
let params = {}
let start = '开始', end = '至今'
if (allExportDateRange.value && allExportDateRange.value.length === 2) {
params.start_date = allExportDateRange.value[0]
params.end_date = allExportDateRange.value[1]
start = params.start_date
end = params.end_date
}
const response = await adminApi.exportAllExcel(params)
downloadBlob(response.data, `总报表_${start}${end}.xlsx`)
ElMessage.success('全导成功')
exportingAll.value = false
return
}
let params = {}
let start = '开始', end = '至今', prefix = ''
if (type === 'sales') {
prefix = '销售视角'
if (salesFilter.dateRange && salesFilter.dateRange.length === 2) {
params.start_date = salesFilter.dateRange[0]; params.end_date = salesFilter.dateRange[1];
start = params.start_date; end = params.end_date;
}
} else if (type === 'rd') {
prefix = '研发视角'
if (rdFilter.dateRange && rdFilter.dateRange.length === 2) {
params.start_date = rdFilter.dateRange[0]; params.end_date = rdFilter.dateRange[1];
start = params.start_date; end = params.end_date;
}
} else if (type === 'project') {
prefix = '项目视角'
if (projFilter.dateRange && projFilter.dateRange.length === 2) {
params.start_date = projFilter.dateRange[0]; params.end_date = projFilter.dateRange[1];
start = params.start_date; end = params.end_date;
}
if (projFilter.sales_id) params.sales_id = projFilter.sales_id;
}
const response = await adminApi.exportExcel(type, params)
downloadBlob(response.data, `${prefix}_${start}${end}.xlsx`)
ElMessage.success('导出成功')
} catch (e) {
exportingAll.value = false
ElMessage.error('导出失败')
}
}
function downloadBlob(data, filename) {
const url = URL.createObjectURL(new Blob([data]))
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
watch(activeTab, (tab) => {
nextTick(() => {
if (tab === 'sales') {
const filtered = salesSummary.value.filter(s => s.total_hours > 0)
renderPieChart(salesChartRef, salesChartInstance, 'sales', filtered.map(s => ({name: s.sales_name, value: s.total_hours})))
renderBarChart(salesBarRef, salesBarInstance, 'sales', filtered.map(s => s.sales_name), filtered.map(s => s.total_hours))
} else if (tab === 'rd') {
renderPieChart(rdChartRef, rdChartInstance, 'rd', rdSummary.value)
renderBarChart(rdBarRef, rdBarInstance, 'rd', rdSummary.value.map(r => r.name), rdSummary.value.map(r => r.value))
} else if (tab === 'project') {
const filtered = projectData.value.filter(p => p.total_hours > 0)
renderPieChart(projectChartRef, projectChartInstance, 'project', filtered.map(p => ({name: p.client_name, value: p.total_hours})))
renderBarChart(projectBarRef, projectBarInstance, 'project', filtered.map(p => p.client_name), filtered.map(p => p.total_hours))
} else if (tab === 'matrix') {
loadMatrixData()
}
})
})
onMounted(async () => {
window.addEventListener('resize', handleResize)
try {
const [s, r] = await Promise.all([adminApi.getSalesUsers(), adminApi.getRdUsers()])
salesUsers.value = s.data
rdUsers.value = r.data
} catch (e) {}
loadSalesData()
loadRdData()
loadProjectData()
loadMatrixData()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
salesChartInstance?.dispose()
rdChartInstance?.dispose()
projectChartInstance?.dispose()
salesBarInstance?.dispose()
rdBarInstance?.dispose()
projectBarInstance?.dispose()
})
// ====== Matrix (研发工时汇总) ======
const matrixFilter = reactive({ dateRange: defaultRange })
const matrixLoading = ref(false)
const matrixCols = ref([]) // 项目-子项目 labels
const matrixRows = ref([]) // { label, cells:{colName:hours}, total }
async function loadMatrixData() {
matrixLoading.value = true
try {
const params = {}
if (matrixFilter.dateRange && matrixFilter.dateRange.length === 2) {
params.start_date = matrixFilter.dateRange[0]
params.end_date = matrixFilter.dateRange[1]
}
const res = await adminApi.getSummaryMatrix(params)
const raw = res.data
// Build cols = unique 项目-子项目 labels
const colSet = new Set(raw.map(r => r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name))
matrixCols.value = [...colSet].sort()
// Build row keys = 研发人员
const rowKeyMap = {}
raw.forEach(r => {
const ck = r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name
if (!rowKeyMap[r.user_name]) rowKeyMap[r.user_name] = {}
rowKeyMap[r.user_name][ck] = (rowKeyMap[r.user_name][ck] || 0) + r.hours
})
const dataRows = Object.entries(rowKeyMap).map(([label, cells]) => {
const total = Object.values(cells).reduce((s, v) => s + v, 0)
return { label, cells, total: Math.round(total * 10) / 10 }
})
// Totals row
const totalCells = {}
matrixCols.value.forEach(c => {
totalCells[c] = Math.round(dataRows.reduce((s, r) => s + (r.cells[c] || 0), 0) * 10) / 10
})
const grandTotal = Math.round(dataRows.reduce((s, r) => s + r.total, 0) * 10) / 10
dataRows.push({ label: '合计', cells: totalCells, total: grandTotal })
matrixRows.value = dataRows
} finally { matrixLoading.value = false }
}
async function exportMatrix() {
const params = {}
let start = '开始', end = '至今'
if (matrixFilter.dateRange && matrixFilter.dateRange.length === 2) {
params.start_date = matrixFilter.dateRange[0]
params.end_date = matrixFilter.dateRange[1]
start = params.start_date; end = params.end_date
}
const response = await adminApi.exportMatrixExcel(params)
downloadBlob(response.data, `研发工时汇总_${start}${end}.xlsx`)
ElMessage.success('导出成功')
}
</script>
<style scoped>
/* Tabs Overrides */
.dashboard-page :deep(.el-tabs--border-card) {
background: var(--bg-card) !important;
border-color: var(--border-color) !important;
border-radius: var(--radius) !important;
overflow: visible !important;
}
.dashboard-page :deep(.el-tabs__content) {
overflow: visible !important;
padding: 20px !important;
}
.dashboard-page :deep(.el-tab-pane) {
overflow: visible !important;
}
.dashboard-page :deep(.el-tabs__header) {
background: rgba(51, 65, 85, 0.3) !important;
border-bottom-color: var(--border-color) !important;
}
.dashboard-page :deep(.el-tabs__item) { color: var(--text-secondary) !important; }
.dashboard-page :deep(.el-tabs__item.is-active) {
color: var(--primary-light) !important;
background: var(--bg-card) !important;
}
.dashboard-page :deep(.el-tabs__item:hover) { color: var(--text-primary) !important; }
/* Table Overrides for Expandable Rows */
.dashboard-page :deep(.el-table__expanded-cell) {
background-color: rgba(0,0,0,0.1) !important;
padding: 0 !important;
}
.dashboard-page :deep(.el-table__expanded-cell:hover) {
background-color: rgba(0,0,0,0.1) !important;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.two-col-layout {
display: flex;
gap: 24px;
align-items: flex-start;
margin-top: 16px;
}
.table-col {
flex: 2;
min-width: 0;
}
.chart-col {
flex: 1;
min-width: 350px;
}
.chart-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 20px;
}
.chart-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
text-align: center;
}
.big-chart {
width: 100%;
height: 400px;
}
/* Matrix Table */
.matrix-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.matrix-table th, .matrix-table td {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: center;
white-space: nowrap;
}
.matrix-table thead th {
background: rgba(99, 102, 241, 0.15);
color: var(--primary-light);
font-weight: 600;
}
.matrix-table .row-header {
text-align: left;
font-weight: 500;
background: rgba(255,255,255,0.03);
min-width: 160px;
}
.matrix-table .total-col {
font-weight: 700;
color: #818cf8;
background: rgba(99,102,241,0.08);
}
.matrix-table .total-row td {
font-weight: 700;
background: rgba(99,102,241,0.12);
color: #c7d2fe;
}
.matrix-table tbody tr:hover td {
background: rgba(255,255,255,0.04);
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div>
<div class="page-header">
<h1>数据管理</h1>
<p>备份恢复与清除系统数据</p>
</div>
<!-- Backup -->
<div class="content-card">
<div class="card-title">💾 手动备份</div>
<p style="color: var(--text-muted); margin-bottom: 16px;">
将当前全部数据用户项目工时记录导出为 JSON 文件可用于恢复
</p>
<el-button type="primary" @click="handleBackup" :loading="backingUp"> 立即备份 / 下载</el-button>
</div>
<!-- Auto-backup list -->
<div class="content-card" style="margin-top: 20px;">
<div class="card-title">
<span>🕐 自动备份列表</span>
<el-button size="small" @click="loadBackups" :loading="loadingBackups">刷新</el-button>
</div>
<p style="color: var(--text-muted); font-size: 13px; margin-bottom: 14px;">
系统每天凌晨3点自动备份最多保留30天
</p>
<el-table :data="backups" stripe v-loading="loadingBackups" empty-text="暂无自动备份">
<el-table-column prop="name" label="文件名" min-width="220" />
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ (row.size / 1024).toFixed(1) }} KB</template>
</el-table-column>
<el-table-column label="备份时间" min-width="180">
<template #default="{ row }">{{ row.created_at?.slice(0, 19).replace('T', ' ') }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="downloadAutoBackup(row.name)">下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Restore -->
<div class="content-card" style="margin-top: 20px;">
<div class="card-title">📂 从备份文件恢复</div>
<p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px;">
上传一个 <code>.json</code> 备份文件系统将清除当前全部数据并从备份中恢复<br/>
此操作不可撤销请确认您已保留重要数据
</p>
<el-upload
accept=".json"
:auto-upload="false"
:show-file-list="true"
:limit="1"
:on-change="handleFileChange"
:on-exceed="() => ElMessage.warning('只能选择一个文件')"
>
<el-button>选择备份文件 (.json)</el-button>
</el-upload>
<el-button type="warning" style="margin-top: 12px" :loading="restoring"
:disabled="!restoreFile" @click="handleRestore">
🔄 开始恢复
</el-button>
</div>
<!-- Danger zone: Reset -->
<div class="content-card danger-card" style="margin-top: 20px;">
<div class="card-title" style="color: #f87171;">⚠️ 危险操作</div>
<p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px;">
清除所有工时记录与客户/项目数据,<strong>用户账号保留</strong>。此操作不可撤销。
</p>
<el-button type="danger" @click="handleReset" :loading="resetting">🗑 清除所有项目与工时数据</el-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const backingUp = ref(false)
const loadingBackups = ref(false)
const backups = ref([])
const restoring = ref(false)
const resetting = ref(false)
const restoreFile = ref(null)
async function handleBackup() {
backingUp.value = true
try {
const response = await adminApi.downloadBackup()
const date = new Date().toISOString().slice(0, 10)
downloadBlob(response.data, `backup-${date}.json`)
ElMessage.success('备份文件已下载')
} finally { backingUp.value = false }
}
async function loadBackups() {
loadingBackups.value = true
try {
const res = await adminApi.getBackups()
backups.value = res.data
} finally { loadingBackups.value = false }
}
async function downloadAutoBackup(filename) {
const response = await adminApi.downloadAutoBackup(filename)
downloadBlob(response.data, filename)
}
function handleFileChange(file) {
restoreFile.value = file.raw
}
async function handleRestore() {
if (!restoreFile.value) return
await ElMessageBox.confirm(
'恢复将清除当前所有数据,替换为备份内容。此操作不可撤销,确定继续吗?',
'确认恢复', { type: 'warning', confirmButtonText: '确认恢复', cancelButtonText: '取消' }
)
restoring.value = true
try {
const text = await restoreFile.value.text()
const data = JSON.parse(text)
const res = await adminApi.restoreData(data)
ElMessage.success(res.message || '恢复成功')
restoreFile.value = null
} catch (e) {
if (e?.response?.data?.message) ElMessage.error(e.response.data.message)
} finally { restoring.value = false }
}
async function handleReset() {
await ElMessageBox.confirm(
'此操作将永久删除所有工时记录和客户/项目数据,用户账号保留。确定吗?',
'二次确认', { type: 'error', confirmButtonText: '确认清除', cancelButtonText: '取消' }
)
await ElMessageBox.confirm(
'最后确认:所有数据将无法恢复,请确保已备份!',
'最终确认', { type: 'error', confirmButtonText: '我确认,执行清除', cancelButtonText: '取消' }
)
resetting.value = true
try {
const res = await adminApi.resetData()
ElMessage.success(res.message || '数据已清除')
} finally { resetting.value = false }
}
function downloadBlob(data, filename) {
const url = URL.createObjectURL(new Blob([data]))
const link = document.createElement('a')
link.href = url; link.download = filename; link.click()
URL.revokeObjectURL(url)
}
onMounted(loadBackups)
</script>
<style scoped>
.danger-card {
border: 1px solid rgba(248, 113, 113, 0.3) !important;
background: rgba(248, 113, 113, 0.04) !important;
}
code {
background: rgba(255,255,255,0.1);
padding: 1px 6px;
border-radius: 4px;
font-family: monospace;
}
</style>
@@ -0,0 +1,126 @@
<template>
<div>
<div class="page-header">
<h1>项目类型标签</h1>
<p>管理研发填报工时时可选用的项目类型标签</p>
</div>
<div class="content-card">
<div class="card-title">
<span>当前标签 ({{ tags.length }} )</span>
</div>
<div class="tags-area" v-loading="loading">
<el-tag
v-for="tag in tags"
:key="tag.id"
closable
effect="dark"
size="large"
class="tag-item"
@close="handleDelete(tag)"
>
{{ tag.name }}
</el-tag>
<template v-if="!addingNew">
<el-button class="add-tag-btn" size="small" @click="startAdd">+ 添加标签</el-button>
</template>
<template v-else>
<el-input
ref="inputRef"
v-model="newTagName"
class="new-tag-input"
size="small"
placeholder="输入标签名,回车确认"
@keyup.enter="confirmAdd"
@blur="confirmAdd"
maxlength="20"
show-word-limit
/>
</template>
</div>
<div style="margin-top: 16px; color: var(--text-muted); font-size: 13px;">
💡 点击标签右侧 × 可删除该标签删除后已填报的历史工时中标签名称不受影响
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const tags = ref([])
const loading = ref(false)
const addingNew = ref(false)
const newTagName = ref('')
const inputRef = ref(null)
async function loadTags() {
loading.value = true
try {
const res = await adminApi.getProjectTypes()
tags.value = res.data
} finally { loading.value = false }
}
async function startAdd() {
addingNew.value = true
newTagName.value = ''
await nextTick()
inputRef.value?.focus()
}
async function confirmAdd() {
const name = newTagName.value.trim()
if (!name) {
addingNew.value = false
return
}
try {
const res = await adminApi.createProjectType({ name })
tags.value.push(res.data)
ElMessage.success(`标签「${name}」添加成功`)
} catch (e) {
// error already shown by api interceptor
} finally {
addingNew.value = false
newTagName.value = ''
}
}
async function handleDelete(tag) {
await ElMessageBox.confirm(`确定删除标签「${tag.name}」吗?`, '确认删除', { type: 'warning' })
await adminApi.deleteProjectType(tag.id)
tags.value = tags.value.filter(t => t.id !== tag.id)
ElMessage.success('已删除')
}
onMounted(loadTags)
</script>
<style scoped>
.tags-area {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 16px 0 8px;
min-height: 60px;
}
.tag-item {
font-size: 14px;
border-radius: 20px;
padding: 6px 14px;
}
.add-tag-btn {
border-radius: 20px;
border-style: dashed;
}
.new-tag-input {
width: 160px;
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div>
<div class="page-header">
<h1>账号管理</h1>
<p>管理系统用户账号分配角色权限</p>
</div>
<div class="content-card">
<div class="card-title">
<span>用户列表</span>
<el-button type="primary" @click="showDialog()">+ 新增用户</el-button>
</div>
<el-table :data="users" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="realname" label="姓名" width="120" />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="roleType(row.role)" effect="dark" round>{{ roleLabel(row.role) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" round>
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" min-width="200">
<template #default="{ row }">
<el-button size="small" @click="showDialog(row)">编辑</el-button>
<el-button size="small" :type="row.status === 1 ? 'warning' : 'success'"
@click="toggleStatus(row)">{{ row.status === 1 ? '禁用' : '启用' }}</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑用户' : '新增用户'" width="460px" destroy-on-close>
<el-form :model="form" :rules="computedRules" ref="formRef" label-width="80px">
<el-form-item label="角色" prop="role">
<el-select v-model="form.role" placeholder="选择角色" style="width: 100%" @change="handleRoleChange">
<el-option label="管理员" value="admin" />
<el-option label="销售" value="sales" />
<el-option label="研发" value="rd" />
</el-select>
</el-form-item>
<el-form-item label="姓名" prop="realname">
<el-input v-model="form.realname" placeholder="真实姓名" />
</el-form-item>
<template v-if="form.role !== 'sales'">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="!!editingId" placeholder="登录用户名" />
</el-form-item>
<el-form-item label="密码" :prop="editingId ? '' : 'password'">
<el-input v-model="form.password" type="password" show-password
:placeholder="editingId ? '留空则不修改' : '请输入密码'" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const users = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({ username: '', password: '', realname: '', role: '' })
const computedRules = computed(() => {
return {
username: form.role === 'sales' ? [] : [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: form.role === 'sales' ? [] : [{ required: true, message: '请输入密码', trigger: 'blur' }],
realname: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
})
function handleRoleChange() {
if (formRef.value) formRef.value.clearValidate(['username', 'password'])
}
const roleLabel = (r) => ({ admin: '管理员', sales: '销售', rd: '研发' }[r] || r)
const roleType = (r) => ({ admin: 'danger', sales: 'warning', rd: '' }[r] || 'info')
async function loadUsers() {
loading.value = true
try {
const res = await adminApi.getUsers()
users.value = res.data
} finally { loading.value = false }
}
function showDialog(row) {
editingId.value = row?.id || null
form.username = row?.username || ''
form.password = ''
form.realname = row?.realname || ''
form.role = row?.role || ''
dialogVisible.value = true
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) {
const updateData = { realname: form.realname, role: form.role }
if (form.password) updateData.password = form.password
await adminApi.updateUser(editingId.value, updateData)
ElMessage.success('更新成功')
} else {
await adminApi.createUser(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadUsers()
} finally { saving.value = false }
}
async function toggleStatus(row) {
await adminApi.updateUser(row.id, { status: row.status === 1 ? 0 : 1 })
ElMessage.success('状态已更新')
loadUsers()
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除用户 "${row.realname}" 吗?`, '确认删除', { type: 'warning' })
await adminApi.deleteUser(row.id)
ElMessage.success('删除成功')
loadUsers()
}
onMounted(loadUsers)
</script>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div>
<div class="page-header">
<h1>工作日设置</h1>
<p>按年份设定每月工作日天数用于计算研发工时上限</p>
</div>
<div class="content-card">
<div class="card-title">
<div class="filter-bar" style="margin-bottom: 0">
<el-select v-model="selectedYear" @change="loadWorkdays" style="width: 120px">
<el-option v-for="y in years" :key="y" :label="`${y}年`" :value="y" />
</el-select>
</div>
<el-button type="primary" :loading="saving" @click="handleSave">保存设置</el-button>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 8px;">
<div v-for="(item, idx) in workdays" :key="idx"
class="stat-card" :class="['purple', 'green', 'blue', 'orange'][idx % 4]">
<div class="label">{{ item.month }}</div>
<div style="display: flex; align-items: center; gap: 8px;">
<el-input-number v-model="item.days" :min="0" :max="31" size="default"
style="width: 120px" />
<span class="unit"></span>
</div>
<div style="margin-top: 6px; font-size: 12px; color: var(--text-muted);">
工时上限: {{ item.days * 8 }} 小时
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { adminApi } from '../../api'
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 5 }, (_, i) => currentYear - 2 + i)
const selectedYear = ref(currentYear)
const saving = ref(false)
const workdays = ref(
Array.from({ length: 12 }, (_, i) => ({ month: i + 1, days: 22 }))
)
async function loadWorkdays() {
try {
const res = await adminApi.getWorkdays(selectedYear.value)
if (res.data.length > 0) {
workdays.value = Array.from({ length: 12 }, (_, i) => {
const found = res.data.find(w => w.month === i + 1)
return { month: i + 1, days: found ? found.days : 22 }
})
}
} catch (e) {}
}
async function handleSave() {
saving.value = true
try {
await adminApi.setWorkdays({
year: selectedYear.value,
months: workdays.value.map(w => ({ month: w.month, days: w.days }))
})
ElMessage.success('保存成功')
} finally { saving.value = false }
}
onMounted(loadWorkdays)
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div>
<div class="page-header">
<h1>个人统计</h1>
<p>查看您的工时分布与月度趋势</p>
</div>
<div class="stat-cards">
<div class="stat-card purple">
<div class="label">参与项目数</div>
<div class="value">{{ stats.projects?.length || 0 }}<span class="unit"></span></div>
</div>
<div class="stat-card green">
<div class="label">累计总工时</div>
<div class="value">{{ stats.total_hours || 0 }}<span class="unit">小时</span></div>
</div>
</div>
<div class="content-card">
<div class="card-title">
<span>各项目工时明细</span>
<div class="filter-bar" style="margin-bottom: 0;">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default"
@change="loadStats" />
</div>
</div>
<el-table :data="stats.projects || []" stripe>
<el-table-column prop="client_name" label="客户/项目" min-width="180" />
<el-table-column prop="total_hours" label="总工时(h)" width="120" />
<el-table-column prop="work_days" label="工作天数" width="110" />
<el-table-column prop="first_date" label="首次填报" width="130" />
<el-table-column prop="last_date" label="最近填报" width="130" />
</el-table>
</div>
<div class="content-card">
<div class="card-title">各项目工时对比</div>
<div ref="chartRef" class="chart-container"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { rdApi } from '../../api'
import * as echarts from 'echarts'
const stats = ref({})
const chartRef = ref(null)
let chartInstance = null
const getRecentMonthRange = () => {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 1)
const pad = (n) => String(n).padStart(2, '0')
const format = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return [format(start), format(end)]
}
const dateRange = ref(getRecentMonthRange())
async function loadStats() {
const params = {}
if (dateRange.value) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const res = await rdApi.getStats(params)
stats.value = res.data
await nextTick()
renderChart()
}
function renderChart() {
if (!chartRef.value) return
if (!chartInstance) chartInstance = echarts.init(chartRef.value, 'dark')
const projects = stats.value.projects || []
chartInstance.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: {
type: 'category',
data: projects.map(p => p.client_name),
axisLabel: { color: '#94a3b8', interval: 0, rotate: 30 }
},
yAxis: { type: 'value', name: '工时(h)', axisLabel: { color: '#94a3b8' } },
series: [{
type: 'bar',
barMaxWidth: 60,
data: projects.map(p => p.total_hours),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#6366f1' },
{ offset: 1, color: '#4338ca' }
]),
borderRadius: [4, 4, 0, 0]
}
}]
})
}
onMounted(loadStats)
</script>
+200
View File
@@ -0,0 +1,200 @@
<template>
<div>
<div class="page-header">
<h1>每日工时填报</h1>
<p>选择日期为参与的项目填写工时</p>
</div>
<div class="content-card">
<div class="card-title">
<div class="filter-bar" style="margin-bottom: 0">
<el-date-picker v-model="selectedDate" type="date" placeholder="选择日期"
value-format="YYYY-MM-DD" size="default" :disabled-date="disabledDate" @change="loadTimesheets" />
<el-tag type="info" effect="dark" round size="large" style="margin-left: 8px;">
当日已填: {{ dayTotal }} / 8 小时
</el-tag>
</div>
<el-button type="primary" @click="showAddDialog" :disabled="dayTotal >= 8">+ 添加工时</el-button>
</div>
<el-table :data="records" stripe v-loading="loading">
<el-table-column prop="client_name" label="客户/项目" min-width="150" />
<el-table-column prop="service_type" label="售前/售后" width="100">
<template #default="{ row }">
<el-tag :type="row.service_type === '售后' ? 'warning' : 'success'" size="small">{{ row.service_type || '售前' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="project_type" label="子项目" width="120" />
<el-table-column prop="hours" label="工时(h)" width="80" />
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Progress bar -->
<div style="margin-top: 16px;">
<el-progress :percentage="(dayTotal / 8) * 100" :color="dayTotal > 8 ? '#ef4444' : '#6366f1'"
:stroke-width="10" :format="() => `${dayTotal}/8h`" />
</div>
</div>
<!-- Add/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑工时' : '添加工时'" width="460px" destroy-on-close>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="项目" prop="client_id" v-if="!editingId">
<el-select v-model="form.client_id" placeholder="选择客户/项目" style="width: 100%" filterable>
<el-option v-for="c in clients" :key="c.id" :label="`${c.name} (${c.sales_name})`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="类型" prop="service_type">
<el-radio-group v-model="form.service_type">
<el-radio value="售前">售前</el-radio>
<el-radio value="售后">售后</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="子项目" prop="project_type">
<el-select v-model="form.project_type" style="width: 100%" filterable
:disabled="!projectTypeOptions.length && !form.project_type"
:placeholder="projectTypeOptions.length ? '请选择子项目' : '该项目未配置子项目'">
<el-option v-for="pt in projectTypeOptions" :key="pt" :label="pt" :value="pt" />
</el-select>
</el-form-item>
<el-form-item label="工时" prop="hours">
<el-input-number v-model="form.hours" :min="0.5" :max="8" :step="0.5" :precision="1" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="工作内容说明(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { rdApi } from '../../api'
const today = new Date().toISOString().split('T')[0]
const selectedDate = ref(today)
const records = ref([])
const dayTotal = ref(0)
const clients = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({ client_id: '', hours: 1, remark: '', service_type: '售前', project_type: '' })
const rules = {
client_id: [{ required: true, message: '请选择项目', trigger: 'change' }],
hours: [{ required: true, message: '请输入工时', trigger: 'blur' }],
project_type: [{ required: true, message: '请输入项目类型', trigger: 'blur' }]
}
const disabledDate = (time) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const minDate = new Date(today)
minDate.setDate(today.getDate() - 40)
return time.getTime() < minDate.getTime() || time.getTime() > today.getTime()
}
async function loadTimesheets() {
loading.value = true
try {
const res = await rdApi.getTimesheets({ date: selectedDate.value })
records.value = res.data.records
dayTotal.value = res.data.day_total
} finally { loading.value = false }
}
async function loadClients() {
const res = await rdApi.getClients()
clients.value = res.data
}
// Compute project type options from selected client's service_types
const projectTypeOptions = computed(() => {
if (!form.client_id) return []
const found = clients.value.find(c => c.id === form.client_id)
return found?.service_types || []
})
// When client changes, reset project_type if it's no longer valid
watch(() => form.client_id, () => {
if (!projectTypeOptions.value.includes(form.project_type)) {
form.project_type = projectTypeOptions.value[0] || ''
}
})
function showAddDialog() {
editingId.value = null
form.client_id = ''
form.hours = 1
form.remark = ''
form.service_type = '售前'
form.project_type = ''
dialogVisible.value = true
}
function showEditDialog(row) {
editingId.value = row.id
form.client_id = row.client_id
form.hours = row.hours
form.remark = row.remark || ''
form.service_type = row.service_type || '售前'
form.project_type = row.project_type || ''
dialogVisible.value = true
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) {
await rdApi.updateTimesheet(editingId.value, {
hours: form.hours,
remark: form.remark,
service_type: form.service_type,
project_type: form.project_type
})
ElMessage.success('更新成功')
} else {
await rdApi.createTimesheet({
client_id: form.client_id,
work_date: selectedDate.value,
hours: form.hours,
remark: form.remark,
service_type: form.service_type,
project_type: form.project_type
})
ElMessage.success('提交成功')
}
dialogVisible.value = false
loadTimesheets()
} finally { saving.value = false }
}
async function handleDelete(row) {
await ElMessageBox.confirm('确定删除该工时记录吗?', '确认', { type: 'warning' })
await rdApi.deleteTimesheet(row.id)
ElMessage.success('删除成功')
loadTimesheets()
}
onMounted(() => {
loadTimesheets()
loadClients()
})
</script>