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

657 lines
15 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">
<!-- 待办概览:今日 / 本周 / 本月 未完成 -->
<view class="dash-card">
<view class="dash-head">
<text class="dash-title">待办概览</text>
<text v-if="overdue > 0" class="dash-badge">逾期 {{ overdue }}</text>
</view>
<view class="todo-row">
<view class="todo-cell">
<text class="todo-value">{{ todayPending }}</text>
<text class="todo-label">今日待办</text>
<text class="todo-sub">共 {{ num(ss.today_total) }} · 完成 {{ num(ss.today_done) }}</text>
</view>
<view class="todo-cell">
<text class="todo-value">{{ weekPending }}</text>
<text class="todo-label">本周待办</text>
<text class="todo-sub">共 {{ num(ss.week_total) }} · 完成 {{ num(ss.week_done) }}</text>
</view>
<view class="todo-cell">
<text class="todo-value">{{ monthPending }}</text>
<text class="todo-label">本月待办</text>
<text class="todo-sub">共 {{ num(ss.month_total) }} · 完成 {{ num(ss.month_done) }}</text>
</view>
</view>
</view>
<!-- 我的报销:本周 / 本月 / 本年 已打款金额 -->
<view class="dash-card">
<view class="dash-head">
<text class="dash-title">我的报销</text>
</view>
<view class="money-row">
<view v-for="item in moneyCards" :key="item.label" class="money-cell">
<text class="money-label">{{ item.label }}</text>
<view class="money-amount">
<text class="currency">¥</text>
<text class="number">{{ money(item.data.paid) }}</text>
</view>
<text class="money-meta">{{ num(item.data.count) }} 单 · 待报 ¥{{ money(item.data.unpaid) }}</text>
</view>
</view>
</view>
<!-- 近 6 个月报销趋势(CSS 双色堆叠柱,移动端不引 echarts) -->
<view v-if="trend.length" class="dash-card">
<view class="dash-head">
<text class="dash-title">近6个月报销趋势</text>
<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 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>
<!-- 状态分布(CSS 进度条) -->
<view v-if="statusList.length" class="dash-card">
<view class="dash-head">
<text class="dash-title">状态分布</text>
</view>
<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 v-if="expenseTypes.length" class="dash-card">
<view class="dash-head">
<text class="dash-title">报销类型分析</text>
</view>
<view v-for="e in expenseTypesTop" :key="e.name" class="type-row">
<text class="type-name">{{ e.name }}</text>
<view class="type-bar-wrap">
<view class="type-bar" :style="{ width: typePct(e.amount) }" />
</view>
<text class="type-amount">¥{{ money(e.amount) }}</text>
</view>
</view>
<!-- 通知公告 -->
<view class="dash-card">
<view class="dash-head">
<text class="dash-title">通知公告</text>
<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>
<view
v-for="n in notices"
:key="n.id"
class="notice-item"
@tap="emit('view-notices')"
>
<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>
</view>
<!-- 工作日程提醒(今日) -->
<view class="dash-card">
<view class="dash-head">
<text class="dash-title">工作日程提醒</text>
<view class="dash-more" @tap="emit('view-schedules')">
<text class="dash-more-text">查看全部</text>
<FaIcon name="chevron-right" color="#94a3b8" :size="11" />
</view>
</view>
<view v-if="!todaySchedules.length" class="dash-empty">
<FaIcon name="calendar-day" color="#c0c4cc" :size="24" />
<text class="dash-empty-text">今日暂无日程</text>
</view>
<view
v-for="s in todaySchedules"
:key="s.id"
class="sched-item"
:class="{ done: s.status === 1 }"
@tap="emit('view-schedules')"
>
<view class="sched-dot" :style="{ background: s.status === 1 ? '#cbd5e1' : (s.color || '#2563eb') }" />
<view class="sched-main">
<view class="sched-title-row">
<text class="sched-title" :class="{ done: s.status === 1 }">{{ s.title }}</text>
<text v-if="s.priority > 0" class="sched-priority" :class="s.priority === 2 ? 'p-urgent' : 'p-important'">
{{ s.priority === 2 ? '紧急' : '重要' }}
</text>
</view>
<text class="sched-time">{{ schedTime(s) }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import FaIcon from '@/components/FaIcon.vue'
const props = defineProps({
data: { type: Object, default: () => ({}) }
})
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 expenseTypes = computed(() => rb.value.expense_types || [])
const expenseTypesTop = computed(() => expenseTypes.value.slice(0, 6))
const expMax = computed(() => {
let m = 1
expenseTypes.value.forEach((e) => { m = Math.max(m, num(e.amount)) })
return m
})
const typePct = (a) => Math.round((num(a) / expMax.value) * 100) + '%'
// ---------- 公告 / 日程 ----------
const notices = computed(() => props.data?.notices || [])
const todaySchedules = computed(() => props.data?.today_schedules || [])
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()}`
}
function schedTime(s) {
if (s.all_day === 1) return '全天'
if (s.end_time) return `${s.start_time || ''} - ${s.end_time}`
return s.start_time || '时间待定'
}
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;
margin-bottom: 32rpx;
}
.dash-card {
@include card;
padding: 26rpx 24rpx;
}
.dash-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.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;
}
/* ============ 待办概览 ============ */
.todo-row {
display: flex;
}
.todo-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
position: relative;
& + &::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
bottom: 8rpx;
width: 1rpx;
background: $color-divider;
}
}
.todo-value {
font-size: 44rpx;
font-weight: 600;
line-height: 1.1;
color: $color-primary;
}
.todo-label {
font-size: 24rpx;
color: $color-text;
}
.todo-sub {
font-size: 20rpx;
color: $color-text-muted;
}
/* ============ 报销统计 ============ */
.money-row {
display: flex;
gap: 16rpx;
}
.money-cell {
flex: 1;
padding: 20rpx 16rpx;
border-radius: $radius-md;
background: $color-bg-page;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.money-label {
font-size: 22rpx;
color: $color-text-muted;
}
.money-amount {
display: flex;
align-items: baseline;
.currency {
font-size: 22rpx;
color: $color-text;
margin-right: 4rpx;
}
.number {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
}
.money-meta {
font-size: 20rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
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;
}
/* ============ 分布 / 类型 进度条 ============ */
.dist-row,
.type-row {
display: flex;
align-items: center;
gap: 14rpx;
& + & {
margin-top: 18rpx;
}
}
.dist-name {
width: 96rpx;
flex-shrink: 0;
font-size: 22rpx;
}
.type-name {
width: 140rpx;
flex-shrink: 0;
font-size: 22rpx;
color: $color-text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-bar-wrap,
.type-bar-wrap {
flex: 1;
height: 14rpx;
background: $color-divider;
border-radius: $radius-full;
overflow: hidden;
}
.dist-bar,
.type-bar {
height: 100%;
border-radius: $radius-full;
transition: width 0.3s ease;
}
.type-bar {
background: linear-gradient(90deg, $color-primary, $color-primary-light);
}
.dist-count,
.type-amount {
flex-shrink: 0;
font-size: 20rpx;
color: $color-text-muted;
white-space: nowrap;
}
/* ============ 公告 ============ */
.notice-item {
display: flex;
align-items: center;
gap: 12rpx;
padding: 18rpx 0;
& + & {
border-top: 1rpx solid $color-divider;
}
&: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;
}
/* ============ 日程 ============ */
.sched-item {
display: flex;
align-items: flex-start;
gap: 14rpx;
padding: 18rpx 0;
& + & {
border-top: 1rpx solid $color-divider;
}
&.done {
opacity: 0.6;
}
}
.sched-dot {
width: 14rpx;
height: 14rpx;
margin-top: 12rpx;
border-radius: 50%;
flex-shrink: 0;
}
.sched-main {
flex: 1;
min-width: 0;
}
.sched-title-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.sched-title {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.done {
text-decoration: line-through;
color: $color-text-muted;
}
}
.sched-priority {
flex-shrink: 0;
font-size: 18rpx;
padding: 2rpx 10rpx;
border-radius: $radius-sm;
&.p-important {
background: #fffbeb;
color: $color-warning;
}
&.p-urgent {
background: #fef3f2;
color: $color-danger;
}
}
.sched-time {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: $color-text-muted;
}
</style>