index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <template>
  2. <div v-loading="loading" class="oa-page" element-loading-text="正在加载合同到期预警数据…">
  3. <ContentWrap>
  4. <div class="page-header">
  5. <div class="header-title">
  6. <Icon icon="ep:alarm-clock" class="title-icon" />
  7. <span>合同到期预警</span>
  8. </div>
  9. <div class="header-extra">
  10. <span class="update-time">更新于 {{ updateTime }}</span>
  11. <el-button type="primary" :icon="Refresh" @click="loadAll">刷新数据</el-button>
  12. </div>
  13. </div>
  14. <!-- 筛选区 -->
  15. <el-form :inline="true" class="filter-form" @submit.prevent>
  16. <el-form-item label="统计院区">
  17. <el-select
  18. v-model="selectedCampusIds"
  19. multiple
  20. collapse-tags
  21. collapse-tags-tooltip
  22. clearable
  23. filterable
  24. placeholder="全部院区"
  25. class="campus-select"
  26. >
  27. <el-option
  28. v-for="item in campusOptions"
  29. :key="item.value"
  30. :label="item.label"
  31. :value="item.value"
  32. />
  33. </el-select>
  34. </el-form-item>
  35. <el-form-item label="预警阈值">
  36. <el-select v-model="warningDays" class="days-select">
  37. <el-option v-for="d in dayOptions" :key="d" :label="`${d} 天内`" :value="d" />
  38. </el-select>
  39. </el-form-item>
  40. <el-form-item>
  41. <el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
  42. <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
  43. <el-button type="success" :icon="Download" :disabled="!hasData" @click="handleExport">
  44. 导出 Excel
  45. </el-button>
  46. </el-form-item>
  47. </el-form>
  48. <el-alert
  49. v-if="loadFailed"
  50. type="warning"
  51. :closable="false"
  52. show-icon
  53. title="合同到期预警数据加载失败,请稍后刷新重试"
  54. class="api-tip"
  55. />
  56. <!-- KPI -->
  57. <div class="kpi-grid">
  58. <div v-for="card in kpiCards" :key="card.label" class="kpi-card" :class="card.theme">
  59. <div class="kpi-label">
  60. <Icon :icon="card.icon" />
  61. <span>{{ card.label }}</span>
  62. </div>
  63. <div class="kpi-value">
  64. {{ card.value }}
  65. <span class="kpi-unit">{{ card.unit }}</span>
  66. </div>
  67. </div>
  68. </div>
  69. </ContentWrap>
  70. <el-row :gutter="12" class="chart-row">
  71. <el-col :xs="24" :lg="12">
  72. <ContentWrap>
  73. <div class="panel-title">合同到期分布</div>
  74. <Echart v-if="hasData" :options="distributionOption" height="340px" />
  75. <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
  76. </ContentWrap>
  77. </el-col>
  78. <el-col :xs="24" :lg="12">
  79. <ContentWrap>
  80. <div class="panel-title">未来月度到期趋势</div>
  81. <Echart v-if="hasData" :options="monthTrendOption" height="340px" />
  82. <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
  83. </ContentWrap>
  84. </el-col>
  85. </el-row>
  86. <ContentWrap class="table-wrap">
  87. <div class="panel-title">合同到期明细</div>
  88. <el-table :data="pagedList" border stripe>
  89. <el-table-column type="index" label="序号" width="60" align="center" />
  90. <el-table-column prop="elderName" label="长者姓名" min-width="110" show-overflow-tooltip />
  91. <el-table-column label="性别" width="70" align="center">
  92. <template #default="{ row }">{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '-' }}</template>
  93. </el-table-column>
  94. <el-table-column prop="age" label="年龄" width="70" align="center">
  95. <template #default="{ row }">{{ row.age ?? '-' }}</template>
  96. </el-table-column>
  97. <el-table-column prop="tenantName" label="所属机构" min-width="150" show-overflow-tooltip />
  98. <el-table-column prop="bedName" label="床位" min-width="110" align="center">
  99. <template #default="{ row }">{{ row.bedName || '-' }}</template>
  100. </el-table-column>
  101. <el-table-column prop="contractStartDate" label="合同起期" min-width="120" align="center" />
  102. <el-table-column prop="contractEndDate" label="合同止期" min-width="120" align="center" />
  103. <el-table-column label="剩余天数" width="120" align="center" sortable>
  104. <template #default="{ row }">
  105. <el-tag :type="remainTag(row.remainDays)" size="small" effect="light">
  106. {{ remainText(row.remainDays) }}
  107. </el-tag>
  108. </template>
  109. </el-table-column>
  110. <el-table-column prop="contactName" label="联系人" min-width="100" align="center">
  111. <template #default="{ row }">{{ row.contactName || '-' }}</template>
  112. </el-table-column>
  113. <el-table-column prop="contactPhone" label="联系电话" min-width="130" align="center">
  114. <template #default="{ row }">{{ row.contactPhone || '-' }}</template>
  115. </el-table-column>
  116. </el-table>
  117. <div class="pager">
  118. <el-pagination
  119. v-model:current-page="pageNo"
  120. v-model:page-size="pageSize"
  121. :total="list.length"
  122. :page-sizes="[10, 20, 50]"
  123. layout="total, sizes, prev, pager, next"
  124. background
  125. />
  126. </div>
  127. </ContentWrap>
  128. </div>
  129. </template>
  130. <script lang="ts" setup>
  131. import { Download, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
  132. import Echart from '@/components/Echart/src/Echart.vue'
  133. import { exportWithCustomHeaders } from '@/utils/excel-export'
  134. import { getCampusOptions } from '../../overview/campus-data'
  135. import { fetchContractExpiry, type ContractExpiryData } from '../occupancy-api'
  136. defineOptions({ name: 'OaOccupancyContractExpiry' })
  137. const message = useMessage()
  138. const campusOptions = getCampusOptions()
  139. const loading = ref(false)
  140. const loadFailed = ref(false)
  141. const updateTime = ref('')
  142. const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
  143. const warningDays = ref<number>(90)
  144. const dayOptions = [30, 60, 90, 180]
  145. const data = ref<ContractExpiryData | null>(null)
  146. const hasData = computed(() => (list.value.length ?? 0) > 0 || (data.value?.summary?.length ?? 0) > 0)
  147. const pageNo = ref(1)
  148. const pageSize = ref(10)
  149. const list = computed(() => data.value?.list || [])
  150. const pagedList = computed(() => {
  151. const start = (pageNo.value - 1) * pageSize.value
  152. return list.value.slice(start, start + pageSize.value)
  153. })
  154. const pad2 = (n: number) => String(n).padStart(2, '0')
  155. const remainText = (days: number) => {
  156. if (days < 0) return `逾期${-days}天`
  157. if (days === 0) return '今日到期'
  158. return `剩${days}天`
  159. }
  160. const remainTag = (days: number) => {
  161. if (days < 0) return 'danger' as const
  162. if (days <= 30) return 'danger' as const
  163. if (days <= 60) return 'warning' as const
  164. if (days <= 90) return 'primary' as const
  165. return 'success' as const
  166. }
  167. /* ---------------- KPI ---------------- */
  168. const kpiCards = computed(() => {
  169. const summary = data.value?.summary || []
  170. const find = (d: number) => summary.find((s) => s.days === d)
  171. const expired = summary.reduce((s, i) => s + (i.expiredCount || 0), 0)
  172. return [
  173. { label: `${dayOptions[0]}天内到期`, value: find(dayOptions[0])?.expiringCount || 0, unit: '份', icon: 'ep:timer', theme: 'orange' },
  174. { label: `${dayOptions[1]}天内到期`, value: find(dayOptions[1])?.expiringCount || 0, unit: '份', icon: 'ep:timer', theme: 'cyan' },
  175. { label: `${dayOptions[2]}天内到期`, value: find(dayOptions[2])?.expiringCount || 0, unit: '份', icon: 'ep:calendar', theme: 'blue' },
  176. { label: '已逾期合同', value: expired || 0, unit: '份', icon: 'ep:warning', theme: 'purple' }
  177. ]
  178. })
  179. /* ---------------- 图表 ---------------- */
  180. const PIE_COLORS = ['#f56c6c', '#e6a23c', '#f0a020', '#409eff', '#67c23a', '#8b5cf6', '#909399']
  181. const distributionOption = computed(() => {
  182. const buckets = (data.value?.distributionBuckets || []).filter((b) => b.count > 0)
  183. return {
  184. tooltip: { trigger: 'item', formatter: '{b}:{c} 份({d}%)' },
  185. legend: { bottom: 0, icon: 'circle' },
  186. series: [
  187. {
  188. type: 'pie',
  189. radius: ['45%', '70%'],
  190. center: ['50%', '46%'],
  191. label: { formatter: '{b}\n{c} 份' },
  192. data: buckets.map((b, i) => ({
  193. name: b.label,
  194. value: b.count,
  195. itemStyle: {
  196. color: b.label.includes('逾期') || b.label === '已过期' ? '#f56c6c' : PIE_COLORS[i % PIE_COLORS.length]
  197. }
  198. }))
  199. }
  200. ]
  201. }
  202. })
  203. const monthTrendOption = computed(() => {
  204. const items = data.value?.monthTrend || []
  205. return {
  206. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  207. grid: { left: 48, right: 48, top: 24, bottom: 30 },
  208. xAxis: { type: 'category', data: items.map((i) => i.month), axisLabel: { rotate: 30 } },
  209. yAxis: { type: 'value', name: '份' },
  210. series: [
  211. {
  212. name: '到期份数',
  213. type: 'line',
  214. smooth: true,
  215. symbolSize: 7,
  216. data: items.map((i) => i.count),
  217. lineStyle: { width: 2.5, color: '#e6a23c' },
  218. itemStyle: { color: '#e6a23c' },
  219. areaStyle: { color: 'rgba(230,162,60,0.12)' }
  220. }
  221. ]
  222. }
  223. })
  224. /* ---------------- 加载与导出 ---------------- */
  225. const loadAll = async () => {
  226. const ids = selectedCampusIds.value.length
  227. ? selectedCampusIds.value
  228. : campusOptions.map((item) => item.value)
  229. loading.value = true
  230. loadFailed.value = false
  231. pageNo.value = 1
  232. try {
  233. data.value = await fetchContractExpiry({ tenantIds: ids, days: [warningDays.value] })
  234. const now = new Date()
  235. updateTime.value = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())} ${pad2(
  236. now.getHours()
  237. )}:${pad2(now.getMinutes())}`
  238. } catch {
  239. loadFailed.value = true
  240. data.value = null
  241. } finally {
  242. loading.value = false
  243. }
  244. }
  245. const handleQuery = () => {
  246. loadAll()
  247. }
  248. const handleReset = () => {
  249. selectedCampusIds.value = campusOptions.map((item) => item.value)
  250. warningDays.value = 90
  251. loadAll()
  252. }
  253. const handleExport = () => {
  254. const rows = list.value.map((r) => ({
  255. ...r,
  256. genderText: r.gender === 1 ? '男' : r.gender === 2 ? '女' : '-',
  257. remainText: remainText(r.remainDays)
  258. }))
  259. const headers = [
  260. { key: 'elderName', title: '长者姓名', width: 14 },
  261. { key: 'genderText', title: '性别', width: 8 },
  262. { key: 'age', title: '年龄', width: 8 },
  263. { key: 'tenantName', title: '所属机构', width: 22 },
  264. { key: 'bedName', title: '床位', width: 12 },
  265. { key: 'contractStartDate', title: '合同起期', width: 14 },
  266. { key: 'contractEndDate', title: '合同止期', width: 14 },
  267. { key: 'remainText', title: '剩余天数', width: 12 },
  268. { key: 'contactName', title: '联系人', width: 12 },
  269. { key: 'contactPhone', title: '联系电话', width: 16 }
  270. ]
  271. exportWithCustomHeaders(rows, headers, `合同到期预警_${new Date().toISOString().slice(0, 10)}.xlsx`, '合同到期明细')
  272. message.success('导出成功')
  273. }
  274. onMounted(() => {
  275. loadAll()
  276. })
  277. </script>
  278. <style lang="scss" scoped>
  279. .oa-page {
  280. .page-header {
  281. display: flex;
  282. align-items: center;
  283. justify-content: space-between;
  284. margin-bottom: 14px;
  285. .header-title {
  286. display: flex;
  287. align-items: center;
  288. font-size: 18px;
  289. font-weight: 600;
  290. color: #303133;
  291. .title-icon {
  292. margin-right: 8px;
  293. font-size: 22px;
  294. color: #409eff;
  295. }
  296. }
  297. .header-extra {
  298. display: flex;
  299. align-items: center;
  300. }
  301. .update-time {
  302. margin-right: 14px;
  303. font-size: 13px;
  304. color: #909399;
  305. }
  306. }
  307. .filter-form {
  308. margin-top: 4px;
  309. .campus-select {
  310. width: 300px;
  311. max-width: 56vw;
  312. }
  313. .days-select {
  314. width: 140px;
  315. }
  316. }
  317. .api-tip {
  318. margin: 4px 0 14px;
  319. }
  320. .kpi-grid {
  321. display: grid;
  322. grid-template-columns: repeat(4, 1fr);
  323. gap: 12px;
  324. @media (max-width: 1200px) {
  325. grid-template-columns: repeat(2, 1fr);
  326. }
  327. @media (max-width: 768px) {
  328. grid-template-columns: 1fr;
  329. }
  330. .kpi-card {
  331. padding: 16px 18px;
  332. border-radius: 8px;
  333. color: #fff;
  334. .kpi-label {
  335. display: flex;
  336. align-items: center;
  337. font-size: 13px;
  338. .el-icon {
  339. margin-right: 6px;
  340. font-size: 16px;
  341. }
  342. }
  343. .kpi-value {
  344. margin-top: 8px;
  345. font-size: 28px;
  346. font-weight: 700;
  347. line-height: 1.15;
  348. .kpi-unit {
  349. margin-left: 4px;
  350. font-size: 13px;
  351. font-weight: 400;
  352. opacity: 0.85;
  353. }
  354. }
  355. &.blue {
  356. background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
  357. }
  358. &.cyan {
  359. background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
  360. }
  361. &.orange {
  362. background: linear-gradient(135deg, #ffb066 0%, #f5851d 100%);
  363. }
  364. &.purple {
  365. background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
  366. }
  367. }
  368. }
  369. .chart-row,
  370. .table-wrap {
  371. margin-top: 12px;
  372. }
  373. .chart-empty {
  374. display: flex;
  375. align-items: center;
  376. justify-content: center;
  377. height: 300px;
  378. }
  379. .panel-title {
  380. position: relative;
  381. padding-left: 10px;
  382. margin-bottom: 12px;
  383. font-size: 15px;
  384. font-weight: 600;
  385. color: #303133;
  386. &::before {
  387. content: '';
  388. position: absolute;
  389. left: 0;
  390. top: 2px;
  391. width: 4px;
  392. height: 16px;
  393. background: #409eff;
  394. border-radius: 2px;
  395. }
  396. }
  397. .pager {
  398. display: flex;
  399. justify-content: flex-end;
  400. margin-top: 12px;
  401. }
  402. }
  403. </style>