560 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-6">
<!-- 加载状态 -->
<div
v-if="canViewReports && isLoading"
class="flex items-center justify-center py-20"
>
<div class="flex flex-col items-center space-y-3 text-text-secondary">
<svg class="h-8 w-8 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span class="text-sm">加载中...</span>
</div>
</div>
<template v-else-if="canViewReports">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">Overview</p>
<h2 class="page-title">资产运行总览</h2>
<div class="mt-3 flex flex-wrap gap-2">
<router-link v-if="canBorrow" to="/borrow/scan" class="btn-primary text-sm">扫码出库</router-link>
<router-link v-if="canBorrow" to="/return/scan" class="btn-secondary text-sm">扫码入库</router-link>
<router-link v-if="canViewAllAssets" to="/equipment" class="btn-secondary text-sm">资产管理</router-link>
<router-link v-if="canApproveDisposals" to="/workflows/disposals?scope=pending" class="btn-secondary text-sm">待我审批</router-link>
<router-link v-if="canMaintain" to="/maintenance" class="btn-secondary text-sm">维修管理</router-link>
</div>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">今日借出</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_borrow || 0) }}</p>
<p class="page-meta__subvalue">{{ formatCurrencyNoFraction(stats?.today_borrow_value || 0) }}</p>
</div>
<div v-if="canApproveDisposals" class="page-meta">
<p class="page-meta__label">待我审批</p>
<p class="page-meta__value text-status-warning">{{ pendingDisposals }}</p>
<p class="page-meta__subvalue">报废申请</p>
</div>
<div class="page-meta">
<p class="page-meta__label">今日归还</p>
<p class="page-meta__value">{{ formatNumber(stats?.today_return || 0) }}</p>
<p class="page-meta__subvalue">{{ formatCurrencyNoFraction(stats?.today_return_value || 0) }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">利用率</p>
<p class="page-meta__value">{{ formatPercent(stats?.usage_rate || 0) }}</p>
<p class="page-meta__subvalue">价值 {{ formatPercent(stats?.value_usage_rate || 0) }}</p>
</div>
</div>
</section>
<!-- 统计卡片 -->
<div class="grid gap-2 sm:gap-3 grid-cols-2 lg:grid-cols-4">
<div v-for="card in statCards.slice(0, 4)" :key="card.title" class="card">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary truncate">{{ card.title }}</p>
<p class="mt-2 text-lg sm:text-2xl font-semibold tracking-tight text-text-primary truncate">{{ card.value }}</p>
</div>
<div class="flex h-8 w-8 sm:h-10 sm:w-10 shrink-0 items-center justify-center rounded-lg bg-accent-primary/10">
<component :is="card.icon" class="h-4 w-4 sm:h-5 sm:w-5 text-accent-primary" />
</div>
</div>
<p class="mt-2 text-[10px] sm:text-xs text-text-secondary truncate">{{ card.subtitle }}</p>
</div>
</div>
<!-- 图表区域 -->
<div class="grid gap-4 lg:grid-cols-2">
<!-- 资产状态分布 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">资产状态分布</h3>
<span class="badge badge-neutral">实时</span>
</div>
<div ref="statusChartRef" class="h-64"></div>
<div class="mt-3 grid grid-cols-3 gap-2">
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">可用</p>
<p class="text-lg font-semibold text-status-success mt-1">{{ formatNumber(stats?.available_equipment || 0) }}</p>
</div>
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">借出</p>
<p class="text-lg font-semibold text-status-warning mt-1">{{ formatNumber(stats?.borrowed_equipment || 0) }}</p>
</div>
<div class="panel text-center">
<p class="text-xs text-text-tertiary uppercase">维护</p>
<p class="text-lg font-semibold text-status-error mt-1">{{ formatNumber(stats?.maintenance_equipment || 0) }}</p>
</div>
</div>
</section>
<!-- 库存结构 -->
<section class="card">
<div class="list-header">
<h3 class="section-title">库存结构</h3>
<span class="badge badge-neutral">TOP 5</span>
</div>
<div ref="categoryChartRef" class="h-64"></div>
<div class="mt-4 space-y-2">
<div
v-for="item in topCategories"
:key="item.equipment_type"
class="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div>
<p class="text-sm font-medium text-text-primary">{{ item.equipment_type }}</p>
<p class="text-xs text-text-tertiary">
<template v-if="item.is_child_type">
可用 {{ formatNumber(item.available_quantity) }} · 空闲可挂载 {{ formatNumber(item.mountable_available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</template>
<template v-else>
可用 {{ formatNumber(item.available_quantity) }} · 借出 {{ formatNumber(item.borrowed_quantity) }}
</template>
</p>
</div>
<span class="text-lg font-semibold text-text-primary">{{ formatNumber(item.total_quantity) }}</span>
</div>
<div v-if="topCategories.length === 0" class="text-sm text-text-secondary py-3 text-center">
暂无数据
</div>
</div>
</section>
</div>
<!-- 最近借用记录 -->
<div class="grid gap-6 lg:grid-cols-3">
<section class="card lg:col-span-2">
<div class="list-header">
<h3 class="section-title">最近借用记录</h3>
<router-link v-if="canBorrow" to="/borrow" class="btn-secondary text-sm">查看全部</router-link>
</div>
<!-- 移动端:卡片列表 -->
<div class="mobile-card-list md:hidden">
<article v-for="record in recentRecords" :key="record.id" class="mobile-card">
<div class="mobile-card__row">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text-primary truncate">{{ record.equipment_name }}</p>
<p class="text-xs text-text-tertiary font-mono">{{ record.equipment_code }}</p>
</div>
<span class="badge" :class="getStatusBadgeClass(record.status)">
{{ record.status }}
</span>
</div>
<div class="mobile-card__meta mt-3">
<div>
<p class="mobile-card__label">使用者</p>
<p class="mobile-card__value">{{ record.person_nickname }}</p>
</div>
<div>
<p class="mobile-card__label">位置</p>
<p class="mobile-card__value truncate">{{ record.storageLocation?.name || '-' }}</p>
</div>
<div class="col-span-2">
<p class="mobile-card__label">借用时间</p>
<p class="mobile-card__value">{{ formatDateTime(record.borrow_time) }}</p>
</div>
</div>
</article>
<div v-if="recentRecords.length === 0" class="empty-state">
暂无借用记录
</div>
</div>
<!-- 桌面端:表格 -->
<div class="hidden md:block">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>资产</th>
<th>使用者</th>
<th>位置</th>
<th>借用时间</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="record in recentRecords" :key="record.id">
<td>
<p class="font-medium text-text-primary">{{ record.equipment_name }}</p>
<p class="text-xs text-text-tertiary font-mono">{{ record.equipment_code }}</p>
</td>
<td>
<p class="text-text-primary">{{ record.person_nickname }}</p>
</td>
<td>
<p class="text-text-secondary">{{ record.storageLocation?.name || '-' }}</p>
</td>
<td class="text-text-secondary text-sm">
{{ formatDateTime(record.borrow_time) }}
</td>
<td>
<span
class="badge"
:class="getStatusBadgeClass(record.status)"
>
{{ record.status }}
</span>
</td>
</tr>
<tr v-if="recentRecords.length === 0">
<td colspan="5" class="text-center text-text-secondary py-8">暂无借用记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 运营提醒 -->
<section class="card">
<h3 class="section-title mb-3">运营概览</h3>
<div class="space-y-3">
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">总资产价值</p>
<p class="text-xl font-semibold text-text-primary mt-1">
{{ formatCurrencyNoFraction(stats?.total_value || 0) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">可用资产</p>
<p class="text-base font-semibold text-status-success mt-1">
{{ formatNumber(stats?.available_equipment || 0) }}
</p>
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.available_value || 0) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">借出资产</p>
<p class="text-base font-semibold text-status-warning mt-1">
{{ formatNumber(stats?.borrowed_equipment || 0) }} 件
</p>
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.borrowed_value || 0) }}
</p>
</div>
<div class="panel">
<p class="text-xs text-text-tertiary uppercase">维护中</p>
<p class="text-base font-semibold text-status-error mt-1">
{{ formatNumber(stats?.maintenance_equipment || 0) }} 件
</p>
<p class="text-xs text-text-secondary mt-0.5">
价值 {{ formatCurrencyNoFraction(stats?.maintenance_value || 0) }}
</p>
</div>
</div>
</section>
</div>
</template>
<template v-else>
<section class="page-header">
<div>
<p class="page-kicker">My Assets</p>
<h2 class="page-title">我的资产</h2>
<router-link to="/my-assets" class="btn-primary mt-3 text-sm">查看全部</router-link>
</div>
<div class="page-meta-grid">
<div class="page-meta">
<p class="page-meta__label">当前未归还</p>
<p class="page-meta__value">{{ myAssets.length }}</p>
</div>
<div class="page-meta">
<p class="page-meta__label">员工绑定</p>
<p class="page-meta__value text-base">{{ authStore.user?.person_bound ? '已绑定' : '未绑定' }}</p>
</div>
</div>
</section>
<section class="card">
<div class="list-header">
<h3 class="section-title">正在使用</h3>
<span class="badge badge-neutral">只读</span>
</div>
<p v-if="myAssetsLoading" class="empty-state">正在加载...</p>
<p v-else-if="myAssetsError" class="text-sm text-status-error">{{ myAssetsError }}</p>
<p v-else-if="myAssets.length === 0" class="empty-state">当前没有未归还资产。</p>
<div v-else class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<article v-for="record in myAssets.slice(0, 6)" :key="record.borrow_record_id" class="panel">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-text-primary">{{ record.equipment.equipment_name }}</p>
<p class="mt-1 font-mono text-xs text-text-tertiary">{{ record.equipment.equipment_code }}</p>
</div>
<span class="badge badge-warning">使用中</span>
</div>
<p class="mt-3 text-xs text-text-secondary">
预计归还:{{ record.is_long_term ? '长期使用' : record.expected_return_time ? formatDateTime(record.expected_return_time) : '未设置' }}
</p>
</article>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import * as echarts from 'echarts'
import { getDashboardStats } from '@/api/statistics'
import { getBorrowRecordList } from '@/api/borrow'
import type { DashboardStats, BorrowRecord } from '@/types'
import { getMyAssets, type MyAssetRecord } from '@/api/myAssets'
import { getDisposalList } from '@/api/disposal'
import { PERMISSIONS } from '@/auth/permissions'
import { formatCurrencyNoFraction, formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import {
BanknotesIcon,
ChartBarSquareIcon,
CheckCircleIcon,
CubeIcon,
WrenchScrewdriverIcon,
ArrowPathRoundedSquareIcon
} from '@heroicons/vue/24/outline'
const authStore = useAuthStore()
const stats = ref<DashboardStats | null>(null)
const recentRecords = ref<BorrowRecord[]>([])
const statusChartRef = ref<HTMLElement>()
const categoryChartRef = ref<HTMLElement>()
const isLoading = ref(false)
const pendingDisposals = ref(0)
const myAssets = ref<MyAssetRecord[]>([])
const myAssetsLoading = ref(false)
const myAssetsError = ref('')
let statusChart: echarts.ECharts | null = null
let categoryChart: echarts.ECharts | null = null
const canViewReports = computed(() => authStore.can(PERMISSIONS.REPORTS_VIEW))
const canViewAllAssets = computed(() => authStore.can(PERMISSIONS.ASSETS_VIEW_ALL))
const canBorrow = computed(() => authStore.can(PERMISSIONS.ASSETS_BORROW))
const canMaintain = computed(() => authStore.can(PERMISSIONS.ASSETS_MAINTENANCE))
const canApproveDisposals = computed(() => authStore.can(PERMISSIONS.DISPOSALS_APPROVE))
const statCards = computed(() => {
const data = stats.value
return [
{
title: '资产库存总量',
value: formatNumber(data?.total_equipment || 0),
icon: CubeIcon,
color: 'primary' as const,
subtitle: `总价值 ${formatCurrencyNoFraction(data?.total_value || 0)}`
},
{
title: '可用库存',
value: formatNumber(data?.available_equipment || 0),
icon: CheckCircleIcon,
color: 'success' as const,
subtitle: `可用价值 ${formatCurrencyNoFraction(data?.available_value || 0)}`
},
{
title: '已借出库存',
value: formatNumber(data?.borrowed_equipment || 0),
icon: ArrowPathRoundedSquareIcon,
color: 'warning' as const,
subtitle: `借出价值 ${formatCurrencyNoFraction(data?.borrowed_value || 0)}`
},
{
title: '维护中库存',
value: formatNumber(data?.maintenance_equipment || 0),
icon: WrenchScrewdriverIcon,
color: 'error' as const,
subtitle: `维护价值 ${formatCurrencyNoFraction(data?.maintenance_value || 0)}`
},
{
title: '今日借出',
value: formatNumber(data?.today_borrow || 0),
icon: ChartBarSquareIcon,
color: 'primary' as const,
subtitle: `数量利用率 ${formatPercent(data?.usage_rate || 0)}`
},
{
title: '今日归还',
value: formatNumber(data?.today_return || 0),
icon: BanknotesIcon,
color: 'success' as const,
subtitle: `价值利用率 ${formatPercent(data?.value_usage_rate || 0)}`
}
]
})
const topCategories = computed(() =>
[...(stats.value?.inventory_by_type || [])]
.sort((a, b) => b.total_quantity - a.total_quantity)
.slice(0, 5)
)
const fetchDashboardData = async () => {
isLoading.value = true
try {
const [statsResponse, recordsResponse] = await Promise.all([
getDashboardStats(),
getBorrowRecordList({ page: 1, pageSize: 8 })
])
if (statsResponse.data) {
stats.value = statsResponse.data
updateStatusChart()
updateCategoryChart()
}
if (recordsResponse.data) {
recentRecords.value = recordsResponse.data.list
}
} catch (error) {
console.error('获取仪表板数据失败:', error)
} finally {
isLoading.value = false
}
}
const fetchRoleSummary = async () => {
if (canApproveDisposals.value) {
try {
const response = await getDisposalList({ page: 1, pageSize: 1, scope: 'pending' })
pendingDisposals.value = response.data?.total || 0
} catch (error) {
console.error('获取待审批数量失败:', error)
}
}
}
const fetchMyAssets = async () => {
myAssetsLoading.value = true
myAssetsError.value = ''
try {
const response = await getMyAssets()
myAssets.value = response.data?.assets || []
} catch (error: any) {
myAssetsError.value = error?.message || '本人资产加载失败'
} finally {
myAssetsLoading.value = false
}
}
const initCharts = () => {
if (statusChartRef.value && !statusChart) {
statusChart = echarts.init(statusChartRef.value)
}
if (categoryChartRef.value && !categoryChart) {
categoryChart = echarts.init(categoryChartRef.value)
}
updateStatusChart()
updateCategoryChart()
}
const updateStatusChart = () => {
if (!statusChart || !stats.value) return
statusChart.setOption({
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
icon: 'circle',
textStyle: { color: '#6e6e6e' }
},
series: [
{
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '45%'],
padAngle: 3,
label: {
formatter: '{b}\n{d}%',
color: '#a1a1a1'
},
data: [
{ value: stats.value.available_equipment || 0, name: '可用', itemStyle: { color: '#10a37f' } },
{ value: stats.value.borrowed_equipment || 0, name: '已借出', itemStyle: { color: '#f59e0b' } },
{ value: stats.value.maintenance_equipment || 0, name: '维护中', itemStyle: { color: '#ef4444' } }
]
}
]
})
}
const updateCategoryChart = () => {
if (!categoryChart || !stats.value) return
const categories = topCategories.value
categoryChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: {
left: 0,
right: 0,
top: 10,
bottom: 10,
containLabel: true
},
xAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#6e6e6e' }
},
yAxis: {
type: 'category',
axisTick: { show: false },
axisLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#a1a1a1' },
data: categories.map((item) => item.equipment_type)
},
series: [
{
type: 'bar',
barWidth: 16,
data: categories.map((item, index) => ({
value: item.total_quantity,
itemStyle: {
borderRadius: [0, 8, 8, 0],
color: ['#10a37f', '#0d8c6d', '#19c59f', '#f59e0b', '#3b82f6'][index % 5]
}
}))
}
]
})
}
const getStatusBadgeClass = (status: string) => {
const classes: Record<string, string> = {
'已借出': 'badge-warning',
'已归还': 'badge-success',
'逾期': 'badge-error'
}
return classes[status] || 'badge-neutral'
}
const resizeHandler = () => {
statusChart?.resize()
categoryChart?.resize()
}
onMounted(async () => {
if (canViewReports.value) {
await Promise.all([fetchDashboardData(), fetchRoleSummary()])
initCharts()
window.addEventListener('resize', resizeHandler)
} else {
await fetchMyAssets()
}
})
onUnmounted(() => {
statusChart?.dispose()
categoryChart?.dispose()
window.removeEventListener('resize', resizeHandler)
})
</script>