| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- <template>
- <section class="panel">
- <div class="toolbar">
- <el-select v-model="query.periodType" placeholder="考核周期" clearable style="width: 130px;">
- <el-option label="周度" :value="1" />
- <el-option label="月度" :value="2" />
- </el-select>
- <el-input v-model="query.periodCode" :placeholder="periodCodePlaceholder(query.periodType)" clearable style="width: 200px;" />
- <el-select v-model="query.storeBelongto" placeholder="校区/区域" clearable filterable style="width: 180px;">
- <el-option v-for="b in belongtoOptions" :key="b.value" :label="b.label" :value="b.value" />
- </el-select>
- <el-select v-model="query.assessLevel" placeholder="考核等级" clearable style="width: 130px;">
- <el-option label="优秀" :value="1" />
- <el-option label="良好" :value="2" />
- <el-option label="合格" :value="3" />
- <el-option label="不合格" :value="4" />
- </el-select>
- <el-input v-model="storeNameKw" placeholder="门店名称" clearable style="width: 160px;" />
- <el-button type="primary" @click="loadAll">查询</el-button>
- <el-button @click="reset">重置</el-button>
- <span class="grow"></span>
- <el-button type="warning" plain @click="updateRank">更新排名</el-button>
- <el-button type="success" plain @click="exportData">导出报告</el-button>
- <el-button type="primary" @click="triggerVisible = true">手动汇总</el-button>
- </div>
- <!-- 统计卡片 -->
- <div class="stat-cards">
- <div class="stat-card"><span>参评门店</span><b>{{ stat.storeCount }}</b><em>家</em></div>
- <div class="stat-card"><span>平均综合分</span><b>{{ stat.avgTotal }}</b><em>分</em></div>
- <div class="stat-card good"><span>优秀门店</span><b>{{ stat.excellent }}</b><em>家</em></div>
- <div class="stat-card fail"><span>不合格门店</span><b>{{ stat.fail }}</b><em>家</em></div>
- </div>
- <div class="charts-row">
- <!-- 等级分布 -->
- <div class="chart-panel">
- <h4>考核等级分布</h4>
- <div class="donut-wrap" v-if="distTotal > 0">
- <div class="donut" :style="donutStyle"></div>
- <div class="donut-center">{{ distTotal }}</div>
- </div>
- <div class="dist-legend">
- <div v-for="d in distributions" :key="d.level" class="dist-item">
- <span class="dot" :style="{ background: levelColor(d.level) }"></span>
- <span>{{ levelText(d.level) }}</span>
- <b>{{ d.count }}</b>
- <em>{{ distPercent(d.count) }}%</em>
- </div>
- <div v-if="!distTotal" class="muted">暂无数据</div>
- </div>
- </div>
- <!-- 趋势对比 -->
- <div class="chart-panel grow">
- <h4>门店考核趋势对比</h4>
- <div class="trend-toolbar">
- <el-select v-model="trendStoreIds" multiple filterable placeholder="选择对比门店(可多选)" style="width: 320px;" @change="loadTrend">
- <el-option v-for="s in trendStoreOptions" :key="s.storeId" :label="s.storeName" :value="s.storeId" />
- </el-select>
- <el-button size="mini" @click="loadTrend">刷新趋势</el-button>
- </div>
- <div class="trend-chart" v-if="trendSeries.length">
- <svg :viewBox="'0 0 ' + chartW + ' ' + chartH" class="trend-svg">
- <g v-for="g in gridLines" :key="g.y">
- <line :x1="padL" :y1="g.y" :x2="chartW - padR" :y2="g.y" class="grid-line" />
- <text :x="padL - 6" :y="g.y + 4" class="grid-text">{{ g.label }}</text>
- </g>
- <g v-for="cat in trendCats" :key="cat">
- <text :x="cat.x" :y="chartH - padB + 14" class="grid-text cat-text" text-anchor="middle">{{ cat.label }}</text>
- </g>
- <polyline
- v-for="s in trendSeries"
- :key="s.storeId"
- :points="s.pointsText"
- fill="none"
- :stroke="s.color"
- stroke-width="2" />
- <circle
- v-for="p in allPoints"
- :key="p.key"
- :cx="p.x"
- :cy="p.y"
- r="3"
- :fill="p.color" />
- </svg>
- </div>
- <div class="trend-legend">
- <span v-for="s in trendSeries" :key="s.storeId" class="legend-item">
- <i :style="{ background: s.color }"></i>{{ s.storeName }}
- </span>
- <span v-if="!trendSeries.length" class="muted">请选择门店查看得分趋势</span>
- </div>
- </div>
- </div>
- <!-- 排名表 -->
- <div class="rank-block">
- <h4>考核排名({{ filteredRows.length }} 家门店)</h4>
- <el-table :data="filteredRows" border stripe v-loading="loading" @row-click="openDetail">
- <el-table-column label="排名" width="60" align="center">
- <template slot-scope="s">
- <span class="rank-badge" :class="rankClass(s.row.rankNo)">{{ s.row.rankNo || '-' }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="storeName" label="门店" min-width="140" />
- <el-table-column label="校区" width="110">
- <template slot-scope="s">{{ belongtoName(s.row.storeBelongto) }}</template>
- </el-table-column>
- <el-table-column label="巡检分" width="85" align="center">
- <template slot-scope="s">{{ fmtNum(s.row.inspectScore) }}</template>
- </el-table-column>
- <el-table-column label="自动分" width="85" align="center">
- <template slot-scope="s">{{ fmtNum(s.row.autoScore) }}</template>
- </el-table-column>
- <el-table-column label="综合分" width="100" align="center">
- <template slot-scope="s">
- <span class="total-score">{{ fmtNum(s.row.totalScore) }}</span>
- </template>
- </el-table-column>
- <el-table-column label="等级" width="85">
- <template slot-scope="s"><el-tag size="mini" :type="levelMeta(s.row.assessLevel).type">{{ levelMeta(s.row.assessLevel).text }}</el-tag></template>
- </el-table-column>
- <el-table-column prop="taskCount" label="巡检次数" width="80" align="center" />
- <el-table-column label="及格率" width="75" align="center">
- <template slot-scope="s">{{ passRate(s.row) }}</template>
- </el-table-column>
- <el-table-column prop="rectCount" label="整改数" width="70" align="center" />
- <el-table-column prop="overtimeRectCount" label="超期整改" width="80" align="center">
- <template slot-scope="s"><span :class="{ 'overtime': s.row.overtimeRectCount > 0 }">{{ s.row.overtimeRectCount || 0 }}</span></template>
- </el-table-column>
- <el-table-column label="操作" width="80" fixed="right">
- <template slot-scope="s"><el-button type="text" @click.stop="openDetail(s.row)">详情</el-button></template>
- </el-table-column>
- </el-table>
- <div class="empty" v-if="!filteredRows.length && !loading">暂无考核数据,可点击“手动汇总”或等待定时任务生成。</div>
- </div>
- <assess-detail :visible.sync="detailVisible" :row="detailRow" @saved="loadAll" />
- <!-- 手动汇总 -->
- <el-dialog title="手动汇总考核数据" :visible.sync="triggerVisible" width="480px">
- <el-form :model="triggerForm" label-width="100px">
- <el-form-item label="考核周期" required>
- <el-radio-group v-model="triggerForm.periodType">
- <el-radio :label="1">周度</el-radio>
- <el-radio :label="2">月度</el-radio>
- </el-radio-group>
- </el-form-item>
- <el-form-item label="周期编码" required>
- <el-input v-model="triggerForm.periodCode" :placeholder="'例:' + periodCodePlaceholder(triggerForm.periodType)" />
- <span class="tip">按上方周期与小范围自动生成;周度形如 2026W35,月度形如 202608。</span>
- </el-form-item>
- <el-form-item label="周期范围" required>
- <el-date-picker v-model="periodRange" type="daterange" value-format="yyyy-MM-dd" start-placeholder="开始日期" end-placeholder="结束日期" style="width: 100%;" />
- </el-form-item>
- <el-form-item>
- <span class="tip">汇总将按周期内已完成任务计算各门店巡检分、自动分与综合分,并生成排名。</span>
- </el-form-item>
- </el-form>
- <span slot="footer">
- <el-button @click="triggerVisible = false">取消</el-button>
- <el-button type="primary" :loading="triggering" @click="trigger">开始汇总</el-button>
- </span>
- </el-dialog>
- </section>
- </template>
- <script>
- import { post, ok, msg, fmtNum, cleanParams, ASSESS_LEVEL } from '../common'
- import AssessDetail from './Detail.vue'
- const LEVEL_COLORS = { 1: '#67C23A', 2: '#409EFF', 3: '#E6A23C', 4: '#F56C6C' }
- export default {
- name: 'InspectAssessIndex',
- components: { AssessDetail },
- data() {
- return {
- query: { periodType: '', periodCode: '', storeBelongto: '', assessLevel: '', pageSize: 500, currentPage: 1 },
- storeNameKw: '',
- rows: [],
- loading: false,
- stores: [],
- distributions: [],
- trendStoreIds: [],
- trendRows: [],
- detailVisible: false,
- detailRow: null,
- triggerVisible: false,
- triggering: false,
- periodRange: [],
- triggerForm: { periodType: 2, periodCode: '', periodStartDate: '', periodEndDate: '' },
- // 图表参数
- padL: 40, padR: 16, padT: 12, padB: 26, chartW: 560, chartH: 200
- }
- },
- watch: {
- 'triggerForm.periodType'() { this.autoPeriodCode() },
- periodRange() { this.autoPeriodCode() }
- },
- computed: {
- belongtoOptions() {
- const map = {}
- this.stores.forEach(s => {
- if (s.storeBelongtoName) map[s.storeBelongtoName] = s.storeBelongto || ''
- })
- return Object.keys(map).map(k => ({ label: k, value: map[k] }))
- },
- trendStoreOptions() {
- return this.rows.map(r => ({ storeId: r.storeId, storeName: r.storeName }))
- },
- filteredRows() {
- const kw = (this.storeNameKw || '').trim()
- if (!kw) return this.rows
- return this.rows.filter(r => (r.storeName || '').indexOf(kw) !== -1)
- },
- stat() {
- const rows = this.filteredRows
- const total = rows.length
- const avg = total ? rows.reduce((s, r) => s + (Number(r.totalScore) || 0), 0) / total : 0
- return {
- storeCount: total,
- avgTotal: avg.toFixed(1),
- excellent: rows.filter(r => r.assessLevel === 1).length,
- fail: rows.filter(r => r.assessLevel === 4).length
- }
- },
- distTotal() {
- return this.distributions.reduce((s, d) => s + d.count, 0)
- },
- donutStyle() {
- if (!this.distTotal) return {}
- let acc = 0
- const stops = this.distributions.map(d => {
- const start = acc / this.distTotal * 360
- acc += d.count
- const end = acc / this.distTotal * 360
- return LEVEL_COLORS[d.level] + ' ' + start.toFixed(1) + 'deg ' + end.toFixed(1) + 'deg'
- })
- return { background: 'conic-gradient(' + stops.join(',') + ')' }
- },
- trendCats() {
- const cats = this.uniqPeriodCodes
- const W = this.chartW - this.padL - this.padR
- return cats.map((c, i) => ({
- label: c,
- x: cats.length === 1 ? this.padL + W / 2 : this.padL + (i / (cats.length - 1)) * W,
- value: c
- }))
- },
- uniqPeriodCodes() {
- const set = {}
- this.trendRows.forEach(r => { set[r.periodCode] = 1 })
- return Object.keys(set).sort()
- },
- trendSeries() {
- const cats = this.uniqPeriodCodes
- const H = this.chartH - this.padT - this.padB
- const W = this.chartW - this.padL - this.padR
- const maxY = Math.max(100, ...this.trendRows.map(r => Number(r.totalScore) || 0))
- const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#9B59B6', '#00BCD4', '#FF9800', '#8BC34A']
- return this.trendStoreIds.map((sid, si) => {
- const points = cats.map((c, i) => {
- const row = this.trendRows.find(r => String(r.storeId) === String(sid) && String(r.periodCode) === String(c))
- const val = row ? (Number(row.totalScore) || 0) : 0
- const x = cats.length === 1 ? this.padL + W / 2 : this.padL + (i / (cats.length - 1)) * W
- const y = this.padT + H - (val / maxY) * H
- return { x, y, val }
- })
- return {
- storeId: sid,
- storeName: (this.trendStoreOptions.find(o => String(o.storeId) === String(sid)) || {}).storeName || sid,
- color: colors[si % colors.length],
- points,
- pointsText: points.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' ')
- }
- })
- },
- allPoints() {
- const list = []
- this.trendSeries.forEach(s => {
- s.points.forEach(p => {
- list.push({ key: s.storeId + '-' + p.x, x: p.x, y: p.y, color: s.color })
- })
- })
- return list
- },
- gridLines() {
- const lines = []
- for (let i = 0; i <= 4; i++) {
- const y = this.padT + (this.chartH - this.padT - this.padB) * (i / 4)
- lines.push({ y, label: 100 - i * 25 })
- }
- return lines
- }
- },
- created() {
- this.loadStores()
- this.loadAll()
- },
- methods: {
- loadStores() {
- post(this.$axios, '/storeManage/getStoreList', {}).then(res => { this.stores = (res && res.list) || [] })
- },
- loadAll() {
- this.loading = true
- post(this.$axios, '/assessResultManage/getAssessPage', cleanParams(this.query)).then(res => {
- this.rows = (res && res.list) || []
- }).finally(() => { this.loading = false })
- this.loadDistribution()
- this.loadTrend()
- },
- periodCodePlaceholder(type) {
- if (Number(type) === 1) return '周度,如 2026W35'
- if (Number(type) === 2) return '月度,如 202608'
- return '周度 2026W35 / 月度 202608'
- },
- // ISO 8601 周数(与定时任务 / Java Calendar WEEK_OF_YEAR 基本对齐)
- weekNumber(dt) {
- const d = new Date(Date.UTC(dt.getFullYear(), dt.getMonth(), dt.getDate()))
- const dayNum = d.getUTCDay() || 7
- d.setUTCDate(d.getUTCDate() + 4 - dayNum)
- const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
- const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
- return { year: d.getUTCFullYear(), week }
- },
- autoPeriodCode() {
- if (!this.periodRange || this.periodRange.length < 2 || !this.periodRange[0]) { return }
- const type = Number(this.triggerForm.periodType)
- const start = new Date(this.periodRange[0])
- if (type === 1) {
- const { year, week } = this.weekNumber(start)
- this.triggerForm.periodCode = year + 'W' + week
- } else if (type === 2) {
- const month = start.getMonth() + 1
- this.triggerForm.periodCode = start.getFullYear() + (month < 10 ? '0' : '') + month
- }
- },
- loadDistribution() {
- if (!this.query.periodType || !this.query.periodCode) {
- this.distributions = []
- return
- }
- post(this.$axios, '/assessResultManage/getLevelDistribution', { periodType: this.query.periodType || '', periodCode: this.query.periodCode || '' }).then(res => {
- const list = (res && res.list) || []
- this.distributions = list.map(row => ({ level: Number(row && row.level), count: Number((row && row.count) || 0) })).filter(d => !isNaN(d.level))
- })
- },
- loadTrend() {
- if (!this.trendStoreIds.length) {
- this.trendRows = []
- return
- }
- post(this.$axios, '/assessResultManage/getAssessTrend', { storeIds: this.trendStoreIds, periodType: this.query.periodType || '' }).then(res => {
- const list = (res && res.list) || []
- this.trendRows = list.map(row => {
- // 后端返回 Map:{ storeId, storeName, periodCode, totalScore, rankNo }
- let it
- if (Array.isArray(row)) {
- it = { storeId: row[0], periodCode: row[2], totalScore: row[3] }
- } else {
- it = { storeId: row.storeId, periodCode: row.periodCode, totalScore: row.totalScore }
- }
- return { storeId: String(it.storeId), periodCode: String(it.periodCode == null ? '' : it.periodCode), totalScore: Number(it.totalScore || 0) }
- })
- })
- },
- updateRank() {
- if (!this.query.periodType || !this.query.periodCode) return this.$message.warning('请先选择考核周期与周期编码')
- post(this.$axios, '/assessResultManage/updateAssessRank', { periodType: this.query.periodType, periodCode: this.query.periodCode }).then(res => {
- if (ok(res)) { this.loadAll(); this.$message.success('排名已更新') } else { this.$message.error(msg(res) || '更新失败') }
- })
- },
- exportData() {
- if (!this.query.periodType || !this.query.periodCode) return this.$message.warning('请先选择考核周期与周期编码')
- this.$axios.post('/assessResultManage/exportAssessExcel', require('querystring').stringify({ periodType: this.query.periodType, periodCode: this.query.periodCode }), { responseType: 'blob' }).then(res => {
- const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
- const elink = document.createElement('a')
- elink.download = '考核排名-' + this.query.periodCode + '.xlsx'
- elink.style.display = 'none'
- elink.href = URL.createObjectURL(blob)
- document.body.appendChild(elink)
- elink.click()
- URL.revokeObjectURL(elink.href)
- document.body.removeChild(elink)
- }).catch(() => this.$message.error('导出失败'))
- },
- reset() {
- this.query = { periodType: '', periodCode: '', storeBelongto: '', assessLevel: '', pageSize: 500, currentPage: 1 }
- this.storeNameKw = ''
- this.loadAll()
- },
- openDetail(row) {
- this.detailRow = row
- this.detailVisible = true
- },
- trigger() {
- if (!this.triggerForm.periodCode) return this.$message.warning('请输入周期编码')
- const trigType = Number(this.triggerForm.periodType)
- const trigCode = String(this.triggerForm.periodCode || '').trim()
- if (!(trigType === 1 ? /^\d{4}W\d{1,2}$/i : /^\d{6}$/).test(trigCode)) {
- return this.$message.warning(trigType === 1 ? '周期编码格式应为 2026W35(年W周数)' : '周期编码格式应为 202608(年+月)')
- }
- if (!this.periodRange || !this.periodRange.length) return this.$message.warning('请选择周期范围')
- const form = Object.assign({}, this.triggerForm, { periodStartDate: this.periodRange[0], periodEndDate: this.periodRange[1] })
- this.triggering = true
- post(this.$axios, '/assessResultManage/triggerAssess', form).then(res => {
- if (ok(res)) {
- const count = (res && res.obj) || 0
- this.$message.success('汇总完成,共汇总 ' + count + ' 家门店')
- this.triggerVisible = false
- this.query.periodType = form.periodType
- this.query.periodCode = form.periodCode
- this.loadAll()
- } else {
- this.$message.error(msg(res) || '汇总失败')
- }
- }).finally(() => { this.triggering = false })
- },
- distPercent(n) {
- if (!this.distTotal) return 0
- return (n / this.distTotal * 100).toFixed(1)
- },
- rankClass(v) {
- const n = Number(v || 0)
- if (n === 1) return 'rank-1'
- if (n === 2) return 'rank-2'
- if (n === 3) return 'rank-3'
- return 'rank-n'
- },
- belongtoName(v) {
- if (v === null || v === undefined || v === '') return '-'
- const st = this.stores.find(s => String(s.storeBelongto) === String(v))
- return st ? (st.storeBelongtoName || '-') : '-'
- },
- passRate(row) {
- const total = Number(row.taskCount || 0)
- if (!total) return '-'
- return Math.round(Number(row.finishTaskCount || 0) / total * 100) + '%'
- },
- levelColor(v) { return LEVEL_COLORS[v] || '#909399' },
- levelText(v) { return (ASSESS_LEVEL[v] || {}).text || '-' },
- levelMeta(v) { return ASSESS_LEVEL[v] || { text: '-', type: 'info' } },
- fmtNum
- }
- }
- </script>
- <style scoped>
- .panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; padding: 18px; }
- .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
- .toolbar .el-select, .toolbar .el-input { width: 160px; }
- .grow { flex: 1; }
- .stat-cards { display: flex; gap: 16px; margin-bottom: 16px; }
- .stat-card { flex: 1; background: #f5f7fa; border-radius: 6px; padding: 16px 20px; display: flex; align-items: baseline; gap: 8px; }
- .stat-card span { color: #909399; font-size: 13px; }
- .stat-card b { font-size: 28px; color: #303133; }
- .stat-card em { color: #909399; font-style: normal; font-size: 12px; }
- .stat-card.good b { color: #67c23a; }
- .stat-card.fail b { color: #f56c6c; }
- .charts-row { display: flex; gap: 16px; margin-bottom: 16px; }
- .chart-panel { border: 1px solid #ebeef5; border-radius: 6px; padding: 14px; width: 300px; }
- .chart-panel.grow { flex: 1; width: auto; }
- .chart-panel h4 { margin: 0 0 12px; font-size: 14px; color: #303133; }
- .donut-wrap { position: relative; width: 160px; height: 160px; margin: 0 auto 12px; }
- .donut { width: 160px; height: 160px; border-radius: 50%; }
- .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; }
- .dist-legend { display: flex; flex-direction: column; gap: 8px; }
- .dist-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #606266; }
- .dist-item b { margin-left: auto; }
- .dist-item em { color: #909399; font-style: normal; width: 44px; text-align: right; }
- .dot { width: 10px; height: 10px; border-radius: 50%; }
- .trend-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
- .trend-chart { height: 200px; }
- .trend-svg { width: 100%; height: 200px; }
- .grid-line { stroke: #ebeef5; stroke-width: 1; }
- .grid-text { fill: #909399; font-size: 11px; }
- .trend-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; }
- .legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #606266; }
- .legend-item i { width: 12px; height: 3px; border-radius: 2px; }
- .rank-block { border: 1px solid #ebeef5; border-radius: 6px; padding: 14px; }
- .rank-block h4 { margin: 0 0 12px; font-size: 14px; color: #303133; }
- .rank-badge { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; font-size: 12px; font-weight: 700; }
- .rank-1 { background: #f56c6c; color: #fff; }
- .rank-2 { background: #e6a23c; color: #fff; }
- .rank-3 { background: #f7ba2a; color: #fff; }
- .rank-n { background: #f0f2f5; color: #606266; }
- .total-score { color: #409eff; font-weight: 600; }
- .overtime { color: #f56c6c; }
- .empty { color: #909399; padding: 24px; text-align: center; }
- .muted { color: #909399; }
- .tip { color: #909399; font-size: 12px; line-height: 1.6; }
- </style>
|