index.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. <template>
  2. <div class="detail-page">
  3. <section class="detail-hero">
  4. <div>
  5. <span>WARNING ITEM PROFILE</span>
  6. <h1>预警事项详情</h1>
  7. <p>通过事项编码查看完整配置、处置环节和可选预警原因。</p>
  8. </div>
  9. <div class="search-box">
  10. <el-input v-model="itemCode" placeholder="输入预警事项编码" clearable @keyup.enter="search">
  11. <template #prepend><el-icon><Search /></el-icon></template>
  12. <template #append><el-button type="primary" @click="search">查询详情</el-button></template>
  13. </el-input>
  14. </div>
  15. </section>
  16. <template v-if="hasDetail">
  17. <section class="identity-card">
  18. <div class="identity-main">
  19. <div class="identity-icon" :style="{ '--level-color': levelMeta(detail.warningLevel).color }"><el-icon><Warning /></el-icon></div>
  20. <div>
  21. <span class="identity-code">{{ detail.itemCode }}</span>
  22. <h2>{{ detail.itemName }}</h2>
  23. <div class="identity-tags">
  24. <el-tag :type="levelMeta(detail.warningLevel).tagType">{{ levelMeta(detail.warningLevel).label }}</el-tag>
  25. <el-tag effect="plain">{{ typeLabel(detail.warningType) }}</el-tag>
  26. <el-tag :type="statusMeta(detail.status).type">{{ statusMeta(detail.status).label }}</el-tag>
  27. </div>
  28. </div>
  29. </div>
  30. <div class="identity-meta">
  31. <div><span>所属行业</span><strong>{{ detail.industry || '-' }}</strong></div>
  32. <div><span>预警专项</span><strong>{{ detail.warningSpecial || '通用专项' }}</strong></div>
  33. <div><span>响应时长</span><strong>{{ detail.duration || '-' }}<small> 小时</small></strong></div>
  34. </div>
  35. </section>
  36. <div class="detail-grid">
  37. <main>
  38. <el-card class="info-card" shadow="never">
  39. <template #header><div class="card-heading"><span>环节配置</span><small>{{ detailLinks.length }} 个标准环节</small></div></template>
  40. <div v-if="detailLinks.length" class="process-flow">
  41. <div v-for="(link, index) in detailLinks" :key="index" class="process-item">
  42. <div class="process-node">{{ String(index + 1).padStart(2, '0') }}</div>
  43. <div class="process-line" v-if="index < detailLinks.length - 1"></div>
  44. <div class="process-content">
  45. <div class="process-title"><strong>{{ link.name }}</strong><span>{{ link.timeLimit || '-' }} 分钟</span></div>
  46. <small>责任方:{{ link.handler || '未配置' }}</small>
  47. <p>{{ link.description || '暂无环节说明' }}</p>
  48. </div>
  49. </div>
  50. </div>
  51. <el-empty v-else description="暂未配置环节" />
  52. </el-card>
  53. <el-card class="info-card" shadow="never">
  54. <template #header><div class="card-heading"><span>事项备注</span></div></template>
  55. <div class="remark-content">{{ detail.remark || '暂无补充说明' }}</div>
  56. </el-card>
  57. </main>
  58. <aside>
  59. <el-card class="info-card reason-card" shadow="never">
  60. <template #header><div class="card-heading"><span>可选预警原因</span><small>{{ reasons.length }} 条</small></div></template>
  61. <div v-loading="reasonLoading">
  62. <div v-for="reason in reasons" :key="reason.reasonId" class="reason-row">
  63. <span class="reason-index">{{ reason.sortOrder ?? '-' }}</span>
  64. <div><strong>{{ reason.reasonName }}</strong><p>{{ reason.description || '暂无描述' }}</p></div>
  65. </div>
  66. <el-empty v-if="!reasonLoading && !reasons.length" description="暂无启用原因" :image-size="60" />
  67. </div>
  68. </el-card>
  69. <el-card class="info-card meta-card" shadow="never">
  70. <template #header><div class="card-heading"><span>记录信息</span></div></template>
  71. <el-descriptions :column="1" size="small">
  72. <el-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</el-descriptions-item>
  73. <el-descriptions-item label="创建人">{{ detail.createBy || '-' }}</el-descriptions-item>
  74. <el-descriptions-item label="更新时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
  75. <el-descriptions-item label="更新人">{{ detail.updateBy || '-' }}</el-descriptions-item>
  76. </el-descriptions>
  77. </el-card>
  78. </aside>
  79. </div>
  80. </template>
  81. <el-card v-else class="empty-detail" shadow="never">
  82. <div class="empty-detail-icon"><el-icon><Search /></el-icon></div>
  83. <h3>{{ searched ? '未找到对应事项' : '输入编码开始查询' }}</h3>
  84. <p>{{ searched ? '请检查事项编码是否正确,或前往事项信息查询浏览完整清单。' : '事项编码是预警清单的唯一标识,支持精确查询。' }}</p>
  85. </el-card>
  86. </div>
  87. </template>
  88. <script setup name="WarningItemDetail">
  89. import { computed, onMounted, reactive, ref } from 'vue'
  90. import { useRoute } from 'vue-router'
  91. import { ElMessage } from 'element-plus'
  92. import { Search, Warning } from '@element-plus/icons-vue'
  93. import { getDicts } from '@/api/system/dict/data'
  94. import { getWarningItemByCode, listReasonsByTypeAndLevel } from '@/api/warning/list'
  95. import {
  96. fallbackWarningTypes,
  97. levelMeta,
  98. normalizeWarningTypeDict,
  99. optionLabel,
  100. parseLinkConfig,
  101. statusMeta
  102. } from '../shared'
  103. const route = useRoute()
  104. const itemCode = ref(String(route.query.code || ''))
  105. const searched = ref(false)
  106. const reasonLoading = ref(false)
  107. const warningTypes = ref(fallbackWarningTypes)
  108. const reasons = ref([])
  109. const detail = reactive({})
  110. const hasDetail = computed(() => !!detail.itemId)
  111. const detailLinks = computed(() => parseLinkConfig(detail.linkConfig))
  112. function typeLabel(value) {
  113. return optionLabel(warningTypes.value, value)
  114. }
  115. async function loadTypes() {
  116. try {
  117. const res = await getDicts('warning_type')
  118. warningTypes.value = normalizeWarningTypeDict(res.data)
  119. } catch {
  120. warningTypes.value = fallbackWarningTypes
  121. }
  122. }
  123. async function search() {
  124. if (!itemCode.value.trim()) {
  125. ElMessage.warning('请输入预警事项编码')
  126. return
  127. }
  128. searched.value = true
  129. Object.keys(detail).forEach(key => delete detail[key])
  130. reasons.value = []
  131. try {
  132. const res = await getWarningItemByCode(itemCode.value.trim())
  133. Object.assign(detail, res.data || {})
  134. await loadReasons()
  135. } catch {
  136. // request 统一提示错误,页面保留空状态
  137. }
  138. }
  139. async function loadReasons() {
  140. reasonLoading.value = true
  141. try {
  142. const res = await listReasonsByTypeAndLevel(detail.warningType, detail.warningLevel)
  143. reasons.value = res.data || []
  144. } finally {
  145. reasonLoading.value = false
  146. }
  147. }
  148. onMounted(async () => {
  149. await loadTypes()
  150. if (itemCode.value) search()
  151. })
  152. </script>
  153. <style scoped lang="scss">
  154. .detail-page { min-height: calc(100vh - 84px); padding: 20px; background: #f4f7fb; }
  155. .detail-hero {
  156. display: flex;
  157. align-items: center;
  158. justify-content: space-between;
  159. gap: 25px;
  160. padding: 28px 32px;
  161. color: #fff;
  162. border-radius: 18px;
  163. background: linear-gradient(120deg, #172554, #1e40af 55%, #2563eb);
  164. box-shadow: 0 14px 38px rgba(29, 64, 160, .23);
  165. > div:first-child { flex: 1; }
  166. span { color: #9bc7ff; font-size: 11px; letter-spacing: 3px; }
  167. h1 { margin: 6px 0 8px; font-size: 28px; }
  168. p { margin: 0; color: #cad9f7; }
  169. }
  170. .search-box { width: 390px; :deep(.el-input-group__prepend) { padding: 0 13px; color: #4f6ea8; background: #fff; } :deep(.el-input__wrapper) { box-shadow: none; } }
  171. .identity-card {
  172. display: flex;
  173. align-items: center;
  174. justify-content: space-between;
  175. gap: 20px;
  176. margin: 16px 0;
  177. padding: 22px 26px;
  178. border: 1px solid #dfe7f1;
  179. border-radius: 16px;
  180. background: #fff;
  181. box-shadow: 0 8px 25px rgba(39, 61, 91, .06);
  182. }
  183. .identity-main { display: flex; align-items: center; gap: 16px; }
  184. .identity-icon { display: grid; width: 58px; height: 58px; color: var(--level-color); font-size: 27px; border: 1px solid color-mix(in srgb, var(--level-color) 25%, transparent); border-radius: 17px; background: color-mix(in srgb, var(--level-color) 10%, #fff); place-items: center; }
  185. .identity-code { color: #8392a7; font: 12px Consolas, monospace; }
  186. .identity-main h2 { margin: 5px 0 10px; color: #20314b; font-size: 22px; }
  187. .identity-tags { display: flex; gap: 7px; }
  188. .identity-meta { display: flex; gap: 34px; }
  189. .identity-meta div { display: flex; flex-direction: column; gap: 6px; }
  190. .identity-meta span { color: #94a3b8; font-size: 11px; }
  191. .identity-meta strong { color: #334155; font-size: 15px; }
  192. .identity-meta small { color: #94a3b8; font-size: 11px; }
  193. .detail-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .55fr); gap: 16px; }
  194. .info-card { margin-bottom: 16px; border: 1px solid #e2e8f0; border-radius: 14px; }
  195. .card-heading { display: flex; align-items: center; justify-content: space-between; color: #1f3149; font-weight: 700; small { color: #94a3b8; font-size: 11px; font-weight: 400; } }
  196. .process-flow { padding: 8px 8px 12px; }
  197. .process-item { position: relative; display: flex; gap: 16px; min-height: 115px; }
  198. .process-node { z-index: 1; display: grid; flex: 0 0 42px; width: 42px; height: 42px; color: #fff; font: 12px Consolas, monospace; border: 4px solid #e9f0ff; border-radius: 50%; background: #3168d8; place-items: center; }
  199. .process-line { position: absolute; top: 42px; bottom: 0; left: 20px; width: 2px; background: #dbe6f8; }
  200. .process-content { flex: 1; padding: 3px 0 22px; }
  201. .process-title { display: flex; align-items: center; justify-content: space-between; strong { color: #273a54; } span { color: #3168d8; font: 12px Consolas, monospace; } }
  202. .process-content > small { display: block; margin: 7px 0; color: #8795a8; }
  203. .process-content p { margin: 0; color: #66778d; line-height: 1.65; }
  204. .remark-content { min-height: 90px; color: #66778d; line-height: 1.8; }
  205. .reason-row { display: flex; gap: 10px; padding: 13px 0; border-bottom: 1px dashed #e8edf3; &:last-child { border-bottom: 0; } }
  206. .reason-index { display: grid; flex: 0 0 25px; width: 25px; height: 25px; color: #2563eb; font-size: 11px; border-radius: 7px; background: #edf4ff; place-items: center; }
  207. .reason-row div { min-width: 0; }
  208. .reason-row strong { color: #334155; font-size: 13px; }
  209. .reason-row p { overflow: hidden; margin: 5px 0 0; color: #94a3b8; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
  210. .empty-detail { display: grid; min-height: 420px; margin-top: 16px; place-items: center; text-align: center; }
  211. .empty-detail-icon { display: grid; width: 72px; height: 72px; color: #7191d1; font-size: 35px; border-radius: 23px; background: #edf3ff; place-items: center; }
  212. .empty-detail h3 { margin: 16px 0 6px; color: #334155; }
  213. .empty-detail p { margin: 0; color: #94a3b8; font-size: 12px; }
  214. @media (max-width: 1000px) { .detail-grid { grid-template-columns: 1fr; } .identity-meta { gap: 16px; } }
  215. @media (max-width: 720px) { .detail-hero, .identity-card { align-items: flex-start; flex-direction: column; } .search-box { width: 100%; } .identity-meta { width: 100%; justify-content: space-between; } }
  216. </style>