585 lines
13 KiB
Vue
585 lines
13 KiB
Vue
<template>
|
||
<view class="oa-dash">
|
||
<!-- ============ 上半:公告 + 数据统计 ============ -->
|
||
<template v-if="part !== 'bottom'">
|
||
<!-- 通知公告 -->
|
||
<view class="dash-card">
|
||
<view class="dash-head">
|
||
<view class="dash-head-left">
|
||
<view class="head-icon hi-notice">
|
||
<FaIcon name="bullhorn" color="#2563eb" :size="12" />
|
||
</view>
|
||
<text class="dash-title">公告</text>
|
||
</view>
|
||
<view class="dash-more" @tap="emit('view-notices')">
|
||
<text class="dash-more-text">查看全部</text>
|
||
<FaIcon name="chevron-right" color="#94a3b8" :size="11" />
|
||
</view>
|
||
</view>
|
||
<view v-if="!notices.length" class="dash-empty">
|
||
<FaIcon name="bullhorn" color="#c0c4cc" :size="24" />
|
||
<text class="dash-empty-text">暂无公告</text>
|
||
</view>
|
||
<!-- 单行垂直轮播公告 -->
|
||
<swiper
|
||
v-else
|
||
class="notice-swiper"
|
||
vertical
|
||
autoplay
|
||
circular
|
||
:interval="3000"
|
||
:duration="500"
|
||
>
|
||
<swiper-item v-for="n in notices" :key="n.id" @tap="emit('view-notices')">
|
||
<view class="notice-line">
|
||
<view class="notice-main">
|
||
<text class="notice-tag" :class="'nt-' + (n.notice_type || 0)">
|
||
{{ noticeTypeLabel(n.notice_type) }}
|
||
</text>
|
||
<text v-if="n.is_top === 1" class="notice-top">置顶</text>
|
||
<text class="notice-title">{{ n.title }}</text>
|
||
</view>
|
||
<text class="notice-time">{{ noticeTime(n.publish_time) }}</text>
|
||
</view>
|
||
</swiper-item>
|
||
</swiper>
|
||
</view>
|
||
|
||
<!-- 数据统计:待办 + 报销 -->
|
||
<view class="dash-card">
|
||
<view class="dash-head">
|
||
<view class="dash-head-left">
|
||
<view class="head-icon hi-stat">
|
||
<FaIcon name="chart-column" color="#2563eb" :size="12" />
|
||
</view>
|
||
<text class="dash-title">数据统计</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 待办 -->
|
||
<view class="stat-tile" @tap="emit('view-schedules')">
|
||
<view class="stat-tile-head">
|
||
<view class="tile-icon ti-todo">
|
||
<FaIcon name="clipboard-list" color="#2563eb" :size="11" />
|
||
</view>
|
||
<text class="tile-name">待办</text>
|
||
<text v-if="overdue > 0" class="dash-badge">逾期 {{ overdue }}</text>
|
||
<FaIcon name="chevron-right" color="#c0c4cc" :size="10" />
|
||
</view>
|
||
<view class="stat-row">
|
||
<view class="stat-cell">
|
||
<text class="stat-value primary">{{ todayPending }}</text>
|
||
<text class="stat-label">今日</text>
|
||
<text class="stat-sub">共 {{ num(ss.today_total) }} · 完成 {{ num(ss.today_done) }}</text>
|
||
</view>
|
||
<view class="stat-cell">
|
||
<text class="stat-value primary">{{ weekPending }}</text>
|
||
<text class="stat-label">本周</text>
|
||
<text class="stat-sub">共 {{ num(ss.week_total) }} · 完成 {{ num(ss.week_done) }}</text>
|
||
</view>
|
||
<view class="stat-cell">
|
||
<text class="stat-value primary">{{ monthPending }}</text>
|
||
<text class="stat-label">本月</text>
|
||
<text class="stat-sub">共 {{ num(ss.month_total) }} · 完成 {{ num(ss.month_done) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 报销 -->
|
||
<view class="stat-tile">
|
||
<view class="stat-tile-head">
|
||
<view class="tile-icon ti-money">
|
||
<FaIcon name="money-bill-wave" color="#10b981" :size="11" />
|
||
</view>
|
||
<text class="tile-name">报销</text>
|
||
</view>
|
||
<view class="stat-row">
|
||
<view v-for="item in moneyCards" :key="item.label" class="stat-cell">
|
||
<view class="stat-amount">
|
||
<text class="stat-currency">¥</text>
|
||
<text class="stat-value">{{ money(item.data.paid) }}</text>
|
||
</view>
|
||
<text class="stat-label">{{ item.label }}</text>
|
||
<text class="stat-sub">{{ num(item.data.count) }} 单 · 待报 ¥{{ money(item.data.unpaid) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<!-- ============ 下半:报销趋势 + 状态分布 ============ -->
|
||
<template v-else>
|
||
<view v-if="trend.length || statusList.length" class="dash-card">
|
||
<view class="dash-head">
|
||
<view class="dash-head-left">
|
||
<view class="head-icon hi-trend">
|
||
<FaIcon name="chart-column" color="#8b5cf6" :size="12" />
|
||
</view>
|
||
<text class="dash-title">近6个月报销趋势</text>
|
||
</view>
|
||
<view class="legend">
|
||
<view class="legend-item"><view class="legend-dot paid" /><text>已报销</text></view>
|
||
<view class="legend-item"><view class="legend-dot unpaid" /><text>未报销</text></view>
|
||
</view>
|
||
</view>
|
||
<view v-if="trend.length" class="trend-chart">
|
||
<view v-for="t in trend" :key="t.month" class="trend-col">
|
||
<view class="trend-bar-wrap">
|
||
<view class="trend-bar unpaid" :style="{ height: barH(t.unpaid) }" />
|
||
<view class="trend-bar paid" :style="{ height: barH(t.paid) }" />
|
||
</view>
|
||
<text class="trend-month">{{ monthLabel(t.month) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 状态分布 -->
|
||
<view v-if="statusList.length" class="status-block">
|
||
<text class="status-title">状态分布</text>
|
||
<view v-for="s in statusList" :key="s.key" class="dist-row">
|
||
<text class="dist-name" :style="{ color: statusColor(s.key) }">{{ s.name }}</text>
|
||
<view class="dist-bar-wrap">
|
||
<view class="dist-bar" :style="{ width: s.pct, background: statusColor(s.key) }" />
|
||
</view>
|
||
<text class="dist-count">{{ num(s.count) }}单 / ¥{{ money(s.amount) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
import FaIcon from '@/components/FaIcon.vue'
|
||
|
||
const props = defineProps({
|
||
data: { type: Object, default: () => ({}) },
|
||
// top:公告 + 数据统计;bottom:报销趋势 + 状态分布(中间由首页插入「常用功能」)
|
||
part: { type: String, default: 'top' }
|
||
})
|
||
const emit = defineEmits(['view-notices', 'view-schedules'])
|
||
|
||
const num = (v) => Number(v || 0)
|
||
|
||
// ---------- 待办概览 ----------
|
||
const ss = computed(() => props.data?.schedule_stats || {})
|
||
const overdue = computed(() => num(ss.value.overdue))
|
||
const todayPending = computed(() => num(ss.value.today_total) - num(ss.value.today_done))
|
||
const weekPending = computed(() => num(ss.value.week_total) - num(ss.value.week_done))
|
||
const monthPending = computed(() => num(ss.value.month_total) - num(ss.value.month_done))
|
||
|
||
// ---------- 报销 ----------
|
||
const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 })
|
||
const rb = computed(() => props.data?.reimburse || {})
|
||
const moneyCards = computed(() => [
|
||
{ label: '本周', data: rb.value.week || emptyStat() },
|
||
{ label: '本月', data: rb.value.month || emptyStat() },
|
||
{ label: '本年', data: rb.value.year || emptyStat() }
|
||
])
|
||
|
||
// ---------- 趋势(近6月,堆叠柱) ----------
|
||
const trend = computed(() => rb.value.trend || [])
|
||
const trendMax = computed(() => {
|
||
let m = 1
|
||
trend.value.forEach((t) => { m = Math.max(m, num(t.total)) })
|
||
return m
|
||
})
|
||
const MAX_BAR = 160
|
||
const barH = (v) => Math.round((num(v) / trendMax.value) * MAX_BAR) + 'rpx'
|
||
const monthLabel = (m) => String(m || '').slice(5) + '月'
|
||
|
||
// ---------- 状态分布 ----------
|
||
const statusColors = { 0: '#94a3b8', 1: '#f59e0b', 2: '#10b981', 3: '#ef4444', 4: '#94a3b8', 5: '#2563eb' }
|
||
const statusColor = (k) => statusColors[Number(k)] || '#94a3b8'
|
||
const statusList = computed(() => {
|
||
const d = rb.value.status_dist || {}
|
||
const arr = Object.keys(d).map((k) => ({ key: Number(k), name: d[k].name, count: d[k].count, amount: d[k].amount }))
|
||
const total = arr.reduce((sum, x) => sum + num(x.count), 0) || 1
|
||
return arr
|
||
.sort((a, b) => a.key - b.key)
|
||
.map((x) => ({ ...x, pct: Math.round((num(x.count) / total) * 100) + '%' }))
|
||
})
|
||
|
||
// ---------- 公告 ----------
|
||
const notices = computed(() => props.data?.notices || [])
|
||
|
||
const noticeTypeLabel = (type) => ({ 1: '公告', 2: '活动' }[type] || '通知')
|
||
|
||
const pad2 = (n) => String(n).padStart(2, '0')
|
||
const fmtDate = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||
// 公告右侧时间:今天显示 HH:mm,本年显示 M/D,跨年显示 YYYY-M-D
|
||
function noticeTime(ts) {
|
||
if (!ts) return ''
|
||
const d = new Date(String(ts).replace(/-/g, '/'))
|
||
if (Number.isNaN(d.getTime())) return String(ts).slice(5, 10)
|
||
const now = new Date()
|
||
if (fmtDate(d) === fmtDate(now)) return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
||
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}/${d.getDate()}`
|
||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
|
||
}
|
||
|
||
const money = (value) =>
|
||
Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import '@/src/styles/page-common.scss';
|
||
|
||
.oa-dash {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
.dash-card {
|
||
@include card;
|
||
padding: 26rpx 24rpx;
|
||
}
|
||
|
||
.dash-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 22rpx;
|
||
}
|
||
|
||
.dash-head-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.head-icon {
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
border-radius: 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&.hi-notice,
|
||
&.hi-stat {
|
||
background: $color-primary-bg;
|
||
}
|
||
|
||
&.hi-trend {
|
||
background: #f3f0ff;
|
||
}
|
||
}
|
||
|
||
.dash-title {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.dash-badge {
|
||
font-size: 22rpx;
|
||
color: $color-danger;
|
||
background: #fef3f2;
|
||
padding: 4rpx 14rpx;
|
||
border-radius: $radius-full;
|
||
}
|
||
|
||
.dash-more {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.dash-more-text {
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.dash-empty {
|
||
padding: 36rpx 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.dash-empty-text {
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
/* ============ 数据统计:待办 / 报销 双磁贴 ============ */
|
||
.stat-tile {
|
||
padding: 20rpx 20rpx 8rpx;
|
||
border-radius: $radius-md;
|
||
background: $color-bg-page;
|
||
|
||
& + & {
|
||
margin-top: 18rpx;
|
||
}
|
||
|
||
&:active {
|
||
opacity: 0.85;
|
||
}
|
||
}
|
||
|
||
.stat-tile-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.tile-icon {
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
border-radius: 10rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&.ti-todo {
|
||
background: $color-primary-bg;
|
||
}
|
||
|
||
&.ti-money {
|
||
background: #ecfdf5;
|
||
}
|
||
}
|
||
|
||
.tile-name {
|
||
flex: 1;
|
||
font-size: 24rpx;
|
||
font-weight: 600;
|
||
color: $color-text-secondary;
|
||
}
|
||
|
||
.stat-row {
|
||
display: flex;
|
||
}
|
||
|
||
.stat-cell {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
padding-bottom: 12rpx;
|
||
}
|
||
|
||
.stat-amount {
|
||
display: flex;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.stat-currency {
|
||
font-size: 20rpx;
|
||
color: $color-text;
|
||
margin-right: 2rpx;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
line-height: 1.15;
|
||
color: $color-text;
|
||
|
||
&.primary {
|
||
color: $color-primary;
|
||
}
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 22rpx;
|
||
color: $color-text-secondary;
|
||
}
|
||
|
||
.stat-sub {
|
||
font-size: 18rpx;
|
||
color: $color-text-muted;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ============ 趋势柱状 ============ */
|
||
.legend {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
font-size: 20rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
border-radius: 4rpx;
|
||
|
||
&.paid {
|
||
background: $color-success;
|
||
}
|
||
|
||
&.unpaid {
|
||
background: $color-warning;
|
||
}
|
||
}
|
||
|
||
.trend-chart {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
height: 220rpx;
|
||
padding-top: 8rpx;
|
||
}
|
||
|
||
.trend-col {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
height: 100%;
|
||
}
|
||
|
||
.trend-bar-wrap {
|
||
flex: 1;
|
||
width: 44rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
}
|
||
|
||
.trend-bar {
|
||
width: 100%;
|
||
min-height: 2rpx;
|
||
|
||
&.unpaid {
|
||
background: $color-warning;
|
||
border-radius: 6rpx 6rpx 0 0;
|
||
}
|
||
|
||
&.paid {
|
||
background: $color-success;
|
||
}
|
||
}
|
||
|
||
.trend-month {
|
||
margin-top: 10rpx;
|
||
font-size: 20rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
/* ============ 状态分布 ============ */
|
||
.status-block {
|
||
margin-top: 26rpx;
|
||
padding-top: 22rpx;
|
||
border-top: 1rpx solid $color-divider;
|
||
}
|
||
|
||
.status-title {
|
||
display: block;
|
||
margin-bottom: 16rpx;
|
||
font-size: 24rpx;
|
||
font-weight: 600;
|
||
color: $color-text-secondary;
|
||
}
|
||
|
||
.dist-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14rpx;
|
||
|
||
& + & {
|
||
margin-top: 18rpx;
|
||
}
|
||
}
|
||
|
||
.dist-name {
|
||
width: 96rpx;
|
||
flex-shrink: 0;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
.dist-bar-wrap {
|
||
flex: 1;
|
||
height: 14rpx;
|
||
background: $color-divider;
|
||
border-radius: $radius-full;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dist-bar {
|
||
height: 100%;
|
||
border-radius: $radius-full;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.dist-count {
|
||
flex-shrink: 0;
|
||
font-size: 20rpx;
|
||
color: $color-text-muted;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ============ 公告(单行垂直轮播) ============ */
|
||
.notice-swiper {
|
||
height: 68rpx;
|
||
}
|
||
|
||
.notice-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
height: 68rpx;
|
||
|
||
&:active {
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
|
||
.notice-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
}
|
||
|
||
.notice-tag {
|
||
flex-shrink: 0;
|
||
font-size: 20rpx;
|
||
padding: 2rpx 12rpx;
|
||
border-radius: $radius-sm;
|
||
background: $color-primary-bg;
|
||
color: $color-primary;
|
||
|
||
&.nt-2 {
|
||
background: #ecfdf5;
|
||
color: $color-success;
|
||
}
|
||
}
|
||
|
||
.notice-top {
|
||
flex-shrink: 0;
|
||
font-size: 20rpx;
|
||
color: $color-danger;
|
||
}
|
||
|
||
.notice-title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 26rpx;
|
||
color: $color-text;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.notice-time {
|
||
flex-shrink: 0;
|
||
font-size: 20rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
</style>
|