style: UI鍏ㄩ潰鍗囩骇涓洪涔﹂鏍?- 鐧藉簳钃濊壊涓昏壊璋?娓呯埥涓撲笟
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m22s
Build and Deploy Web / build-and-deploy (push) Successful in 51s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
seaislee1209 2026-02-12 15:07:43 +08:00
parent df9147a554
commit bc06725ed1
12 changed files with 764 additions and 382 deletions

View File

@ -1,8 +1,3 @@
<template> <template>
<router-view /> <router-view />
</template> </template>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; }
</style>

View File

@ -1,57 +1,53 @@
<template> <template>
<el-container class="layout"> <el-container class="layout">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<el-aside :width="isCollapsed ? '64px' : '220px'" class="aside"> <el-aside :width="isCollapsed ? '64px' : '240px'" class="aside">
<div class="logo" @click="isCollapsed = !isCollapsed"> <div class="logo" @click="isCollapsed = !isCollapsed">
<el-icon :size="24"><DataAnalysis /></el-icon> <div class="logo-icon">A</div>
<span v-show="!isCollapsed" class="logo-text">AirLabs</span> <transition name="fade">
<div v-show="!isCollapsed" class="logo-info">
<span class="logo-title">AirLabs</span>
<span class="logo-sub">项目管理系统</span>
</div>
</transition>
</div>
<nav class="nav-menu">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: isActive(item.path) }"
v-show="!item.role || hasRole(item.role)"
>
<el-icon :size="18"><component :is="item.icon" /></el-icon>
<span v-show="!isCollapsed" class="nav-label">{{ item.label }}</span>
</router-link>
</nav>
<!-- 底部用户信息 -->
<div class="sidebar-footer" v-show="!isCollapsed">
<div class="user-brief">
<div class="user-avatar">{{ authStore.user?.name?.[0] || '?' }}</div>
<div class="user-meta">
<div class="user-name">{{ authStore.user?.name }}</div>
<div class="user-role">{{ authStore.user?.role }}</div>
</div>
</div>
</div> </div>
<el-menu
:default-active="$route.path"
:collapse="isCollapsed"
router
background-color="#1d1e2c"
text-color="#a0a3bd"
active-text-color="#ffffff"
class="side-menu"
>
<el-menu-item v-if="authStore.isOwner()" index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/projects">
<el-icon><FolderOpened /></el-icon>
<span>项目管理</span>
</el-menu-item>
<el-menu-item index="/submissions">
<el-icon><EditPen /></el-icon>
<span>内容提交</span>
</el-menu-item>
<el-menu-item v-if="authStore.isLeaderOrAbove()" index="/costs">
<el-icon><Money /></el-icon>
<span>成本管理</span>
</el-menu-item>
<el-menu-item v-if="authStore.isOwner()" index="/users">
<el-icon><User /></el-icon>
<span>用户管理</span>
</el-menu-item>
</el-menu>
</el-aside> </el-aside>
<!-- 主内容区 --> <!-- 主内容区 -->
<el-container> <el-container class="main-container">
<el-header class="header"> <el-header class="header">
<div class="header-left"> <div class="header-left">
<el-breadcrumb separator="/"> <h3 class="page-route-title">{{ currentTitle }}</h3>
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ $route.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-tag :type="roleTagType" size="small" class="role-tag">{{ authStore.user?.role }}</el-tag> <el-button text class="logout-btn" @click="handleLogout">
<span class="user-name">{{ authStore.user?.name }}</span> <el-icon :size="16"><SwitchButton /></el-icon>
<el-button text @click="handleLogout"> <span>退出</span>
<el-icon><SwitchButton /></el-icon>
</el-button> </el-button>
</div> </div>
</el-header> </el-header>
@ -64,18 +60,46 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const router = useRouter() const router = useRouter()
const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const isCollapsed = ref(false) const isCollapsed = ref(false)
const roleTagType = computed(() => { const menuItems = [
const map = { 'Owner': 'danger', '主管': 'warning', '组长': '', '成员': 'info' } { path: '/dashboard', label: '仪表盘', icon: 'Odometer', role: 'Owner' },
return map[authStore.user?.role] || 'info' { path: '/projects', label: '项目管理', icon: 'FolderOpened' },
{ path: '/submissions', label: '内容提交', icon: 'EditPen' },
{ path: '/costs', label: '成本管理', icon: 'Money', role: 'leader+' },
{ path: '/users', label: '用户管理', icon: 'User', role: 'Owner' },
]
const titleMap = {
'/dashboard': '仪表盘',
'/projects': '项目管理',
'/submissions': '内容提交',
'/costs': '成本管理',
'/users': '用户管理',
}
const currentTitle = computed(() => {
if (route.path.startsWith('/projects/')) return '项目详情'
if (route.path.startsWith('/settlement/')) return '项目结算'
return titleMap[route.path] || ''
}) })
function isActive(path) {
return route.path === path || route.path.startsWith(path + '/')
}
function hasRole(role) {
if (role === 'Owner') return authStore.isOwner()
if (role === 'leader+') return authStore.isLeaderOrAbove()
return true
}
onMounted(async () => { onMounted(async () => {
if (authStore.token && !authStore.user) { if (authStore.token && !authStore.user) {
await authStore.fetchUser() await authStore.fetchUser()
@ -90,32 +114,124 @@ function handleLogout() {
<style scoped> <style scoped>
.layout { height: 100vh; } .layout { height: 100vh; }
/* ── 侧边栏 ── */
.aside { .aside {
background: #1d1e2c; background: var(--bg-sidebar);
transition: width 0.3s; border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width 0.25s ease;
overflow: hidden; overflow: hidden;
} }
.logo { .logo {
height: 60px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; padding: 0 16px;
gap: 8px; gap: 10px;
color: #fff;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #2d2e3e; border-bottom: 1px solid var(--border-light);
} }
.logo-text { font-size: 18px; font-weight: 700; letter-spacing: 2px; } .logo-icon {
.side-menu { border-right: none; } width: 32px;
height: 32px;
border-radius: 8px;
background: var(--primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
}
.logo-info { display: flex; flex-direction: column; }
.logo-title { font-size: 15px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.logo-sub { font-size: 11px; color: var(--text-secondary); }
/* 导航菜单 */
.nav-menu {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
color: var(--text-regular);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: var(--bg-active);
color: var(--primary);
}
.nav-label { line-height: 1; }
/* 底部用户 */
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-light);
}
.user-brief { display: flex; align-items: center; gap: 10px; }
.user-avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--primary-light);
color: var(--primary);
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 13px; flex-shrink: 0;
}
.user-meta { display: flex; flex-direction: column; }
.user-name { font-size: 13px; font-weight: 600; color: var(--text-primary); line-height: 1.3; }
.user-role { font-size: 11px; color: var(--text-secondary); }
/* ── 顶栏 ── */
.main-container { background: var(--bg-page); }
.header { .header {
height: 56px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid #e8e8e8; padding: 0 24px;
background: #fff; background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
} }
.header-right { display: flex; align-items: center; gap: 12px; } .page-route-title {
.user-name { font-size: 14px; color: #333; } font-size: 16px;
.role-tag { margin-right: 4px; } font-weight: 600;
.main { background: #f5f7fa; min-height: 0; } color: var(--text-primary);
}
.header-right { display: flex; align-items: center; gap: 8px; }
.logout-btn {
color: var(--text-secondary) !important;
font-size: 13px !important;
gap: 4px;
}
.logout-btn:hover { color: var(--danger) !important; }
/* ── 主内容 ── */
.main {
padding: 24px;
background: var(--bg-page);
min-height: 0;
overflow-y: auto;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style> </style>

View File

@ -4,6 +4,7 @@ import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'

View File

@ -1,79 +1,174 @@
/*
AirLabs Project · 飞书风格全局主题
*/
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; /* 主色 */
line-height: 1.5; --primary: #3370FF;
font-weight: 400; --primary-light: #E8F0FE;
--primary-hover: #2860E1;
color-scheme: light dark; /* 功能色 */
color: rgba(255, 255, 255, 0.87); --success: #34C759;
background-color: #242424; --warning: #FF9500;
--danger: #FF3B30;
--info: #8E8E93;
font-synthesis: none; /* 文字 */
text-rendering: optimizeLegibility; --text-primary: #1F2329;
-webkit-font-smoothing: antialiased; --text-regular: #3B3F46;
-moz-osx-font-smoothing: grayscale; --text-secondary: #8F959E;
--text-placeholder: #BFC3C9;
/* 背景 */
--bg-page: #F5F6F7;
--bg-card: #FFFFFF;
--bg-sidebar: #FFFFFF;
--bg-hover: #F0F1F2;
--bg-active: #E8F0FE;
/* 边框 */
--border-color: #E5E6EB;
--border-light: #F0F1F2;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
/* 圆角 */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
/* 字体 */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
} }
a { * {
font-weight: 500; margin: 0;
color: #646cff; padding: 0;
text-decoration: inherit; box-sizing: border-box;
}
a:hover {
color: #535bf2;
} }
body { body {
margin: 0; font-family: var(--font-family);
display: flex; font-size: 14px;
place-items: center; color: var(--text-primary);
min-width: 320px; background: var(--bg-page);
min-height: 100vh; -webkit-font-smoothing: antialiased;
} }
h1 { /* ── Element Plus 主题覆盖 ── */
font-size: 3.2em;
line-height: 1.1; /* 按钮 */
.el-button--primary {
--el-button-bg-color: var(--primary) !important;
--el-button-border-color: var(--primary) !important;
--el-button-hover-bg-color: var(--primary-hover) !important;
--el-button-hover-border-color: var(--primary-hover) !important;
border-radius: var(--radius-sm) !important;
}
.el-button {
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
} }
button { /* 卡片 */
border-radius: 8px; .el-card {
border: 1px solid transparent; border: 1px solid var(--border-color) !important;
padding: 0.6em 1.2em; border-radius: var(--radius-md) !important;
font-size: 1em; box-shadow: var(--shadow-sm) !important;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
} }
button:hover { .el-card__header {
border-color: #646cff; border-bottom: 1px solid var(--border-light) !important;
padding: 16px 20px !important;
font-size: 14px !important;
} }
button:focus, .el-card__body {
button:focus-visible { padding: 20px !important;
outline: 4px auto -webkit-focus-ring-color;
} }
.card { /* 表格 */
padding: 2em; .el-table {
--el-table-border-color: var(--border-light) !important;
--el-table-header-bg-color: #FAFBFC !important;
--el-table-row-hover-bg-color: #F7F8FA !important;
border-radius: var(--radius-md) !important;
overflow: hidden;
font-size: 13px !important;
}
.el-table th.el-table__cell {
font-weight: 600 !important;
color: var(--text-secondary) !important;
font-size: 12px !important;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
#app { /* 标签 */
max-width: 1280px; .el-tag {
margin: 0 auto; border-radius: 4px !important;
padding: 2rem; font-size: 12px !important;
text-align: center; border: none !important;
}
.el-tag--success { background: #E8F8EE !important; color: #1A9E3F !important; }
.el-tag--warning { background: #FFF3E0 !important; color: #D47E00 !important; }
.el-tag--danger { background: #FFE8E7 !important; color: #D4380D !important; }
.el-tag--info { background: #F0F1F2 !important; color: #646A73 !important; }
.el-tag--primary, .el-tag:not([class*="--"]) { background: var(--primary-light) !important; color: var(--primary) !important; }
/* 输入框 */
.el-input__wrapper {
border-radius: var(--radius-sm) !important;
box-shadow: 0 0 0 1px var(--border-color) inset !important;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px #C0C4CC inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--primary) inset !important;
} }
@media (prefers-color-scheme: light) { /* 对话框 */
:root { .el-dialog {
color: #213547; border-radius: var(--radius-lg) !important;
background-color: #ffffff; }
} .el-dialog__header {
a:hover { border-bottom: 1px solid var(--border-light);
color: #747bff; margin-right: 0 !important;
} padding: 16px 24px !important;
button { }
background-color: #f9f9f9; .el-dialog__body {
} padding: 24px !important;
}
.el-dialog__footer {
border-top: 1px solid var(--border-light);
padding: 12px 24px !important;
}
/* 进度条 */
.el-progress-bar__outer {
border-radius: 4px !important;
background: #F0F1F2 !important;
}
.el-progress-bar__inner {
border-radius: 4px !important;
}
/* 选项卡 */
.el-tabs__item {
font-weight: 500 !important;
}
.el-tabs__item.is-active {
color: var(--primary) !important;
}
.el-tabs__active-bar {
background-color: var(--primary) !important;
}
/* 描述列表 */
.el-descriptions__label {
color: var(--text-secondary) !important;
font-weight: 500 !important;
} }

View File

@ -151,6 +151,6 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 16px; } .page-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
.tab-header { margin-bottom: 12px; } .tab-header { margin-bottom: 12px; }
</style> </style>

View File

@ -1,100 +1,110 @@
<template> <template>
<div class="dashboard" v-loading="loading"> <div class="dashboard" v-loading="loading">
<h2 class="page-title">仪表盘</h2>
<!-- 顶部统计卡片 --> <!-- 顶部统计卡片 -->
<el-row :gutter="16" class="stat-row"> <div class="stat-grid">
<el-col :span="6"> <div class="stat-card">
<el-card shadow="hover" class="stat-card"> <div class="stat-icon blue"><el-icon :size="20"><FolderOpened /></el-icon></div>
<div class="stat-body">
<div class="stat-value">{{ data.active_projects || 0 }}</div>
<div class="stat-label">进行中项目</div> <div class="stat-label">进行中项目</div>
<div class="stat-value blue">{{ data.active_projects }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-label">本月人力成本</div>
<div class="stat-value orange">¥{{ formatNum(data.monthly_labor_cost) }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-label">本月产出</div>
<div class="stat-value green">{{ formatSecs(data.monthly_total_seconds) }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-label">人均日产出</div>
<div class="stat-value purple">{{ formatSecs(data.avg_daily_seconds_per_person) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 项目进度 -->
<el-card class="section-card">
<template #header>
<span class="section-title">项目进度一览</span>
</template>
<div v-for="p in data.projects" :key="p.id" class="project-row" @click="$router.push(`/projects/${p.id}`)">
<div class="project-info">
<span class="project-name">{{ p.name }}</span>
<el-tag size="small" :type="typeTagMap[p.project_type]">{{ p.project_type }}</el-tag>
<el-tag v-if="p.is_overdue" size="small" type="danger">超期</el-tag>
</div>
<div class="project-progress">
<el-progress
:percentage="Math.min(p.progress_percent, 100)"
:color="p.is_overdue ? '#f56c6c' : '#67c23a'"
:stroke-width="14"
:text-inside="true"
:format="() => p.progress_percent + '%'"
/>
<span class="progress-detail">{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }}</span>
</div> </div>
</div> </div>
<el-empty v-if="!data.projects?.length" description="暂无进行中的项目" /> <div class="stat-card">
</el-card> <div class="stat-icon orange"><el-icon :size="20"><Money /></el-icon></div>
<div class="stat-body">
<div class="stat-value">¥{{ formatNum(data.monthly_labor_cost) }}</div>
<div class="stat-label">本月人力成本</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><el-icon :size="20"><VideoCamera /></el-icon></div>
<div class="stat-body">
<div class="stat-value">{{ formatSecs(data.monthly_total_seconds) }}</div>
<div class="stat-label">本月总产出</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon purple"><el-icon :size="20"><TrendCharts /></el-icon></div>
<div class="stat-body">
<div class="stat-value">{{ formatSecs(data.avg_daily_seconds_per_person) }}</div>
<div class="stat-label">人均日产出</div>
</div>
</div>
</div>
<el-row :gutter="16"> <!-- 项目进度 -->
<div class="card">
<div class="card-header">
<span class="card-title">项目进度</span>
<span class="card-count">{{ data.projects?.length || 0 }} 个进行中</span>
</div>
<div class="card-body">
<div v-for="p in data.projects" :key="p.id" class="progress-item" @click="$router.push(`/projects/${p.id}`)">
<div class="progress-top">
<div class="progress-info">
<span class="progress-name">{{ p.name }}</span>
<el-tag size="small" :type="typeTagMap[p.project_type]">{{ p.project_type }}</el-tag>
<el-tag v-if="p.is_overdue" size="small" type="danger">超期</el-tag>
</div>
<span class="progress-pct">{{ p.progress_percent }}%</span>
</div>
<el-progress
:percentage="Math.min(p.progress_percent, 100)"
:color="p.is_overdue ? '#FF3B30' : '#3370FF'"
:stroke-width="6"
:show-text="false"
/>
<div class="progress-meta">
<span>{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }}</span>
<span v-if="p.waste_rate > 0" :style="{color: p.waste_rate > 30 ? '#FF3B30' : '#8F959E'}">损耗 {{ p.waste_rate }}%</span>
</div>
</div>
<el-empty v-if="!data.projects?.length" description="暂无进行中的项目" :image-size="80" />
</div>
</div>
<div class="two-col">
<!-- 损耗排行 --> <!-- 损耗排行 -->
<el-col :span="12"> <div class="card">
<el-card class="section-card"> <div class="card-header"><span class="card-title">损耗排行</span></div>
<template #header><span class="section-title">损耗排行</span></template> <div class="card-body">
<el-table :data="data.waste_ranking" size="small" stripe> <el-table :data="data.waste_ranking" size="small">
<el-table-column prop="project_name" label="项目" /> <el-table-column prop="project_name" label="项目" />
<el-table-column label="损耗秒数" align="right"> <el-table-column label="损耗" align="right" width="100">
<template #default="{ row }">{{ formatSecs(row.waste_seconds) }}</template> <template #default="{ row }">{{ formatSecs(row.waste_seconds) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="损耗率" align="right" width="100"> <el-table-column label="损耗率" align="right" width="90">
<template #default="{ row }"> <template #default="{ row }">
<span :style="{ color: row.waste_rate > 30 ? '#f56c6c' : '#333' }">{{ row.waste_rate }}%</span> <span class="rate-badge" :class="{ danger: row.waste_rate > 30 }">{{ row.waste_rate }}%</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> <el-empty v-if="!data.waste_ranking?.length" description="暂无数据" :image-size="60" />
</el-col> </div>
</div>
<!-- 已结算项目 --> <!-- 已结算项目 -->
<el-col :span="12"> <div class="card">
<el-card class="section-card"> <div class="card-header"><span class="card-title">已结算项目</span></div>
<template #header><span class="section-title">已结算项目</span></template> <div class="card-body">
<el-table :data="data.settled_projects" size="small" stripe> <el-table :data="data.settled_projects" size="small">
<el-table-column prop="project_name" label="项目" /> <el-table-column prop="project_name" label="项目" />
<el-table-column label="总成本" align="right"> <el-table-column label="总成本" align="right" width="100">
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template> <template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="盈亏" align="right"> <el-table-column label="盈亏" align="right" width="100">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.profit_loss != null" :style="{ color: row.profit_loss >= 0 ? '#67c23a' : '#f56c6c', fontWeight: 600 }"> <span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }} {{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
</span> </span>
<span v-else style="color:#999"></span> <span v-else class="text-muted"></span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> <el-empty v-if="!data.settled_projects?.length" description="暂无数据" :image-size="60" />
</el-col> </div>
</el-row> </div>
</div>
</div> </div>
</template> </template>
@ -104,12 +114,7 @@ import { dashboardApi } from '../api'
const loading = ref(false) const loading = ref(false)
const data = ref({}) const data = ref({})
const typeTagMap = { const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
'客户正式项目': 'success',
'客户测试项目': 'warning',
'内部原创项目': '',
'内部测试项目': 'info',
}
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) } function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
function formatSecs(s) { function formatSecs(s) {
@ -126,22 +131,85 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 16px; } /* 统计网格 */
.stat-row { margin-bottom: 16px; } .stat-grid {
.stat-card { text-align: center; } display: grid;
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; } grid-template-columns: repeat(4, 1fr);
.stat-value { font-size: 28px; font-weight: 700; } gap: 16px;
.stat-value.blue { color: #409eff; } margin-bottom: 20px;
.stat-value.orange { color: #e6a23c; } }
.stat-value.green { color: #67c23a; } .stat-card {
.stat-value.purple { color: #9b59b6; } background: var(--bg-card);
.section-card { margin-bottom: 16px; } border: 1px solid var(--border-color);
.section-title { font-weight: 600; } border-radius: var(--radius-md);
.project-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f0f0f0; cursor: pointer; } padding: 20px;
.project-row:hover { background: #fafafa; } display: flex;
.project-info { display: flex; align-items: center; gap: 8px; min-width: 260px; } align-items: center;
.project-name { font-weight: 500; } gap: 16px;
.project-progress { flex: 1; display: flex; align-items: center; gap: 12px; } }
.project-progress .el-progress { flex: 1; } .stat-icon {
.progress-detail { font-size: 12px; color: #909399; white-space: nowrap; } width: 44px; height: 44px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.stat-icon.blue { background: #E8F0FE; color: #3370FF; }
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
.stat-icon.green { background: #E8F8EE; color: #34C759; }
.stat-icon.purple { background: #F0E8FE; color: #9B59B6; }
.stat-body { flex: 1; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
/* 卡片 */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.card-count { font-size: 12px; color: var(--text-secondary); }
.card-body { padding: 16px 20px; }
/* 项目进度 */
.progress-item {
padding: 14px 0;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: background 0.15s;
margin: 0 -20px;
padding-left: 20px;
padding-right: 20px;
}
.progress-item:last-child { border-bottom: none; }
.progress-item:hover { background: #FAFBFC; }
.progress-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.progress-info { display: flex; align-items: center; gap: 8px; }
.progress-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
.progress-meta {
display: flex; justify-content: space-between;
font-size: 12px; color: var(--text-secondary); margin-top: 6px;
}
/* 两列布局 */
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* 标签 */
.rate-badge {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px;
}
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
.profit { font-weight: 600; color: #34C759; }
.profit.loss { color: #FF3B30; }
.text-muted { color: var(--text-secondary); }
</style> </style>

View File

@ -1,22 +1,33 @@
<template> <template>
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-left">
<div class="login-header"> <div class="brand">
<el-icon :size="36" color="#409eff"><DataAnalysis /></el-icon> <div class="brand-icon">A</div>
<h1>AirLabs Project</h1> <h1>AirLabs Project</h1>
<p>内容组项目管理系统</p> <p>内容组 · 项目管理系统</p>
</div>
<div class="brand-features">
<div class="feature"><span class="dot blue"></span>项目进度实时追踪</div>
<div class="feature"><span class="dot green"></span>成本自动核算分摊</div>
<div class="feature"><span class="dot orange"></span>损耗与效率分析</div>
</div>
</div>
<div class="login-right">
<div class="login-card">
<h2>登录</h2>
<p class="login-desc">使用你的账号登录系统</p>
<el-form :model="form" @submit.prevent="handleLogin" class="login-form">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" placeholder="密码" type="password" size="large" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
</el-button>
</el-form>
</div> </div>
<el-form :model="form" @submit.prevent="handleLogin" class="login-form">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" placeholder="密码" prefix-icon="Lock" type="password" size="large" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" style="width:100%">
</el-button>
</el-form>
</div> </div>
</div> </div>
</template> </template>
@ -40,7 +51,7 @@ async function handleLogin() {
loading.value = true loading.value = true
try { try {
await authStore.login(form.username, form.password) await authStore.login(form.username, form.password)
ElMessage.success(`欢迎${authStore.user?.name}`) ElMessage.success(`欢迎回来${authStore.user?.name || ''}`)
router.push('/') router.push('/')
} catch { } catch {
// //
@ -54,22 +65,69 @@ async function handleLogin() {
.login-page { .login-page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
}
/* 左侧品牌区 */
.login-left {
flex: 1;
background: linear-gradient(135deg, #3370FF 0%, #2451B8 100%);
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px;
color: #fff;
}
.brand { margin-bottom: 48px; }
.brand-icon {
width: 48px; height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 700;
margin-bottom: 24px;
backdrop-filter: blur(10px);
}
.brand h1 { font-size: 32px; font-weight: 700; margin-bottom: 8px; }
.brand p { font-size: 16px; opacity: 0.8; }
.brand-features { display: flex; flex-direction: column; gap: 16px; }
.feature {
display: flex; align-items: center; gap: 12px;
font-size: 15px; opacity: 0.9;
}
.dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.dot.blue { background: #7EB8FF; }
.dot.green { background: #7DECA2; }
.dot.orange { background: #FFB967; }
/* 右侧登录区 */
.login-right {
width: 480px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #1d1e2c 0%, #2d3a5c 100%); background: var(--bg-page);
} }
.login-card { .login-card {
width: 400px; width: 360px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
} }
.login-header { .login-card h2 {
text-align: center; font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.login-desc {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 32px; margin-bottom: 32px;
} }
.login-header h1 { font-size: 24px; margin: 12px 0 4px; color: #1d1e2c; } .login-form .el-form-item { margin-bottom: 20px; }
.login-header p { color: #999; font-size: 14px; } .login-btn {
.login-form { margin-top: 8px; } width: 100%;
height: 44px !important;
font-size: 15px !important;
border-radius: var(--radius-sm) !important;
}
</style> </style>

View File

@ -1,91 +1,99 @@
<template> <template>
<div v-loading="loading"> <div v-loading="loading">
<div class="page-header"> <div class="page-header">
<div> <div class="page-header-left">
<el-button text @click="$router.push('/projects')"><el-icon><ArrowLeft /></el-icon> 返回</el-button> <el-button text @click="$router.push('/projects')" class="back-btn"><el-icon><ArrowLeft /></el-icon></el-button>
<h2 style="display:inline; margin-left:8px">{{ project.name }}</h2> <h2>{{ project.name }}</h2>
<el-tag :type="typeTagMap[project.project_type]" style="margin-left:8px">{{ project.project_type }}</el-tag> <el-tag :type="typeTagMap[project.project_type]" size="small">{{ project.project_type }}</el-tag>
<el-tag :type="project.status === '已完成' ? 'success' : ''" style="margin-left:4px">{{ project.status }}</el-tag> <el-tag :type="project.status === '已完成' ? 'success' : 'info'" size="small">{{ project.status }}</el-tag>
</div> </div>
<el-space> <el-space>
<el-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" @click="handleComplete"> <el-button v-if="authStore.isOwner() && project.status === '制作中'" type="danger" plain @click="handleComplete">确认完成</el-button>
确认完成结算 <el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">查看结算</el-button>
</el-button>
<el-button v-if="authStore.isOwner() && project.status === '已完成'" type="primary" @click="$router.push(`/settlement/${project.id}`)">
查看结算报告
</el-button>
</el-space> </el-space>
</div> </div>
<!-- 项目概览 --> <!-- 概览卡片 -->
<el-row :gutter="16" class="stat-row"> <div class="stat-grid">
<el-col :span="6"> <div class="stat-card">
<el-card shadow="hover"><div class="stat-label">目标时长</div><div class="stat-value">{{ formatSecs(project.target_total_seconds) }}</div></el-card> <div class="stat-label">目标时长</div>
</el-col> <div class="stat-value">{{ formatSecs(project.target_total_seconds) }}</div>
<el-col :span="6"> </div>
<el-card shadow="hover"><div class="stat-label">已提交</div><div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div></el-card> <div class="stat-card">
</el-col> <div class="stat-label">已提交</div>
<el-col :span="6"> <div class="stat-value">{{ formatSecs(project.total_submitted_seconds) }}</div>
<el-card shadow="hover"><div class="stat-label">完成进度</div><div class="stat-value" :style="{color: project.progress_percent > 100 ? '#e6a23c' : '#409eff'}">{{ project.progress_percent }}%</div></el-card> </div>
</el-col> <div class="stat-card">
<el-col :span="6"> <div class="stat-label">完成进度</div>
<el-card shadow="hover"><div class="stat-label">损耗率</div><div class="stat-value" :style="{color: project.waste_rate > 30 ? '#f56c6c' : '#67c23a'}">{{ project.waste_rate }}%</div></el-card> <div class="stat-value" :style="{color: project.progress_percent > 100 ? '#FF9500' : '#3370FF'}">{{ project.progress_percent }}%</div>
</el-col> </div>
</el-row> <div class="stat-card">
<div class="stat-label">损耗率</div>
<div class="stat-value" :style="{color: project.waste_rate > 30 ? '#FF3B30' : '#34C759'}">{{ project.waste_rate }}%</div>
</div>
</div>
<!-- 进度条 --> <!-- 进度条 -->
<el-card class="section-card"> <div class="card">
<template #header><span class="section-title">项目进度</span></template> <div class="card-header"><span class="card-title">项目进度</span></div>
<el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="20" :text-inside="true" <div class="card-body">
:format="() => project.progress_percent + '%'" <el-progress :percentage="Math.min(project.progress_percent, 100)" :stroke-width="8" :show-text="false"
:color="progressColor" style="margin-bottom:12px" /> :color="progressColor" style="margin-bottom:12px" />
<div class="meta-row"> <div class="meta-row">
<span>目标{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span> <span>目标{{ project.episode_count }} × {{ project.episode_duration_minutes }} = {{ formatSecs(project.target_total_seconds) }}</span>
<span v-if="project.estimated_completion_date">预估完成{{ project.estimated_completion_date }}</span> <span v-if="project.estimated_completion_date">预估完成{{ project.estimated_completion_date }}</span>
</div>
</div> </div>
</el-card> </div>
<!-- 团队效率 --> <!-- 团队效率 -->
<el-card v-if="authStore.isLeaderOrAbove() && efficiency.length" class="section-card"> <div v-if="authStore.isLeaderOrAbove() && efficiency.length" class="card">
<template #header><span class="section-title">团队效率人均基准对比</span></template> <div class="card-header"><span class="card-title">团队效率</span></div>
<el-table :data="efficiency" size="small" stripe> <div class="card-body">
<el-table-column prop="user_name" label="成员" width="100" /> <el-table :data="efficiency" size="small">
<el-table-column label="提交总秒数" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column> <el-table-column prop="user_name" label="成员" width="100" />
<el-table-column label="人均基准" align="right"><template #default="{row}">{{ formatSecs(row.baseline) }}</template></el-table-column> <el-table-column label="提交总量" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
<el-table-column label="超出基准" align="right"> <el-table-column label="人均基准" align="right"><template #default="{row}">{{ formatSecs(row.baseline) }}</template></el-table-column>
<template #default="{row}"> <el-table-column label="超出基准" align="right">
<span :style="{color: row.excess_seconds > 0 ? '#f56c6c' : '#67c23a'}"> <template #default="{row}">
{{ row.excess_seconds > 0 ? '+' : '' }}{{ formatSecs(row.excess_seconds) }} <span :style="{color: row.excess_seconds > 0 ? '#FF3B30' : '#34C759', fontWeight: 600}">
</span> {{ row.excess_seconds > 0 ? '+' : '' }}{{ formatSecs(row.excess_seconds) }}
</template> </span>
</el-table-column> </template>
<el-table-column label="超出比例" align="right" width="100"> </el-table-column>
<template #default="{row}"> <el-table-column label="超出比例" align="right" width="100">
<span :style="{color: row.excess_rate > 20 ? '#f56c6c' : '#333'}">{{ row.excess_rate > 0 ? '+' : '' }}{{ row.excess_rate }}%</span> <template #default="{row}">
</template> <span class="rate-badge" :class="{danger: row.excess_rate > 20}">{{ row.excess_rate > 0 ? '+' : '' }}{{ row.excess_rate }}%</span>
</el-table-column> </template>
</el-table> </el-table-column>
</el-card> </el-table>
</div>
</div>
<!-- 提交记录 --> <!-- 提交记录 -->
<el-card class="section-card"> <div class="card">
<template #header><span class="section-title">提交记录</span></template> <div class="card-header">
<el-table :data="submissions" size="small" stripe> <span class="card-title">提交记录</span>
<el-table-column prop="submit_date" label="日期" width="110" /> <span class="card-count">{{ submissions.length }} </span>
<el-table-column prop="user_name" label="提交人" width="80" /> </div>
<el-table-column prop="project_phase" label="阶段" width="70" /> <div class="card-body">
<el-table-column prop="work_type" label="工作类型" width="80"> <el-table :data="submissions" size="small">
<template #default="{row}"> <el-table-column prop="submit_date" label="日期" width="110" />
<el-tag :type="row.work_type === '测试' ? 'warning' : ''" size="small">{{ row.work_type }}</el-tag> <el-table-column prop="user_name" label="提交人" width="80" />
</template> <el-table-column prop="project_phase" label="阶段" width="70" />
</el-table-column> <el-table-column label="工作类型" width="80">
<el-table-column prop="content_type" label="内容类型" width="90" /> <template #default="{row}">
<el-table-column label="产出时长" width="90" align="right"> <el-tag :type="row.work_type === '测试' ? 'warning' : row.work_type === '方案' ? 'info' : ''" size="small">{{ row.work_type }}</el-tag>
<template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip /> <el-table-column prop="content_type" label="内容类型" width="90" />
</el-table> <el-table-column label="产出时长" width="90" align="right">
</el-card> <template #default="{row}">{{ row.total_seconds > 0 ? formatSecs(row.total_seconds) : '—' }}</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
</el-table>
</div>
</div>
</div> </div>
</template> </template>
@ -105,10 +113,7 @@ const submissions = ref([])
const efficiency = ref([]) const efficiency = ref([])
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' } const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
const progressColor = computed(() => { const progressColor = computed(() => project.value.progress_percent > 100 ? '#FF9500' : '#3370FF')
if (project.value.progress_percent > 100) return '#e6a23c'
return '#67c23a'
})
function formatSecs(s) { function formatSecs(s) {
if (!s) return '0秒' if (!s) return '0秒'
@ -144,11 +149,34 @@ onMounted(load)
</script> </script>
<style scoped> <style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.stat-row { margin-bottom: 16px; } .page-header-left { display: flex; align-items: center; gap: 8px; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 4px; } .page-header-left h2 { font-size: 18px; font-weight: 600; }
.stat-value { font-size: 24px; font-weight: 700; } .back-btn { font-size: 16px !important; padding: 4px !important; }
.section-card { margin-bottom: 16px; }
.section-title { font-weight: 600; } .stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: #909399; } .stat-card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 20px; text-align: center;
}
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); margin-bottom: 16px;
}
.card-header {
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
display: flex; justify-content: space-between; align-items: center;
}
.card-title { font-size: 14px; font-weight: 600; }
.card-count { font-size: 12px; color: var(--text-secondary); }
.card-body { padding: 20px; }
.meta-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); }
.rate-badge {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
}
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
</style> </style>

View File

@ -1,59 +1,67 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>项目管理</h2> <div></div>
<el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true"> <el-button v-if="authStore.isSupervisor()" type="primary" @click="showCreate = true">
<el-icon><Plus /></el-icon> 新建项目 <el-icon><Plus /></el-icon> 新建项目
</el-button> </el-button>
</div> </div>
<!-- 筛选 --> <!-- 筛选 -->
<el-card class="filter-card"> <div class="filter-bar">
<el-space> <el-select v-model="filter.status" placeholder="状态" clearable style="width:120px" @change="load">
<el-select v-model="filter.status" placeholder="状态" clearable style="width:130px" @change="load"> <el-option label="制作中" value="制作中" />
<el-option label="制作中" value="制作中" /> <el-option label="已完成" value="已完成" />
<el-option label="已完成" value="已完成" /> </el-select>
</el-select> <el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:150px" @change="load">
<el-select v-model="filter.project_type" placeholder="项目类型" clearable style="width:160px" @change="load"> <el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" />
<el-option v-for="t in projectTypes" :key="t" :label="t" :value="t" /> </el-select>
</el-select> </div>
</el-space>
</el-card>
<!-- 项目列表 --> <!-- 项目列表 -->
<el-table :data="projects" v-loading="loading" stripe @row-click="row => $router.push(`/projects/${row.id}`)"> <div class="card">
<el-table-column prop="name" label="项目名称" min-width="160" /> <div class="card-body">
<el-table-column label="类型" width="140"> <el-table :data="projects" v-loading="loading" @row-click="row => $router.push(`/projects/${row.id}`)" style="cursor:pointer">
<template #default="{ row }"> <el-table-column prop="name" label="项目名称" min-width="160">
<el-tag size="small" :type="typeTagMap[row.project_type]">{{ row.project_type }}</el-tag> <template #default="{ row }">
</template> <span class="cell-bold">{{ row.name }}</span>
</el-table-column> </template>
<el-table-column label="状态" width="100"> </el-table-column>
<template #default="{ row }"> <el-table-column label="类型" width="140">
<el-tag size="small" :type="row.status === '已完成' ? 'success' : ''">{{ row.status }}</el-tag> <template #default="{ row }">
</template> <el-tag size="small" :type="typeTagMap[row.project_type]">{{ row.project_type }}</el-tag>
</el-table-column> </template>
<el-table-column prop="leader_name" label="负责人" width="100" /> </el-table-column>
<el-table-column label="目标" width="140"> <el-table-column label="状态" width="90">
<template #default="{ row }">{{ row.episode_count }} × {{ row.episode_duration_minutes }}</template> <template #default="{ row }">
</el-table-column> <el-tag size="small" :type="row.status === '已完成' ? 'success' : 'info'">{{ row.status }}</el-tag>
<el-table-column label="进度" width="180"> </template>
<template #default="{ row }"> </el-table-column>
<el-progress :percentage="Math.min(row.progress_percent, 100)" :stroke-width="10" :text-inside="true" <el-table-column prop="leader_name" label="负责人" width="90" />
:format="() => row.progress_percent + '%'" <el-table-column label="目标" width="130">
:color="row.progress_percent > 100 ? '#e6a23c' : '#409eff'" /> <template #default="{ row }">{{ row.episode_count }} × {{ row.episode_duration_minutes }}</template>
</template> </el-table-column>
</el-table-column> <el-table-column label="进度" width="180">
<el-table-column label="损耗率" width="90" align="right"> <template #default="{ row }">
<template #default="{ row }"> <div class="cell-progress">
<span :style="{ color: row.waste_rate > 30 ? '#f56c6c' : '#333' }">{{ row.waste_rate }}%</span> <el-progress :percentage="Math.min(row.progress_percent, 100)" :stroke-width="6" :show-text="false"
</template> :color="row.progress_percent > 100 ? '#FF9500' : '#3370FF'" />
</el-table-column> <span class="progress-text">{{ row.progress_percent }}%</span>
</el-table> </div>
</template>
</el-table-column>
<el-table-column label="损耗率" width="90" align="right">
<template #default="{ row }">
<span class="rate-badge" :class="{ danger: row.waste_rate > 30 }">{{ row.waste_rate }}%</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 新建项目对话框 --> <!-- 新建项目对话框 -->
<el-dialog v-model="showCreate" title="新建项目" width="560px" destroy-on-close> <el-dialog v-model="showCreate" title="新建项目" width="560px" destroy-on-close>
<el-form :model="form" label-width="120px" label-position="left"> <el-form :model="form" label-width="110px" label-position="left">
<el-form-item label="项目名称" required> <el-form-item label="项目名称" required>
<el-input v-model="form.name" placeholder="输入项目名称" /> <el-input v-model="form.name" placeholder="输入项目名称" />
</el-form-item> </el-form-item>
@ -141,6 +149,19 @@ onMounted(async () => {
<style scoped> <style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; } .filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }
.filter-card { margin-bottom: 16px; } .card {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.card-body { padding: 4px 0; }
.cell-bold { font-weight: 500; color: var(--text-primary); }
.cell-progress { display: flex; align-items: center; gap: 8px; }
.cell-progress .el-progress { flex: 1; }
.progress-text { font-size: 12px; font-weight: 600; color: var(--text-secondary); min-width: 36px; text-align: right; }
.rate-badge {
font-size: 12px; font-weight: 600; color: var(--text-secondary);
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px; display: inline-block;
}
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
</style> </style>

View File

@ -121,11 +121,11 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.page-header { margin-bottom: 16px; } .page-header { margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
.section-card { margin-bottom: 16px; } .section-card { margin-bottom: 16px; }
.section-title { font-weight: 600; } .section-title { font-weight: 600; font-size: 14px; }
.stat-row { margin-bottom: 16px; } .stat-row { margin-bottom: 16px; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 4px; } .stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
.stat-value { font-size: 24px; font-weight: 700; } .stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.big-stat { text-align: center; padding: 12px 0; } .big-stat { text-align: center; padding: 16px 0; }
</style> </style>

View File

@ -175,6 +175,6 @@ onMounted(async () => {
<style scoped> <style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; } .page-header h2 { font-size: 18px; font-weight: 600; }
.filter-card { margin-bottom: 16px; } .filter-card { margin-bottom: 16px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px; }
</style> </style>

View File

@ -97,5 +97,5 @@ onMounted(load)
<style scoped> <style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; } .page-header h2 { font-size: 18px; font-weight: 600; }
</style> </style>