Files
yunzerwebsiteallinone/uniapp/components/sub/SubOaDashboard.vue
T

585 lines
13 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>
<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>