Files
qiji/frontend/src/views/desktop/Dashboard.vue
T
v6ole ab20bc5a1f Merge branch 'main' into develop
# Conflicts:
#	backend/app/api/key_visits.py
#	backend/app/api/mini_business.py
#	backend/app/api/visits.py
#	backend/app/api/work_plans.py
#	backend/app/main.py
#	backend/app/models/__init__.py
#	backend/app/schemas/key_visit.py
#	backend/app/schemas/mini_business.py
#	backend/app/schemas/work_plan.py
#	backend/app/services/light_board.py
#	frontend/src/components/DesktopLayout.vue
#	frontend/src/stores/theme.ts
#	frontend/src/views/desktop/ManagerWorkspace.vue
#	frontend/src/views/desktop/WorkPlans.vue
#	frontend/src/views/mobile/KeyVisitForm.vue
#	frontend/src/views/mobile/LeaveForm.vue
#	frontend/src/views/mobile/PlansList.vue
#	frontend/src/views/mobile/VisitForm.vue
#	frontend/src/views/mobile/WorkPlanForm.vue
2026-08-17 09:31:12 +08:00

444 lines
17 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, overdue_plans: 0, week_start: '', week_end: '' })
const progress = ref<any[]>([])
const weekOffset = ref(0) // 0 = current week, -1 = last week, etc.
const isHistoricalWeek = computed(() => weekOffset.value < 0)
const weekPickerDate = computed({
get: () => {
const d = new Date()
d.setDate(d.getDate() + weekOffset.value * 7)
return d.toISOString().slice(0, 10)
},
set: (_val: string) => {} // placeholder, actual change via buttons
})
function changeWeek(delta: number) {
weekOffset.value += delta
loadData()
}
function goCurrentWeek() {
weekOffset.value = 0
loadData()
}
async function loadData() {
loading.value = true
try {
const refDate = new Date()
refDate.setDate(refDate.getDate() + weekOffset.value * 7)
const refStr = refDate.toISOString().slice(0, 10)
const [sRes, pRes] = await Promise.all([
dashboardApi.getStats({ reference_date: refStr }),
dashboardApi.getProgress({ reference_date: refStr }),
])
stats.value = sRes.data
progress.value = pRes.data
} catch (e: any) {
ElMessage.error('加载仪表盘失败')
} finally {
loading.value = false
}
}
onMounted(loadData)
function goWeeklyReport(managerId?: string) {
if (managerId) router.push({ path: '/weekly-report', query: { manager_id: managerId } })
else router.push('/weekly-report')
}
function getRefDate(): string {
const d = new Date()
d.setDate(d.getDate() + weekOffset.value * 7)
return d.toISOString().slice(0, 10)
}
async function handleExport() {
try {
const res = await api.get('/export/weekly-report', { params: { reference_date: getRefDate() }, responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url; a.download = 'weekly_report.xlsx'; a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e: any) { ElMessage.error('导出失败') }
}
function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_day' {
if (p.is_rest_day) return 'rest_day'
if (p.on_leave) return 'on_leave'
if (p.has_reported_today && p.completed) return 'full'
if (p.has_reported_today && !p.completed) return 'catching'
return 'missing'
}
</script>
<template>
<div class="dashboard" v-loading="loading">
<!-- ═══ Page Header ═══ -->
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">仪表盘</h2>
<div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)" title="上一周"></button>
<span class="week-label">{{ stats.week_start }} {{ stats.week_end }}</span>
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0" title="下一周"></button>
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
<el-tag v-if="isHistoricalWeek" type="info" size="small" style="margin-left:8px">📦 历史归档 · 只读</el-tag>
</div>
</div>
<div class="page-head-actions">
<el-button type="primary" @click="goWeeklyReport()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
本周周报详情
</el-button>
<el-button type="success" v-if="auth.isDirector" @click="handleExport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出 Excel
</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- ═══ Stats Cards ═══ -->
<div class="stat-grid">
<div class="stat-card stat-card--clickable" @click="router.push('/weekly-report')">
<div class="stat-glyph stat-glyph--ink">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.week_visits }}</span>
<span class="stat-label">本周拜访</span>
</div>
</div>
<div class="stat-card stat-card--clickable" @click="router.push('/work-plans')">
<div class="stat-glyph stat-glyph--sage">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.work_plans }}<span v-if="stats.overdue_plans" class="stat-overdue">/{{ stats.overdue_plans }}</span></span>
<span class="stat-label">工作计划<template v-if="stats.overdue_plans"> · <span style="color:var(--vermilion)">{{ stats.overdue_plans }}条过期</span></template></span>
</div>
</div>
<div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
<div class="stat-glyph stat-glyph--gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.mini_business }}</span>
<span class="stat-label">商机跟单</span>
</div>
</div>
<div class="stat-card stat-card--clickable" @click="router.push('/key-visits')">
<div class="stat-glyph stat-glyph--vermilion">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.key_visits }}</span>
<span class="stat-label">要客拜访</span>
</div>
</div>
<div class="stat-card stat-card--clickable stat-card--slate" @click="router.push('/leaves')">
<div class="stat-glyph stat-glyph--slate">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.week_leaves }}</span>
<span class="stat-label">本周请假</span>
</div>
</div>
</div>
<!-- ═══ Reporting Progress ═══ -->
<el-card class="progress-card">
<template #header>
<div class="card-header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
填报进度
</div>
</template>
<div v-if="progress.length === 0" class="empty">暂无数据</div>
<div class="progress-grid">
<div
v-for="p in progress"
:key="p.manager_id"
class="progress-row"
:class="`progress-row--${rowState(p)}`"
>
<!-- Chinese Seal Watermark -->
<div class="seal-stamp" :class="`seal-stamp--${rowState(p)}`" aria-hidden="true">
<span class="seal-char">{{ { full: '满', catching: '追', missing: '未', on_leave: '假', rest_day: '休' }[rowState(p)] }}</span>
<span class="seal-char">{{ { full: '', catching: '', missing: '填', on_leave: '', rest_day: '' }[rowState(p)] }}</span>
</div>
<div class="progress-info">
<span class="progress-name">
<el-link type="primary" :underline="false" @click="goWeeklyReport(p.manager_id)">{{ p.manager_name }}</el-link>
<span class="progress-status" :class="`progress-status--${rowState(p)}`">
{{ { full: '本周已满', catching: '今日已填', missing: '今日未填', on_leave: '请假中', rest_day: '休息日' }[rowState(p)] }}
</span>
</span>
<span class="progress-count" v-if="!p.on_leave">{{ p.visit_count }} / {{ p.expected }} </span>
</div>
<el-progress
v-if="!p.on_leave && !p.is_rest_day"
:percentage="Math.min(100, Math.round((p.visit_count / Math.max(p.expected, 1)) * 100))"
:color="rowState(p) === 'full' ? '#4A6741' : rowState(p) === 'catching' ? '#C4934A' : '#B8472E'"
:stroke-width="12"
/>
<span v-else-if="p.on_leave" class="leave-text">请假中</span>
<span v-else-if="p.is_rest_day" class="rest-text">休息日</span>
</div>
</div>
</el-card>
</div>
</template>
<style scoped>
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: center; }
.page-head-actions { display: flex; gap: 8px; flex-shrink: 0; }
.page-title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.week-nav { display: flex; align-items: center; gap: 10px; margin: 6px 0 4px; }
.week-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 4px 10px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.week-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.week-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.week-label { font-family: var(--font-mono); font-size: 12px; color: var(--ink); letter-spacing: 0.04em; }
.week-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 4px 10px; cursor: pointer; font-size: 12px; }
.week-nav-reset:hover { background: var(--gold); color: #fff; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Stat Grid ═══ */
.stat-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
background: var(--surface);
border: 1px solid var(--warm-border);
padding: 20px 22px;
transition: all var(--transition);
}
.stat-card:hover { box-shadow: var(--shadow-md); }
.stat-card--clickable { cursor: pointer; }
.stat-card--clickable:hover { border-color: var(--gold); }
.stat-card--slate:hover { border-color: #5B7FA5; }
.stat-glyph {
width: 48px; height: 48px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.stat-glyph--ink { background: #EEF2F2; color: var(--ink); }
.stat-glyph--sage { background: #EDF2EC; color: var(--sage); }
.stat-glyph--gold { background: #FBF6EE; color: var(--gold); }
.stat-glyph--vermilion { background: #FBF1EE; color: var(--vermilion); }
.stat-glyph--slate { color: #5B7FA5; }
.stat-body { display: flex; flex-direction: column; }
.stat-num {
font-family: var(--font-heading);
font-size: 28px; color: var(--ink); line-height: 1.1; letter-spacing: 0.04em;
}
.stat-label {
font-family: var(--font-body);
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
}
.stat-overdue { font-size: 16px; color: var(--vermilion); font-family: var(--font-mono); }
/* ═══ Progress Grid — responsive 2-column ═══ */
.progress-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 8px 24px;
}
/* ═══ Progress ═══ */
.progress-card { margin-top: 4px; }
.card-header-title {
display: flex; align-items: center;
font-family: var(--font-heading);
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
.progress-row {
position: relative;
margin-bottom: 24px;
padding: 16px 18px 14px;
overflow: hidden;
border-left: 4px solid transparent;
transition: border-color 0.3s;
}
.progress-row:last-child { margin-bottom: 0; }
/* Left edge accent — three states */
.progress-row--missing { border-left-color: var(--vermilion); background: rgba(184,71,46,0.02); }
.progress-row--catching { border-left-color: var(--gold); background: rgba(196,147,74,0.03); }
.progress-row--full { border-left-color: var(--sage); background: rgba(74,103,65,0.02); }
.progress-row--on_leave { border-left-color: #5B7FA5; }
.progress-row--rest_day { border-left-color: #9CA3AF; background: rgba(156,163,175,0.02); }
/* ═══ Chinese Seal Stamp Watermark ═══ */
.seal-stamp {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-52%) rotate(-10deg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 3px solid;
border-radius: 8px;
outline: 1.5px solid;
outline-offset: -6px;
pointer-events: none;
user-select: none;
opacity: 0.16;
transition: opacity 0.3s;
/* Single-char stamps (满/追/未) are square; double-char (未填) is slightly wider */
width: 56px;
height: 56px;
}
.progress-row:hover .seal-stamp {
opacity: 0.24;
}
/* Missing stamp — vermilion red ink */
.seal-stamp--missing {
border-color: var(--vermilion);
outline-color: var(--vermilion);
color: var(--vermilion);
}
/* Catching stamp — gold ink */
.seal-stamp--catching {
border-color: var(--gold);
outline-color: var(--gold);
color: var(--gold);
}
/* Full stamp — sage green ink */
.seal-stamp--full {
border-color: var(--sage);
outline-color: var(--sage);
color: var(--sage);
}
/* On-leave stamp — slate blue-gray ink */
.seal-stamp--on_leave {
border-color: #5B7FA5;
outline-color: #5B7FA5;
color: #5B7FA5;
}
.seal-stamp--rest_day {
border-color: #9CA3AF;
outline-color: #9CA3AF;
color: #9CA3AF;
}
.seal-char {
font-family: var(--font-heading);
font-size: 20px;
font-weight: 400;
line-height: 1.1;
letter-spacing: 0.06em;
}
/* ═══ Progress Info ═══ */
.progress-info {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 10px;
position: relative; z-index: 1;
}
.progress-name { display: flex; align-items: center; gap: 10px; font-weight: 500; }
/* Inline status text (replaces el-tag) */
.progress-status {
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.05em;
}
.progress-status--missing { color: var(--vermilion); }
.progress-status--catching { color: var(--gold); }
.progress-status--full { color: var(--sage); }
.progress-status--on_leave { color: #5B7FA5; }
.progress-count {
font-family: var(--font-mono);
font-size: 13px; color: var(--warm-gray); letter-spacing: 0.03em;
position: relative; z-index: 1;
}
.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); }
.rest-text { font-size: 13px; color: #9CA3AF; font-family: var(--font-body); }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
/* ═══ Dark Mode — Dashboard ═══ */
html.dark .stat-glyph--ink,
html.dark .stat-glyph--sage,
html.dark .stat-glyph--gold,
html.dark .stat-glyph--vermilion {
background: transparent;
}
html.dark .progress-row--missing { background: rgba(248,113,113,0.06); }
html.dark .progress-row--catching { background: rgba(251,191,36,0.05); }
html.dark .progress-row--full { background: rgba(74,222,128,0.05); }
html.dark .progress-row--on_leave { background: rgba(96,165,250,0.05); }
html.dark .progress-row--rest_day { background: rgba(156,163,175,0.03); }
</style>