Index.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  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="周期编码,如 2026-08" clearable style="width: 180px;" />
  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="如 2026-08" />
  146. </el-form-item>
  147. <el-form-item label="周期范围" required>
  148. <el-date-picker v-model="periodRange" type="daterange" value-format="yyyy-MM-dd" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 100%;" />
  149. </el-form-item>
  150. <el-form-item>
  151. <span class="tip">汇总将按周期内已完成任务计算各门店巡检分、自动分与综合分,并生成排名。</span>
  152. </el-form-item>
  153. </el-form>
  154. <span slot="footer">
  155. <el-button @click="triggerVisible = false">取消</el-button>
  156. <el-button type="primary" :loading="triggering" @click="trigger">开始汇总</el-button>
  157. </span>
  158. </el-dialog>
  159. </section>
  160. </template>
  161. <script>
  162. import { post, ok, msg, fmtNum, cleanParams, ASSESS_LEVEL } from '../common'
  163. import AssessDetail from './Detail.vue'
  164. const LEVEL_COLORS = { 1: '#67C23A', 2: '#409EFF', 3: '#E6A23C', 4: '#F56C6C' }
  165. export default {
  166. name: 'InspectAssessIndex',
  167. components: { AssessDetail },
  168. data() {
  169. return {
  170. query: { periodType: '', periodCode: '', storeBelongto: '', assessLevel: '', pageSize: 500, currentPage: 1 },
  171. storeNameKw: '',
  172. rows: [],
  173. loading: false,
  174. stores: [],
  175. distributions: [],
  176. trendStoreIds: [],
  177. trendRows: [],
  178. detailVisible: false,
  179. detailRow: null,
  180. triggerVisible: false,
  181. triggering: false,
  182. periodRange: [],
  183. triggerForm: { periodType: 2, periodCode: '', periodStartDate: '', periodEndDate: '' },
  184. // 图表参数
  185. padL: 40, padR: 16, padT: 12, padB: 26, chartW: 560, chartH: 200
  186. }
  187. },
  188. computed: {
  189. belongtoOptions() {
  190. const map = {}
  191. this.stores.forEach(s => {
  192. if (s.storeBelongtoName) map[s.storeBelongtoName] = s.storeBelongto || ''
  193. })
  194. return Object.keys(map).map(k => ({ label: k, value: map[k] }))
  195. },
  196. trendStoreOptions() {
  197. return this.rows.map(r => ({ storeId: r.storeId, storeName: r.storeName }))
  198. },
  199. filteredRows() {
  200. const kw = (this.storeNameKw || '').trim()
  201. if (!kw) return this.rows
  202. return this.rows.filter(r => (r.storeName || '').indexOf(kw) !== -1)
  203. },
  204. stat() {
  205. const rows = this.filteredRows
  206. const total = rows.length
  207. const avg = total ? rows.reduce((s, r) => s + (Number(r.totalScore) || 0), 0) / total : 0
  208. return {
  209. storeCount: total,
  210. avgTotal: avg.toFixed(1),
  211. excellent: rows.filter(r => r.assessLevel === 1).length,
  212. fail: rows.filter(r => r.assessLevel === 4).length
  213. }
  214. },
  215. distTotal() {
  216. return this.distributions.reduce((s, d) => s + d.count, 0)
  217. },
  218. donutStyle() {
  219. if (!this.distTotal) return {}
  220. let acc = 0
  221. const stops = this.distributions.map(d => {
  222. const start = acc / this.distTotal * 360
  223. acc += d.count
  224. const end = acc / this.distTotal * 360
  225. return LEVEL_COLORS[d.level] + ' ' + start.toFixed(1) + 'deg ' + end.toFixed(1) + 'deg'
  226. })
  227. return { background: 'conic-gradient(' + stops.join(',') + ')' }
  228. },
  229. trendCats() {
  230. const cats = this.uniqPeriodCodes
  231. const W = this.chartW - this.padL - this.padR
  232. return cats.map((c, i) => ({
  233. label: c,
  234. x: cats.length === 1 ? this.padL + W / 2 : this.padL + (i / (cats.length - 1)) * W,
  235. value: c
  236. }))
  237. },
  238. uniqPeriodCodes() {
  239. const set = {}
  240. this.trendRows.forEach(r => { set[r.periodCode] = 1 })
  241. return Object.keys(set).sort()
  242. },
  243. trendSeries() {
  244. const cats = this.uniqPeriodCodes
  245. const H = this.chartH - this.padT - this.padB
  246. const W = this.chartW - this.padL - this.padR
  247. const maxY = Math.max(100, ...this.trendRows.map(r => Number(r.totalScore) || 0))
  248. const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#9B59B6', '#00BCD4', '#FF9800', '#8BC34A']
  249. return this.trendStoreIds.map((sid, si) => {
  250. const points = cats.map((c, i) => {
  251. const row = this.trendRows.find(r => String(r.storeId) === String(sid) && String(r.periodCode) === String(c))
  252. const val = row ? (Number(row.totalScore) || 0) : 0
  253. const x = cats.length === 1 ? this.padL + W / 2 : this.padL + (i / (cats.length - 1)) * W
  254. const y = this.padT + H - (val / maxY) * H
  255. return { x, y, val }
  256. })
  257. return {
  258. storeId: sid,
  259. storeName: (this.trendStoreOptions.find(o => String(o.storeId) === String(sid)) || {}).storeName || sid,
  260. color: colors[si % colors.length],
  261. points,
  262. pointsText: points.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' ')
  263. }
  264. })
  265. },
  266. allPoints() {
  267. const list = []
  268. this.trendSeries.forEach(s => {
  269. s.points.forEach(p => {
  270. list.push({ key: s.storeId + '-' + p.x, x: p.x, y: p.y, color: s.color })
  271. })
  272. })
  273. return list
  274. },
  275. gridLines() {
  276. const lines = []
  277. for (let i = 0; i <= 4; i++) {
  278. const y = this.padT + (this.chartH - this.padT - this.padB) * (i / 4)
  279. lines.push({ y, label: 100 - i * 25 })
  280. }
  281. return lines
  282. }
  283. },
  284. created() {
  285. this.loadStores()
  286. this.loadAll()
  287. },
  288. methods: {
  289. loadStores() {
  290. post(this.$axios, '/storeManage/getStoreList', {}).then(res => { this.stores = (res && res.list) || [] })
  291. },
  292. loadAll() {
  293. this.loading = true
  294. post(this.$axios, '/inspectManage/assess/page', cleanParams(this.query)).then(res => {
  295. this.rows = (res && res.list) || []
  296. }).finally(() => { this.loading = false })
  297. this.loadDistribution()
  298. this.loadTrend()
  299. },
  300. loadDistribution() {
  301. if (!this.query.periodType && !this.query.periodCode) {
  302. this.distributions = []
  303. return
  304. }
  305. post(this.$axios, '/inspectManage/assess/levelDistribution', { periodType: this.query.periodType || '', periodCode: this.query.periodCode || '' }).then(res => {
  306. const list = (res && res.list) || []
  307. this.distributions = list.map(row => {
  308. const arr = Array.isArray(row) ? row : Object.keys(row).map(k => row[k])
  309. return { level: Number(arr[0]), count: Number(arr[1] || 0) }
  310. }).filter(d => !isNaN(d.level))
  311. })
  312. },
  313. loadTrend() {
  314. if (!this.trendStoreIds.length) {
  315. this.trendRows = []
  316. return
  317. }
  318. post(this.$axios, '/inspectManage/assess/trend', { storeIds: this.trendStoreIds, periodType: this.query.periodType || '' }).then(res => {
  319. const list = (res && res.list) || []
  320. this.trendRows = list.map(row => {
  321. const arr = Array.isArray(row) ? row : Object.keys(row).map(k => row[k])
  322. return { storeId: String(arr[0]), periodCode: String(arr[1] === null || arr[1] === undefined ? '' : arr[1]), totalScore: Number(arr[2] || 0) }
  323. })
  324. })
  325. },
  326. updateRank() {
  327. if (!this.query.periodType || !this.query.periodCode) return this.$message.warning('请先选择考核周期与周期编码')
  328. post(this.$axios, '/inspectManage/assess/rank', { periodType: this.query.periodType, periodCode: this.query.periodCode }).then(res => {
  329. if (ok(res)) { this.loadAll(); this.$message.success('排名已更新') } else { this.$message.error(msg(res) || '更新失败') }
  330. })
  331. },
  332. exportData() {
  333. if (!this.query.periodType || !this.query.periodCode) return this.$message.warning('请先选择考核周期与周期编码')
  334. this.$axios.post('/inspectManage/assess/export', require('querystring').stringify({ periodType: this.query.periodType, periodCode: this.query.periodCode }), { responseType: 'blob' }).then(res => {
  335. const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
  336. const elink = document.createElement('a')
  337. elink.download = '考核排名-' + this.query.periodCode + '.xlsx'
  338. elink.style.display = 'none'
  339. elink.href = URL.createObjectURL(blob)
  340. document.body.appendChild(elink)
  341. elink.click()
  342. URL.revokeObjectURL(elink.href)
  343. document.body.removeChild(elink)
  344. }).catch(() => this.$message.error('导出失败'))
  345. },
  346. reset() {
  347. this.query = { periodType: '', periodCode: '', storeBelongto: '', assessLevel: '', pageSize: 500, currentPage: 1 }
  348. this.storeNameKw = ''
  349. this.loadAll()
  350. },
  351. openDetail(row) {
  352. this.detailRow = row
  353. this.detailVisible = true
  354. },
  355. trigger() {
  356. if (!this.triggerForm.periodCode) return this.$message.warning('请输入周期编码')
  357. if (!this.periodRange || !this.periodRange.length) return this.$message.warning('请选择周期范围')
  358. const form = Object.assign({}, this.triggerForm, { periodStartDate: this.periodRange[0], periodEndDate: this.periodRange[1] })
  359. this.triggering = true
  360. post(this.$axios, '/inspectManage/assess/trigger', form).then(res => {
  361. if (ok(res)) {
  362. const count = (res && res.obj) || 0
  363. this.$message.success('汇总完成,共汇总 ' + count + ' 家门店')
  364. this.triggerVisible = false
  365. this.query.periodType = form.periodType
  366. this.query.periodCode = form.periodCode
  367. this.loadAll()
  368. } else {
  369. this.$message.error(msg(res) || '汇总失败')
  370. }
  371. }).finally(() => { this.triggering = false })
  372. },
  373. distPercent(n) {
  374. if (!this.distTotal) return 0
  375. return (n / this.distTotal * 100).toFixed(1)
  376. },
  377. rankClass(v) {
  378. const n = Number(v || 0)
  379. if (n === 1) return 'rank-1'
  380. if (n === 2) return 'rank-2'
  381. if (n === 3) return 'rank-3'
  382. return 'rank-n'
  383. },
  384. belongtoName(v) {
  385. if (v === null || v === undefined || v === '') return '-'
  386. const st = this.stores.find(s => String(s.storeBelongto) === String(v))
  387. return st ? (st.storeBelongtoName || '-') : '-'
  388. },
  389. passRate(row) {
  390. const total = Number(row.taskCount || 0)
  391. if (!total) return '-'
  392. return Math.round(Number(row.finishTaskCount || 0) / total * 100) + '%'
  393. },
  394. levelColor(v) { return LEVEL_COLORS[v] || '#909399' },
  395. levelText(v) { return (ASSESS_LEVEL[v] || {}).text || '-' },
  396. levelMeta(v) { return ASSESS_LEVEL[v] || { text: '-', type: 'info' } },
  397. fmtNum
  398. }
  399. }
  400. </script>
  401. <style scoped>
  402. .panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; padding: 18px; }
  403. .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
  404. .toolbar .el-select, .toolbar .el-input { width: 160px; }
  405. .grow { flex: 1; }
  406. .stat-cards { display: flex; gap: 16px; margin-bottom: 16px; }
  407. .stat-card { flex: 1; background: #f5f7fa; border-radius: 6px; padding: 16px 20px; display: flex; align-items: baseline; gap: 8px; }
  408. .stat-card span { color: #909399; font-size: 13px; }
  409. .stat-card b { font-size: 28px; color: #303133; }
  410. .stat-card em { color: #909399; font-style: normal; font-size: 12px; }
  411. .stat-card.good b { color: #67c23a; }
  412. .stat-card.fail b { color: #f56c6c; }
  413. .charts-row { display: flex; gap: 16px; margin-bottom: 16px; }
  414. .chart-panel { border: 1px solid #ebeef5; border-radius: 6px; padding: 14px; width: 300px; }
  415. .chart-panel.grow { flex: 1; width: auto; }
  416. .chart-panel h4 { margin: 0 0 12px; font-size: 14px; color: #303133; }
  417. .donut-wrap { position: relative; width: 160px; height: 160px; margin: 0 auto 12px; }
  418. .donut { width: 160px; height: 160px; border-radius: 50%; }
  419. .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; }
  420. .dist-legend { display: flex; flex-direction: column; gap: 8px; }
  421. .dist-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #606266; }
  422. .dist-item b { margin-left: auto; }
  423. .dist-item em { color: #909399; font-style: normal; width: 44px; text-align: right; }
  424. .dot { width: 10px; height: 10px; border-radius: 50%; }
  425. .trend-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
  426. .trend-chart { height: 200px; }
  427. .trend-svg { width: 100%; height: 200px; }
  428. .grid-line { stroke: #ebeef5; stroke-width: 1; }
  429. .grid-text { fill: #909399; font-size: 11px; }
  430. .trend-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; }
  431. .legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #606266; }
  432. .legend-item i { width: 12px; height: 3px; border-radius: 2px; }
  433. .rank-block { border: 1px solid #ebeef5; border-radius: 6px; padding: 14px; }
  434. .rank-block h4 { margin: 0 0 12px; font-size: 14px; color: #303133; }
  435. .rank-badge { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; font-size: 12px; font-weight: 700; }
  436. .rank-1 { background: #f56c6c; color: #fff; }
  437. .rank-2 { background: #e6a23c; color: #fff; }
  438. .rank-3 { background: #f7ba2a; color: #fff; }
  439. .rank-n { background: #f0f2f5; color: #606266; }
  440. .total-score { color: #409eff; font-weight: 600; }
  441. .overtime { color: #f56c6c; }
  442. .empty { color: #909399; padding: 24px; text-align: center; }
  443. .muted { color: #909399; }
  444. .tip { color: #909399; font-size: 12px; line-height: 1.6; }
  445. </style>