| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- <template>
- <div v-loading="loading" class="oa-page" element-loading-text="正在加载合同到期预警数据…">
- <ContentWrap>
- <div class="page-header">
- <div class="header-title">
- <Icon icon="ep:alarm-clock" class="title-icon" />
- <span>合同到期预警</span>
- </div>
- <div class="header-extra">
- <span class="update-time">更新于 {{ updateTime }}</span>
- <el-button type="primary" :icon="Refresh" @click="loadAll">刷新数据</el-button>
- </div>
- </div>
- <!-- 筛选区 -->
- <el-form :inline="true" class="filter-form" @submit.prevent>
- <el-form-item label="统计院区">
- <el-select
- v-model="selectedCampusIds"
- multiple
- collapse-tags
- collapse-tags-tooltip
- clearable
- filterable
- placeholder="全部院区"
- class="campus-select"
- >
- <el-option
- v-for="item in campusOptions"
- :key="item.value"
- :label="item.label"
- :value="item.value"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="预警阈值">
- <el-select v-model="warningDays" class="days-select">
- <el-option v-for="d in dayOptions" :key="d" :label="`${d} 天内`" :value="d" />
- </el-select>
- </el-form-item>
- <el-form-item>
- <el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
- <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
- <el-button type="success" :icon="Download" :disabled="!hasData" @click="handleExport">
- 导出 Excel
- </el-button>
- </el-form-item>
- </el-form>
- <el-alert
- v-if="loadFailed"
- type="warning"
- :closable="false"
- show-icon
- title="合同到期预警数据加载失败,请稍后刷新重试"
- class="api-tip"
- />
- <!-- KPI -->
- <div class="kpi-grid">
- <div v-for="card in kpiCards" :key="card.label" class="kpi-card" :class="card.theme">
- <div class="kpi-label">
- <Icon :icon="card.icon" />
- <span>{{ card.label }}</span>
- </div>
- <div class="kpi-value">
- {{ card.value }}
- <span class="kpi-unit">{{ card.unit }}</span>
- </div>
- </div>
- </div>
- </ContentWrap>
- <el-row :gutter="12" class="chart-row">
- <el-col :xs="24" :lg="12">
- <ContentWrap>
- <div class="panel-title">合同到期分布</div>
- <Echart v-if="hasData" :options="distributionOption" height="340px" />
- <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
- </ContentWrap>
- </el-col>
- <el-col :xs="24" :lg="12">
- <ContentWrap>
- <div class="panel-title">未来月度到期趋势</div>
- <Echart v-if="hasData" :options="monthTrendOption" height="340px" />
- <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
- </ContentWrap>
- </el-col>
- </el-row>
- <ContentWrap class="table-wrap">
- <div class="panel-title">合同到期明细</div>
- <el-table :data="pagedList" border stripe>
- <el-table-column type="index" label="序号" width="60" align="center" />
- <el-table-column prop="elderName" label="长者姓名" min-width="110" show-overflow-tooltip />
- <el-table-column label="性别" width="70" align="center">
- <template #default="{ row }">{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '-' }}</template>
- </el-table-column>
- <el-table-column prop="age" label="年龄" width="70" align="center">
- <template #default="{ row }">{{ row.age ?? '-' }}</template>
- </el-table-column>
- <el-table-column prop="tenantName" label="所属机构" min-width="150" show-overflow-tooltip />
- <el-table-column prop="bedName" label="床位" min-width="110" align="center">
- <template #default="{ row }">{{ row.bedName || '-' }}</template>
- </el-table-column>
- <el-table-column prop="contractStartDate" label="合同起期" min-width="120" align="center" />
- <el-table-column prop="contractEndDate" label="合同止期" min-width="120" align="center" />
- <el-table-column label="剩余天数" width="120" align="center" sortable>
- <template #default="{ row }">
- <el-tag :type="remainTag(row.remainDays)" size="small" effect="light">
- {{ remainText(row.remainDays) }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="contactName" label="联系人" min-width="100" align="center">
- <template #default="{ row }">{{ row.contactName || '-' }}</template>
- </el-table-column>
- <el-table-column prop="contactPhone" label="联系电话" min-width="130" align="center">
- <template #default="{ row }">{{ row.contactPhone || '-' }}</template>
- </el-table-column>
- </el-table>
- <div class="pager">
- <el-pagination
- v-model:current-page="pageNo"
- v-model:page-size="pageSize"
- :total="list.length"
- :page-sizes="[10, 20, 50]"
- layout="total, sizes, prev, pager, next"
- background
- />
- </div>
- </ContentWrap>
- </div>
- </template>
- <script lang="ts" setup>
- import { Download, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
- import Echart from '@/components/Echart/src/Echart.vue'
- import { exportWithCustomHeaders } from '@/utils/excel-export'
- import { getCampusOptions } from '../../overview/campus-data'
- import { fetchContractExpiry, type ContractExpiryData } from '../occupancy-api'
- defineOptions({ name: 'OaOccupancyContractExpiry' })
- const message = useMessage()
- const campusOptions = getCampusOptions()
- const loading = ref(false)
- const loadFailed = ref(false)
- const updateTime = ref('')
- const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
- const warningDays = ref<number>(90)
- const dayOptions = [30, 60, 90, 180]
- const data = ref<ContractExpiryData | null>(null)
- const hasData = computed(() => (list.value.length ?? 0) > 0 || (data.value?.summary?.length ?? 0) > 0)
- const pageNo = ref(1)
- const pageSize = ref(10)
- const list = computed(() => data.value?.list || [])
- const pagedList = computed(() => {
- const start = (pageNo.value - 1) * pageSize.value
- return list.value.slice(start, start + pageSize.value)
- })
- const pad2 = (n: number) => String(n).padStart(2, '0')
- const remainText = (days: number) => {
- if (days < 0) return `逾期${-days}天`
- if (days === 0) return '今日到期'
- return `剩${days}天`
- }
- const remainTag = (days: number) => {
- if (days < 0) return 'danger' as const
- if (days <= 30) return 'danger' as const
- if (days <= 60) return 'warning' as const
- if (days <= 90) return 'primary' as const
- return 'success' as const
- }
- /* ---------------- KPI ---------------- */
- const kpiCards = computed(() => {
- const summary = data.value?.summary || []
- const find = (d: number) => summary.find((s) => s.days === d)
- const expired = summary.reduce((s, i) => s + (i.expiredCount || 0), 0)
- return [
- { label: `${dayOptions[0]}天内到期`, value: find(dayOptions[0])?.expiringCount || 0, unit: '份', icon: 'ep:timer', theme: 'orange' },
- { label: `${dayOptions[1]}天内到期`, value: find(dayOptions[1])?.expiringCount || 0, unit: '份', icon: 'ep:timer', theme: 'cyan' },
- { label: `${dayOptions[2]}天内到期`, value: find(dayOptions[2])?.expiringCount || 0, unit: '份', icon: 'ep:calendar', theme: 'blue' },
- { label: '已逾期合同', value: expired || 0, unit: '份', icon: 'ep:warning', theme: 'purple' }
- ]
- })
- /* ---------------- 图表 ---------------- */
- const PIE_COLORS = ['#f56c6c', '#e6a23c', '#f0a020', '#409eff', '#67c23a', '#8b5cf6', '#909399']
- const distributionOption = computed(() => {
- const buckets = (data.value?.distributionBuckets || []).filter((b) => b.count > 0)
- return {
- tooltip: { trigger: 'item', formatter: '{b}:{c} 份({d}%)' },
- legend: { bottom: 0, icon: 'circle' },
- series: [
- {
- type: 'pie',
- radius: ['45%', '70%'],
- center: ['50%', '46%'],
- label: { formatter: '{b}\n{c} 份' },
- data: buckets.map((b, i) => ({
- name: b.label,
- value: b.count,
- itemStyle: {
- color: b.label.includes('逾期') || b.label === '已过期' ? '#f56c6c' : PIE_COLORS[i % PIE_COLORS.length]
- }
- }))
- }
- ]
- }
- })
- const monthTrendOption = computed(() => {
- const items = data.value?.monthTrend || []
- return {
- tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
- grid: { left: 48, right: 48, top: 24, bottom: 30 },
- xAxis: { type: 'category', data: items.map((i) => i.month), axisLabel: { rotate: 30 } },
- yAxis: { type: 'value', name: '份' },
- series: [
- {
- name: '到期份数',
- type: 'line',
- smooth: true,
- symbolSize: 7,
- data: items.map((i) => i.count),
- lineStyle: { width: 2.5, color: '#e6a23c' },
- itemStyle: { color: '#e6a23c' },
- areaStyle: { color: 'rgba(230,162,60,0.12)' }
- }
- ]
- }
- })
- /* ---------------- 加载与导出 ---------------- */
- const loadAll = async () => {
- const ids = selectedCampusIds.value.length
- ? selectedCampusIds.value
- : campusOptions.map((item) => item.value)
- loading.value = true
- loadFailed.value = false
- pageNo.value = 1
- try {
- data.value = await fetchContractExpiry({ tenantIds: ids, days: [warningDays.value] })
- const now = new Date()
- updateTime.value = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())} ${pad2(
- now.getHours()
- )}:${pad2(now.getMinutes())}`
- } catch {
- loadFailed.value = true
- data.value = null
- } finally {
- loading.value = false
- }
- }
- const handleQuery = () => {
- loadAll()
- }
- const handleReset = () => {
- selectedCampusIds.value = campusOptions.map((item) => item.value)
- warningDays.value = 90
- loadAll()
- }
- const handleExport = () => {
- const rows = list.value.map((r) => ({
- ...r,
- genderText: r.gender === 1 ? '男' : r.gender === 2 ? '女' : '-',
- remainText: remainText(r.remainDays)
- }))
- const headers = [
- { key: 'elderName', title: '长者姓名', width: 14 },
- { key: 'genderText', title: '性别', width: 8 },
- { key: 'age', title: '年龄', width: 8 },
- { key: 'tenantName', title: '所属机构', width: 22 },
- { key: 'bedName', title: '床位', width: 12 },
- { key: 'contractStartDate', title: '合同起期', width: 14 },
- { key: 'contractEndDate', title: '合同止期', width: 14 },
- { key: 'remainText', title: '剩余天数', width: 12 },
- { key: 'contactName', title: '联系人', width: 12 },
- { key: 'contactPhone', title: '联系电话', width: 16 }
- ]
- exportWithCustomHeaders(rows, headers, `合同到期预警_${new Date().toISOString().slice(0, 10)}.xlsx`, '合同到期明细')
- message.success('导出成功')
- }
- onMounted(() => {
- loadAll()
- })
- </script>
- <style lang="scss" scoped>
- .oa-page {
- .page-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 14px;
- .header-title {
- display: flex;
- align-items: center;
- font-size: 18px;
- font-weight: 600;
- color: #303133;
- .title-icon {
- margin-right: 8px;
- font-size: 22px;
- color: #409eff;
- }
- }
- .header-extra {
- display: flex;
- align-items: center;
- }
- .update-time {
- margin-right: 14px;
- font-size: 13px;
- color: #909399;
- }
- }
- .filter-form {
- margin-top: 4px;
- .campus-select {
- width: 300px;
- max-width: 56vw;
- }
- .days-select {
- width: 140px;
- }
- }
- .api-tip {
- margin: 4px 0 14px;
- }
- .kpi-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 12px;
- @media (max-width: 1200px) {
- grid-template-columns: repeat(2, 1fr);
- }
- @media (max-width: 768px) {
- grid-template-columns: 1fr;
- }
- .kpi-card {
- padding: 16px 18px;
- border-radius: 8px;
- color: #fff;
- .kpi-label {
- display: flex;
- align-items: center;
- font-size: 13px;
- .el-icon {
- margin-right: 6px;
- font-size: 16px;
- }
- }
- .kpi-value {
- margin-top: 8px;
- font-size: 28px;
- font-weight: 700;
- line-height: 1.15;
- .kpi-unit {
- margin-left: 4px;
- font-size: 13px;
- font-weight: 400;
- opacity: 0.85;
- }
- }
- &.blue {
- background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
- }
- &.cyan {
- background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
- }
- &.orange {
- background: linear-gradient(135deg, #ffb066 0%, #f5851d 100%);
- }
- &.purple {
- background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
- }
- }
- }
- .chart-row,
- .table-wrap {
- margin-top: 12px;
- }
- .chart-empty {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 300px;
- }
- .panel-title {
- position: relative;
- padding-left: 10px;
- margin-bottom: 12px;
- font-size: 15px;
- font-weight: 600;
- color: #303133;
- &::before {
- content: '';
- position: absolute;
- left: 0;
- top: 2px;
- width: 4px;
- height: 16px;
- background: #409eff;
- border-radius: 2px;
- }
- }
- .pager {
- display: flex;
- justify-content: flex-end;
- margin-top: 12px;
- }
- }
- </style>
|