first
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user