Index.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. <template>
  2. <section class="panel">
  3. <div class="toolbar">
  4. <el-select v-model="query.periodType" placeholder="考核周期" clearable style="width: 130px;">
  5. <el-option label="周度" :value="1" />
  6. <el-option label="月度" :value="2" />
  7. </el-select>
  8. <el-input v-model="query.periodCode" :placeholder="periodCodePlaceholder(query.periodType)" clearable style="width: 200px;" />
  9. <el-select v-model="query.storeBelongto" placeholder="校区/区域" clearable filterable style="width: 180px;">
  10. <el-option v-for="b in belongtoOptions" :key="b.value" :label="b.label" :value="b.value" />
  11. </el-select>
  12. <el-select v-model="query.assessLevel" placeholder="考核等级" clearable style="width: 130px;">
  13. <el-option label="优秀" :value="1" />
  14. <el-option label="良好" :value="2" />
  15. <el-option label="合格" :value="3" />
  16. <el-option label="不合格" :value="4" />
  17. </el-select>
  18. <el-input v-model="storeNameKw" placeholder="门店名称" clearable style="width: 160px;" />
  19. <el-button type="primary" @click="loadAll">查询</el-button>
  20. <el-button @click="reset">重置</el-button>
  21. <span class="grow"></span>
  22. <el-button type="warning" plain @click="updateRank">更新排名</el-button>
  23. <el-button type="success" plain @click="exportData">导出报告</el-button>
  24. <el-button type="primary" @click="triggerVisible = true">手动汇总</el-button>
  25. </div>
  26. <!-- 统计卡片 -->
  27. <div class="stat-cards">
  28. <div class="stat-card"><span>参评门店</span><b>{{ stat.storeCount }}</b><em>家</em></div>
  29. <div class="stat-card"><span>平均综合分</span><b>{{ stat.avgTotal }}</b><em>分</em></div>
  30. <div class="stat-card good"><span>优秀门店</span><b>{{ stat.excellent }}</b><em>家</em></div>
  31. <div class="stat-card fail"><span>不合格门店</span><b>{{ stat.fail }}</b><em>家</em></div>
  32. </div>
  33. <div class="charts-row">
  34. <!-- 等级分布 -->
  35. <div class="chart-panel">
  36. <h4>考核等级分布</h4>
  37. <div class="donut-wrap" v-if="distTotal > 0">
  38. <div class="donut" :style="donutStyle"></div>
  39. <div class="donut-center">{{ distTotal }}</div>
  40. </div>
  41. <div class="dist-legend">
  42. <div v-for="d in distributions" :key="d.level" class="dist-item">
  43. <span class="dot" :style="{ background: levelColor(d.level) }"></span>
  44. <span>{{ levelText(d.level) }}</span>
  45. <b>{{ d.count }}</b>
  46. <em>{{ distPercent(d.count) }}%</em>
  47. </div>
  48. <div v-if="!distTotal" class="muted">暂无数据</div>
  49. </div>
  50. </div>
  51. <!-- 趋势对比 -->
  52. <div class="chart-panel grow">
  53. <h4>门店考核趋势对比</h4>
  54. <div class="trend-toolbar">
  55. <el-select v-model="trendStoreIds" multiple filterable placeholder="选择对比门店(可多选)" style="width: 320px;" @change="loadTrend">
  56. <el-option v-for="s in trendStoreOptions" :key="s.storeId" :label="s.storeName" :value="s.storeId" />
  57. </el-select>
  58. <el-button size="mini" @click="loadTrend">刷新趋势</el-button>
  59. </div>
  60. <div class="trend-chart" v-if="trendSeries.length">
  61. <svg :viewBox="'0 0 ' + chartW + ' ' + chartH" class="trend-svg">
  62. <g v-for="g in gridLines" :key="g.y">
  63. <line :x1="padL" :y1="g.y" :x2="chartW - padR" :y2="g.y" class="grid-line" />
  64. <text :x="padL - 6" :y="g.y + 4" class="grid-text">{{ g.label }}</text>
  65. </g>
  66. <g v-for="cat in trendCats" :key="cat">
  67. <text :x="cat.x" :y="chartH - padB + 14" class="grid-text cat-text" text-anchor="middle">{{ cat.label }}</text>
  68. </g>
  69. <polyline
  70. v-for="s in trendSeries"
  71. :key="s.storeId"
  72. :points="s.pointsText"
  73. fill="none"
  74. :stroke="s.color"
  75. stroke-width="2" />
  76. <circle
  77. v-for="p in allPoints"
  78. :key="p.key"
  79. :cx="p.x"
  80. :cy="p.y"
  81. r="3"
  82. :fill="p.color" />
  83. </svg>
  84. </div>
  85. <div class="trend-legend">
  86. <span v-for="s in trendSeries" :key="s.storeId" class="legend-item">
  87. <i :style="{ background: s.color }"></i>{{ s.storeName }}
  88. </span>
  89. <span v-if="!trendSeries.length" class="muted">请选择门店查看得分趋势</span>
  90. </div>
  91. </div>
  92. </div>
  93. <!-- 排名表 -->
  94. <div class="rank-block">
  95. <h4>考核排名({{ filteredRows.length }} 家门店)</h4>
  96. <el-table :data="filteredRows" border stripe v-loading="loading" @row-click="openDetail">
  97. <el-table-column label="排名" width="60" align="center">
  98. <template slot-scope="s">
  99. <span class="rank-badge" :class="rankClass(s.row.rankNo)">{{ s.row.rankNo || '-' }}</span>
  100. </template>
  101. </el-table-column>
  102. <el-table-column prop="storeName" label="门店" min-width="140" />
  103. <el-table-column label="校区" width="110">
  104. <template slot-scope="s">{{ belongtoName(s.row.storeBelongto) }}</template>
  105. </el-table-column>
  106. <el-table-column label="巡检分" width="85" align="center">
  107. <template slot-scope="s">{{ fmtNum(s.row.inspectScore) }}</template>
  108. </el-table-column>
  109. <el-table-column label="自动分" width="85" align="center">
  110. <template slot-scope="s">{{ fmtNum(s.row.autoScore) }}</template>
  111. </el-table-column>
  112. <el-table-column label="综合分" width="100" align="center">
  113. <template slot-scope="s">
  114. <span class="total-score">{{ fmtNum(s.row.totalScore) }}</span>
  115. </template>
  116. </el-table-column>
  117. <el-table-column label="等级" width="85">
  118. <template slot-scope="s"><el-tag size="mini" :type="levelMeta(s.row.assessLevel).type">{{ levelMeta(s.row.assessLevel).text }}</el-tag></template>
  119. </el-table-column>
  120. <el-table-column prop="taskCount" label="巡检次数" width="80" align="center" />
  121. <el-table-column label="及格率" width="75" align="center">
  122. <template slot-scope="s">{{ passRate(s.row) }}</template>
  123. </el-table-column>
  124. <el-table-column prop="rectCount" label="整改数" width="70" align="center" />
  125. <el-table-column prop="overtimeRectCount" label="超期整改" width="80" align="center">
  126. <template slot-scope="s"><span :class="{ 'overtime': s.row.overtimeRectCount > 0 }">{{ s.row.overtimeRectCount || 0 }}</span></template>
  127. </el-table-column>
  128. <el-table-column label="操作" width="80" fixed="right">
  129. <template slot-scope="s"><el-button type="text" @click.stop="openDetail(s.row)">详情</el-button></template>
  130. </el-table-column>
  131. </el-table>
  132. <div class="empty" v-if="!filteredRows.length && !loading">暂无考核数据,可点击“手动汇总”或等待定时任务生成。</div>
  133. </div>
  134. <assess-detail :visible.sync="detailVisible" :row="detailRow" @saved="loadAll" />
  135. <!-- 手动汇总 -->
  136. <el-dialog title="手动汇总考核数据" :visible.sync="triggerVisible" width="480px">
  137. <el-form :model="triggerForm" label-width="100px">
  138. <el-form-item label="考核周期" required>
  139. <el-radio-group v-model="triggerForm.periodType">
  140. <el-radio :label="1">周度</el-radio>
  141. <el-radio :label="2">月度</el-radio>
  142. </el-radio-group>
  143. </el-form-item>
  144. <el-form-item label="周期编码" required>
  145. <el-input v-model="triggerForm.periodCode" :placeholder="'例:' + periodCodePlaceholder(triggerForm.periodType)" />
  146. <span class="tip">按上方周期与小范围自动生成;周度形如 2026W35,月度形如 202608。</span>
  147. </el-form-item>
  148. <el-form-item label="周期范围" required>
  149. <el-date-picker v-model="periodRange" type="daterange" value-format="yyyy-MM-dd" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 100%;" />
  150. </el-form-item>
  151. <el-form-item>
  152. <span class="tip">汇总将按周期内已完成任务计算各门店巡检分、自动分与综合分,并生成排名。</span>
  153. </el-form-item>
  154. </el-form>
  155. <span slot="footer">
  156. <el-button @click="triggerVisible = false">取消</el-button>
  157. <el-button type="primary" :loading="triggering" @click="trigger">开始汇总</el-button>
  158. </span>
  159. </el-dialog>
  160. </section>
  161. </template>
  162. <script>
  163. import { post, ok, msg, fmtNum, cleanParams, ASSESS_LEVEL } from '../common'
  164. import AssessDetail from './Detail.vue'
  165. const LEVEL_COLORS = { 1: '#67C23A', 2: '#409EFF', 3: '#E6A23C', 4: '#F56C6C' }
  166. export default {
  167. name: 'InspectAssessIndex',
  168. components: { AssessDetail },
  169. data() {
  170. return {
  171. query: { periodType: '', periodCode: '', storeBelongto: '', assessLevel: '', pageSize: 500, currentPage: 1 },
  172. storeNameKw: '',
  173. rows: [],
  174. loading: false,
  175. stores: [],
  176. distributions: [],
  177. trendStoreIds: [],
  178. trendRows: [],
  179. detailVisible: false,
  180. detailRow: null,
  181. triggerVisible: false,
  182. triggering: false,
  183. periodRange: [],
  184. triggerForm: { periodType: 2, periodCode: '', periodStartDate: '', periodEndDate: '' },
  185. // 图表参数
  186. padL: 40, padR: 16, padT: 12, padB: 26, chartW: 560, chartH: 200
  187. }
  188. },
  189. watch: {
  190. 'triggerForm.periodType'() { this.autoPeriodCode() },
  191. periodRange() { this.autoPeriodCode() }
  192. },
  193. computed: {
  194. belongtoOptions() {
  195. const map = {}
  196. this.stores.forEach(s => {
  197. if (s.storeBelongtoName) map[s.storeBelongtoName] = s.storeBelongto || ''
  198. })
  199. return Object.keys(map).map(k => ({ label: k, value: map[k] }))
  200. },
  201. trendStoreOptions() {
  202. return this.rows.map(r => ({ storeId: r.storeId, storeName: r.storeName }))
  203. },
  204. filteredRows() {
  205. const kw = (this.storeNameKw || '').trim()
  206. if (!kw) return this.rows
  207. return this.rows.filter(r => (r.storeName || '').indexOf(kw) !== -1)
  208. },
  209. stat() {
  210. const rows = this.filteredRows
  211. const total = rows.length
  212. const avg = total ? rows.reduce((s, r) => s + (Number(r.totalScore) || 0), 0) / total : 0
  213. return {
  214. storeCount: total,
  215. avgTotal: avg.toFixed(1),
  216. excellent: rows.filter(r => r.assessLevel === 1).length,
  217. fail: rows.filter(r => r.assessLevel === 4).length
  218. }
  219. },
  220. distTotal() {
  221. return this.distributions.reduce((s, d) => s + d.count, 0)
  222. },
  223. donutStyle() {
  224. if (!this.distTotal) return {}
  225. let acc = 0
  226. const stops = this.distributions.map(d => {
  227. const start = acc / this.distTotal * 360
  228. acc += d.count
  229. const end = acc / this.distTotal * 360
  230. return LEVEL_COLORS[d.level] + ' ' + start.toFixed(1) + 'deg ' + end.toFixed(1) + 'deg'
  231. })
  232. return { background: 'conic-gradient(' + stops.join(',') + ')' }
  233. },
  234. trendCats() {
  235. const cats = this.uniqPeriodCodes
  236. const W = this.chartW - this.padL - this.padR
  237. return cats.map((c, i) => ({
  238. label: c,
  239. x: cats.length === 1 ? this.padL + W / 2 : this.padL + (i / (cats.length - 1)) * W,
  240. value: c
  241. }))
  242. },
  243. uniqPeriodCodes() {
  244. const set = {}
  245. this.trendRows.forEach(r => { set[r.periodCode] = 1 })
  246. return Object.keys(set).sort()
  247. },
  248. trendSeries() {
  249. const cats = this.uniqPeriodCodes
  250. const H = this.chartH - this.padT - this.padB
  251. const W = this.chartW - this.padL - this.padR
  252. const maxY = Math.max(100, ...this.trendRows.map(r => Number(r.totalScore) || 0))
  253. const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#9B59B6', '#00BCD4', '#FF9800', '#8BC34A']
  254. return this.trendStoreIds.map((sid, si) => {
  255. const points = cats.map((c, i) => {
  256. const row = this.trendRows.find(r => String(r.storeId) === String(sid) && String(r.periodCode) === String(c))
  257. const val = row ? (Number(row.totalScore) || 0) : 0
  258. const x = cats.length === 1 ? this.padL + W / 2 : this.padL + (i / (cats.length - 1)) * W
  259. const y = this.padT + H - (val / maxY) * H
  260. return { x, y, val }
  261. })
  262. return {
  263. storeId: sid,
  264. storeName: (this.trendStoreOptions.find(o => String(o.storeId) === String(sid)) || {}).storeName || sid,
  265. color: colors[si % colors.length],
  266. points,
  267. pointsText: points.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' ')
  268. }
  269. })
  270. },
  271. allPoints() {
  272. const list = []
  273. this.trendSeries.forEach(s => {
  274. s.points.forEach(p => {
  275. list.push({ key: s.storeId + '-' + p.x, x: p.x, y: p.y, color: s.color })
  276. })
  277. })
  278. return list
  279. },
  280. gridLines() {
  281. const lines = []
  282. for (let i = 0; i <= 4; i++) {
  283. const y = this.padT + (this.chartH - this.padT - this.padB) * (i / 4)
  284. lines.push({ y, label: 100 - i * 25 })
  285. }
  286. return lines
  287. }
  288. },
  289. created() {
  290. this.loadStores()
  291. this.loadAll()
  292. },
  293. methods: {
  294. loadStores() {
  295. post(this.$axios, '/storeManage/getStoreList', {}).then(res => { this.stores = (res && res.list) || [] })
  296. },
  297. loadAll() {
  298. this.loading = true
  299. post(this.$axios, '/assessResultManage/getAssessPage', cleanParams(this.query)).then(res => {
  300. this.rows = (res && res.list) || []
  301. }).finally(() => { this.loading = false })
  302. this.loadDistribution()
  303. this.loadTrend()
  304. },
  305. periodCodePlaceholder(type) {
  306. if (Number(type) === 1) return '周度,如 2026W35'
  307. if (Number(type) === 2) return '月度,如 202608'
  308. return '周度 2026W35 / 月度 202608'
  309. },
  310. // ISO 8601 周数(与定时任务 / Java Calendar WEEK_OF_YEAR 基本对齐)
  311. weekNumber(dt) {
  312. const d = new Date(Date.UTC(dt.getFullYear(), dt.getMonth(), dt.getDate()))
  313. const dayNum = d.getUTCDay() || 7
  314. d.setUTCDate(d.getUTCDate() + 4 - dayNum)
  315. const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
  316. const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
  317. return { year: d.getUTCFullYear(), week }
  318. },
  319. autoPeriodCode() {
  320. if (!this.periodRange || this.periodRange.length < 2 || !this.periodRange[0]) { return }
  321. const type = Number(this.triggerForm.periodType)
  322. const start = new Date(this.periodRange[0])
  323. if (type === 1) {
  324. const { year, week } = this.weekNumber(start)
  325. this.triggerForm.periodCode = year + 'W' + week
  326. } else if (type === 2) {
  327. const month = start.getMonth() + 1
  328. this.triggerForm.periodCode = start.getFullYear() + (month < 10 ? '0' : '') + month
  329. }
  330. },
  331. loadDistribution() {
  332. if (!this.query.periodType || !this.query.periodCode) {
  333. this.distributions = []
  334. return
  335. }
  336. post(this.$axios, '/assessResultManage/getLevelDistribution', { periodType: this.query.periodType || '', periodCode: this.query.periodCode || '' }).then(res => {
  337. const list = (res && res.list) || []
  338. this.distributions = list.map(row => ({ level: Number(row && row.level), count: Number((row && row.count) || 0) })).filter(d => !isNaN(d.level))
  339. })
  340. },
  341. loadTrend() {
  342. if (!this.trendStoreIds.length) {
  343. this.trendRows = []
  344. return
  345. }
  346. post(this.$axios, '/assessResultManage/getAssessTrend', { storeIds: this.trendStoreIds, periodType: this.query.periodType || '' }).then(res => {
  347. const list = (res && res.list) || []
  348. this.trendRows = list.map(row => {
  349. // 后端返回 Map:{ storeId, storeName, periodCode, totalScore, rankNo }
  350. let it
  351. if (Array.isArray(row)) {
  352. it = { storeId: row[0], periodCode: row[2], totalScore: row[3] }
  353. } else {
  354. it = { storeId: row.storeId, periodCode: row.periodCode, totalScore: row.totalScore }
  355. }
  356. return { storeId: String(it.storeId), periodCode: String(it.periodCode == null ? '' : it.periodCode), totalScore: Number(it.totalScore || 0) }
  357. })
  358. })
  359. },
  360. updateRank() {
  361. if (!this.query.periodType || !this.query.periodCode) return this.$message.warning('请先选择考核周期与周期编码')
  362. post(this.$axios, '/assessResultManage/updateAssessRank', { periodType: this.query.periodType, periodCode: this.query.periodCode }).then(res => {
  363. if (ok(res)) { this.loadAll(); this.$message.success('排名已更新') } else { this.$message.error(msg(res) || '更新失败') }
  364. })
  365. },
  366. exportData() {
  367. if (!this.query.periodType || !this.query.periodCode) return this.$message.warning('请先选择考核周期与周期编码')
  368. this.$axios.post('/assessResultManage/exportAssessExcel', require('querystring').stringify({ periodType: this.query.periodType, periodCode: this.query.periodCode }), { responseType: 'blob' }).then(res => {
  369. const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
  370. const elink = document.createElement('a')
  371. elink.download = '考核排名-' + this.query.periodCode + '.xlsx'
  372. elink.style.display = 'none'
  373. elink.href = URL.createObjectURL(blob)
  374. document.body.appendChild(elink)
  375. elink.click()
  376. URL.revokeObjectURL(elink.href)
  377. document.body.removeChild(elink)
  378. }).catch(() => this.$message.error('导出失败'))
  379. },
  380. reset() {
  381. this.query = { periodType: '', periodCode: '', storeBelongto: '', assessLevel: '', pageSize: 500, currentPage: 1 }
  382. this.storeNameKw = ''
  383. this.loadAll()
  384. },
  385. openDetail(row) {
  386. this.detailRow = row
  387. this.detailVisible = true
  388. },
  389. trigger() {
  390. if (!this.triggerForm.periodCode) return this.$message.warning('请输入周期编码')
  391. const trigType = Number(this.triggerForm.periodType)
  392. const trigCode = String(this.triggerForm.periodCode || '').trim()
  393. if (!(trigType === 1 ? /^\d{4}W\d{1,2}$/i : /^\d{6}$/).test(trigCode)) {
  394. return this.$message.warning(trigType === 1 ? '周期编码格式应为 2026W35(年W周数)' : '周期编码格式应为 202608(年+月)')
  395. }
  396. if (!this.periodRange || !this.periodRange.length) return this.$message.warning('请选择周期范围')
  397. const form = Object.assign({}, this.triggerForm, { periodStartDate: this.periodRange[0], periodEndDate: this.periodRange[1] })
  398. this.triggering = true
  399. post(this.$axios, '/assessResultManage/triggerAssess', form).then(res => {
  400. if (ok(res)) {
  401. const count = (res && res.obj) || 0
  402. this.$message.success('汇总完成,共汇总 ' + count + ' 家门店')
  403. this.triggerVisible = false
  404. this.query.periodType = form.periodType
  405. this.query.periodCode = form.periodCode
  406. this.loadAll()
  407. } else {
  408. this.$message.error(msg(res) || '汇总失败')
  409. }
  410. }).finally(() => { this.triggering = false })
  411. },
  412. distPercent(n) {
  413. if (!this.distTotal) return 0
  414. return (n / this.distTotal * 100).toFixed(1)
  415. },
  416. rankClass(v) {
  417. const n = Number(v || 0)
  418. if (n === 1) return 'rank-1'
  419. if (n === 2) return 'rank-2'
  420. if (n === 3) return 'rank-3'
  421. return 'rank-n'
  422. },
  423. belongtoName(v) {
  424. if (v === null || v === undefined || v === '') return '-'
  425. const st = this.stores.find(s => String(s.storeBelongto) === String(v))
  426. return st ? (st.storeBelongtoName || '-') : '-'
  427. },
  428. passRate(row) {
  429. const total = Number(row.taskCount || 0)
  430. if (!total) return '-'
  431. return Math.round(Number(row.finishTaskCount || 0) / total * 100) + '%'
  432. },
  433. levelColor(v) { return LEVEL_COLORS[v] || '#909399' },
  434. levelText(v) { return (ASSESS_LEVEL[v] || {}).text || '-' },
  435. levelMeta(v) { return ASSESS_LEVEL[v] || { text: '-', type: 'info' } },
  436. fmtNum
  437. }
  438. }
  439. </script>
  440. <style scoped>
  441. .panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; padding: 18px; }
  442. .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
  443. .toolbar .el-select, .toolbar .el-input { width: 160px; }
  444. .grow { flex: 1; }
  445. .stat-cards { display: flex; gap: 16px; margin-bottom: 16px; }
  446. .stat-card { flex: 1; background: #f5f7fa; border-radius: 6px; padding: 16px 20px; display: flex; align-items: baseline; gap: 8px; }
  447. .stat-card span { color: #909399; font-size: 13px; }
  448. .stat-card b { font-size: 28px; color: #303133; }
  449. .stat-card em { color: #909399; font-style: normal; font-size: 12px; }
  450. .stat-card.good b { color: #67c23a; }
  451. .stat-card.fail b { color: #f56c6c; }
  452. .charts-row { display: flex; gap: 16px; margin-bottom: 16px; }
  453. .chart-panel { border: 1px solid #ebeef5; border-radius: 6px; padding: 14px; width: 300px; }
  454. .chart-panel.grow { flex: 1; width: auto; }
  455. .chart-panel h4 { margin: 0 0 12px; font-size: 14px; color: #303133; }
  456. .donut-wrap { position: relative; width: 160px; height: 160px; margin: 0 auto 12px; }
  457. .donut { width: 160px; height: 160px; border-radius: 50%; }
  458. .donut-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 96px; height: 96px; background: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 26px; font-weight: 600; color: #303133; }
  459. .dist-legend { display: flex; flex-direction: column; gap: 8px; }
  460. .dist-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #606266; }
  461. .dist-item b { margin-left: auto; }
  462. .dist-item em { color: #909399; font-style: normal; width: 44px; text-align: right; }
  463. .dot { width: 10px; height: 10px; border-radius: 50%; }
  464. .trend-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
  465. .trend-chart { height: 200px; }
  466. .trend-svg { width: 100%; height: 200px; }
  467. .grid-line { stroke: #ebeef5; stroke-width: 1; }
  468. .grid-text { fill: #909399; font-size: 11px; }
  469. .trend-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; }
  470. .legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #606266; }
  471. .legend-item i { width: 12px; height: 3px; border-radius: 2px; }
  472. .rank-block { border: 1px solid #ebeef5; border-radius: 6px; padding: 14px; }
  473. .rank-block h4 { margin: 0 0 12px; font-size: 14px; color: #303133; }
  474. .rank-badge { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; font-size: 12px; font-weight: 700; }
  475. .rank-1 { background: #f56c6c; color: #fff; }
  476. .rank-2 { background: #e6a23c; color: #fff; }
  477. .rank-3 { background: #f7ba2a; color: #fff; }
  478. .rank-n { background: #f0f2f5; color: #606266; }
  479. .total-score { color: #409eff; font-weight: 600; }
  480. .overtime { color: #f56c6c; }
  481. .empty { color: #909399; padding: 24px; text-align: center; }
  482. .muted { color: #909399; }
  483. .tip { color: #909399; font-size: 12px; line-height: 1.6; }
  484. </style>