添加了项目前景 绩效 审批配置做了一部分有点晕 我换换脑子继续这个 还有说明菜单
This commit is contained in:
25
ruoyi-ui/src/api/oa/performance.js
Normal file
25
ruoyi-ui/src/api/oa/performance.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function myPerformance(period) {
|
||||
return request({ url: '/oa/performance/mine', method: 'get', params: { period } })
|
||||
}
|
||||
export function rankPerformance(query) {
|
||||
return request({ url: '/oa/performance/rank', method: 'get', params: query })
|
||||
}
|
||||
export function userPerformance(userId, period) {
|
||||
return request({ url: '/oa/performance/of/' + userId, method: 'get', params: { period } })
|
||||
}
|
||||
// 高总(或管理员)打主观分 0~40
|
||||
export function setSubjective(userId, userName, period, score) {
|
||||
return request({
|
||||
url: '/oa/performance/subjective', method: 'post',
|
||||
params: { userId, userName, period, score }
|
||||
})
|
||||
}
|
||||
// 手动加/扣分:points 正=扣 负=加
|
||||
export function manualAdjust(userId, userName, points, reason) {
|
||||
return request({
|
||||
url: '/oa/performance/manual', method: 'post',
|
||||
params: { userId, userName, points, reason }
|
||||
})
|
||||
}
|
||||
24
ruoyi-ui/src/api/oa/postpone.js
Normal file
24
ruoyi-ui/src/api/oa/postpone.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 我名下所有未处理的超期
|
||||
export function listMyOverdue() {
|
||||
return request({ url: '/oa/postpone/mine', method: 'get' })
|
||||
}
|
||||
|
||||
export function postponeComplete(businessType, businessId) {
|
||||
return request({
|
||||
url: '/oa/postpone/complete', method: 'post',
|
||||
params: { businessType, businessId }
|
||||
})
|
||||
}
|
||||
|
||||
export function postponeCancel(businessType, businessId, reason) {
|
||||
return request({
|
||||
url: '/oa/postpone/cancel', method: 'post',
|
||||
params: { businessType, businessId, reason }
|
||||
})
|
||||
}
|
||||
|
||||
export function postpone(data) {
|
||||
return request({ url: '/oa/postpone/postpone', method: 'post', data })
|
||||
}
|
||||
18
ruoyi-ui/src/api/oa/projectOverview.js
Normal file
18
ruoyi-ui/src/api/oa/projectOverview.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 项目全景:一次拉完所有维度
|
||||
export function getProjectOverview(projectId) {
|
||||
return request({
|
||||
url: '/oa/project/overview/' + projectId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 集团驾驶舱:全公司项目一览 + 全局汇总
|
||||
export function getProjectDashboardOverview(query) {
|
||||
return request({
|
||||
url: '/oa/project/overview/dashboard',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
193
ruoyi-ui/src/components/PerfWidget/index.vue
Normal file
193
ruoyi-ui/src/components/PerfWidget/index.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="perf-w" v-loading="loading" @click="goDetail">
|
||||
<div v-if="score" class="card" :class="lvClass">
|
||||
<!-- 顶部:等级 + 分数 -->
|
||||
<div class="row1">
|
||||
<div class="grade">{{ score.grade || '-' }}</div>
|
||||
<div class="right">
|
||||
<div class="score">
|
||||
<span class="num">{{ score.totalScore || 0 }}</span>
|
||||
<span class="div">/100</span>
|
||||
</div>
|
||||
<div class="period">{{ score.period }} 月度绩效</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:渐变进度条 -->
|
||||
<div class="track">
|
||||
<div class="track-fill" :style="{ width: pct(score.totalScore, 100) }"></div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:mini 指标 -->
|
||||
<div class="row2">
|
||||
<div class="pill">
|
||||
<span class="lbl">基础</span>
|
||||
<span class="val">{{ score.baseScore || 80 }}</span>
|
||||
</div>
|
||||
<div class="pill" v-if="(score.reward || 0) > 0">
|
||||
<span class="lbl">加</span>
|
||||
<span class="val ok">+{{ score.reward }}</span>
|
||||
</div>
|
||||
<div class="pill" v-if="(score.deduction || 0) > 0">
|
||||
<span class="lbl">扣</span>
|
||||
<span class="val bad">-{{ score.deduction }}</span>
|
||||
</div>
|
||||
<div class="pill">
|
||||
<span class="lbl">主观</span>
|
||||
<span class="val">{{ score.bonus || 0 }}</span>
|
||||
<span v-if="!isSet" class="tip">·待打</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow"><i class="el-icon-arrow-right"></i></div>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty">暂无绩效数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { myPerformance } from '@/api/oa/performance'
|
||||
|
||||
export default {
|
||||
name: 'PerfWidget',
|
||||
data() { return { loading: false, score: null } },
|
||||
computed: {
|
||||
lvClass() {
|
||||
const g = this.score && this.score.grade
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'lv-a'
|
||||
if (g.startsWith('B')) return 'lv-b'
|
||||
if (g.startsWith('C')) return 'lv-c'
|
||||
return 'lv-d'
|
||||
},
|
||||
isSet() {
|
||||
return this.score && (this.score.subjectiveSet === 1 || this.score.subjective_set === 1)
|
||||
}
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
myPerformance().then(res => {
|
||||
this.score = (res.data && res.data.score) || null
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
pct(v, max) {
|
||||
if (!max) return '0%'
|
||||
return Math.max(0, Math.min(100, (v / max) * 100)) + '%'
|
||||
},
|
||||
goDetail() {
|
||||
// 从用户路由树里找到"我的绩效"组件对应的实际路径,避免菜单被挪窝后写死失效
|
||||
const target = 'oa/performance/mine/index'
|
||||
const routes = this.$store.getters && this.$store.getters.permission_routes || this.$router.options.routes || []
|
||||
const path = this.findRoutePath(routes, target, '')
|
||||
if (path) {
|
||||
this.$router.push(path)
|
||||
} else {
|
||||
// 兜底:还是按默认 /perf/mine 尝试一下
|
||||
this.$router.push('/perf/mine').catch(() => { window.location.assign('/perf/mine') })
|
||||
}
|
||||
},
|
||||
findRoutePath(routes, target, parent) {
|
||||
for (const r of routes) {
|
||||
let p = r.path || ''
|
||||
if (p && !p.startsWith('/')) p = (parent.endsWith('/') ? parent : parent + '/') + p
|
||||
else if (p) p = p
|
||||
else p = parent
|
||||
const cmp = r.component
|
||||
const cmpStr = cmp && (cmp.name || (cmp.toString && cmp.toString().match(/['"]([^'"]*?)['"]/)?.[1])) || ''
|
||||
if (r.meta && r.meta.title === '我的绩效') return p
|
||||
if (typeof cmpStr === 'string' && cmpStr.indexOf('performance/mine') >= 0) return p
|
||||
if (r.children && r.children.length) {
|
||||
const found = this.findRoutePath(r.children, target, p)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.perf-w {
|
||||
height: 100%; padding: 6px; box-sizing: border-box; cursor: pointer;
|
||||
}
|
||||
.card {
|
||||
height: 100%; position: relative;
|
||||
background: linear-gradient(135deg, #fff 0%, #f5fbef 100%);
|
||||
border-radius: 12px; padding: 14px 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
transition: all .2s; overflow: hidden;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
}
|
||||
.card:hover { box-shadow: 0 6px 18px rgba(0,0,0,.08); transform: translateY(-1px); }
|
||||
.card::before {
|
||||
content: ''; position: absolute; top: 0; right: 0; width: 130px; height: 130px;
|
||||
background: radial-gradient(circle, rgba(103,194,58,0.18) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card.lv-a { background: linear-gradient(135deg, #fff 0%, #f5fbef 100%); }
|
||||
.card.lv-b { background: linear-gradient(135deg, #fff 0%, #ecf5ff 100%); }
|
||||
.card.lv-b::before { background: radial-gradient(circle, rgba(64,158,255,0.18) 0%, transparent 70%); }
|
||||
.card.lv-c { background: linear-gradient(135deg, #fff 0%, #fdf6ec 100%); }
|
||||
.card.lv-c::before { background: radial-gradient(circle, rgba(230,162,60,0.18) 0%, transparent 70%); }
|
||||
.card.lv-d { background: linear-gradient(135deg, #fff 0%, #fef0f0 100%); }
|
||||
.card.lv-d::before { background: radial-gradient(circle, rgba(245,108,108,0.18) 0%, transparent 70%); }
|
||||
|
||||
.row1 { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
|
||||
.grade {
|
||||
font-size: 56px; font-weight: 800; line-height: 1;
|
||||
color: #67c23a; min-width: 70px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.card.lv-a .grade { color: #67c23a; }
|
||||
.card.lv-b .grade { color: #409EFF; }
|
||||
.card.lv-c .grade { color: #e6a23c; }
|
||||
.card.lv-d .grade { color: #f56c6c; }
|
||||
|
||||
.right { flex: 1; }
|
||||
.score { display: flex; align-items: baseline; gap: 4px; }
|
||||
.score .num { font-size: 28px; font-weight: 700; color: #303133; line-height: 1; }
|
||||
.score .div { font-size: 13px; color: #909399; }
|
||||
.period { font-size: 11px; color: #909399; margin-top: 2px; }
|
||||
|
||||
.track {
|
||||
margin: 10px 0 8px; height: 6px; background: #f0f0f0; border-radius: 3px; overflow: hidden;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.track-fill {
|
||||
height: 100%; border-radius: 3px;
|
||||
background: linear-gradient(90deg, #67c23a 0%, #5daf34 100%);
|
||||
transition: width .6s ease;
|
||||
}
|
||||
.card.lv-b .track-fill { background: linear-gradient(90deg, #409EFF 0%, #337ecc 100%); }
|
||||
.card.lv-c .track-fill { background: linear-gradient(90deg, #e6a23c 0%, #cf9236 100%); }
|
||||
.card.lv-d .track-fill { background: linear-gradient(90deg, #f56c6c 0%, #d65b5b 100%); }
|
||||
|
||||
.row2 {
|
||||
display: flex; gap: 6px; flex-wrap: wrap; position: relative; z-index: 1;
|
||||
}
|
||||
.pill {
|
||||
background: rgba(255,255,255,0.7); border: 1px solid rgba(0,0,0,0.05);
|
||||
border-radius: 12px; padding: 2px 9px; font-size: 11px;
|
||||
display: flex; align-items: center; gap: 4px; color: #606266;
|
||||
}
|
||||
.pill .lbl { color: #909399; }
|
||||
.pill .val { font-weight: 600; color: #303133; font-family: Menlo, Consolas, monospace; }
|
||||
.pill .val.ok { color: #67c23a; }
|
||||
.pill .val.bad { color: #f56c6c; }
|
||||
.pill .tip { color: #c0c4cc; font-size: 10px; }
|
||||
|
||||
.arrow {
|
||||
position: absolute; bottom: 8px; right: 10px;
|
||||
color: #c0c4cc; font-size: 16px; z-index: 1;
|
||||
}
|
||||
.card:hover .arrow { color: #909399; transform: translateX(3px); transition: all .2s; }
|
||||
|
||||
.empty {
|
||||
color: #909399; font-size: 13px;
|
||||
height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
import Announcements from '@/components/Announcements/index.vue'
|
||||
import MiniCalendar from '@/components/MiniCalendar/index.vue'
|
||||
import QuickEntry from '@/components/QuickEntry/index.vue'
|
||||
import PerfWidget from '@/components/PerfWidget/index.vue'
|
||||
import {
|
||||
ExpressQuestionList,
|
||||
FeedbackList,
|
||||
@@ -77,6 +78,11 @@ export const WIDGET_REGISTRY = {
|
||||
title: '快捷入口',
|
||||
component: QuickEntry,
|
||||
defaultSize: { w: 12, h: 4 }
|
||||
},
|
||||
perf: {
|
||||
title: '我的绩效',
|
||||
component: PerfWidget,
|
||||
defaultSize: { w: 4, h: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
376
ruoyi-ui/src/layout/components/OverdueGuard.vue
Normal file
376
ruoyi-ui/src/layout/components/OverdueGuard.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- ============ 强制弹窗 ============ -->
|
||||
<el-dialog
|
||||
title="超期事项处理"
|
||||
:visible.sync="visible"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
width="780px"
|
||||
custom-class="overdue-dialog"
|
||||
append-to-body>
|
||||
|
||||
<div class="head-meta">
|
||||
你名下有 <b>{{ remainCount }}</b> 项已超期。
|
||||
<span class="hint-30">超过 30 天会通知高总并扣除 20 分绩效</span>。
|
||||
建议逐条处理;如暂无法处理可点「稍后处理」,浮窗会一直提醒。
|
||||
</div>
|
||||
|
||||
<div v-if="current" class="item-card">
|
||||
<div class="item-head">
|
||||
<el-tag :type="typeTag(current.business_type)" size="medium">{{ typeLabel(current.business_type) }}</el-tag>
|
||||
<span class="item-title">{{ current.business_title || ('#' + current.business_id) }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span>原截止:{{ fmtDateTime(current.deadline) }}</span>
|
||||
<span class="bad">已超期 {{ overdueDays(current.deadline) }} 天</span>
|
||||
<el-link type="primary" :underline="false" @click="goDetail(current)">
|
||||
查看详情 <i class="el-icon-top-right"></i>
|
||||
</el-link>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" label-width="100px" class="form-area" size="small" ref="form" :rules="rules">
|
||||
<el-form-item label="处理方式">
|
||||
<el-radio-group v-model="form.action">
|
||||
<el-radio label="complete">已完成</el-radio>
|
||||
<el-radio label="postpone">顺延</el-radio>
|
||||
<el-radio label="cancel">取消</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.action === 'postpone'">
|
||||
<el-form-item label="新截止日期" prop="newDeadline">
|
||||
<el-date-picker v-model="form.newDeadline" type="datetime"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
:picker-options="pickerOpts"
|
||||
style="width: 260px;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="顺延理由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="warning" :closable="false" show-icon
|
||||
title="第 3 次及以后顺延需要项目负责人审批,通过后才生效。" />
|
||||
</template>
|
||||
|
||||
<template v-if="form.action === 'cancel'">
|
||||
<el-form-item label="取消理由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="footer">
|
||||
<span class="muted">{{ doneCount }} / {{ totalCount }} 已处理</span>
|
||||
<div>
|
||||
<el-button @click="skip">稍后处理</el-button>
|
||||
<el-button :loading="submitting" type="primary" :disabled="!form.action" @click="submit">
|
||||
提交并下一条
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============ 关不掉的浮窗 ============ -->
|
||||
<transition name="float-slide">
|
||||
<div v-if="floatVisible" class="overdue-float">
|
||||
<div class="float-head">
|
||||
<i class="el-icon-warning"></i>
|
||||
<span>我的超期 ({{ pending.length }})</span>
|
||||
</div>
|
||||
<div class="float-list">
|
||||
<div v-for="item in pending" :key="item.business_type + '-' + item.business_id"
|
||||
class="float-item"
|
||||
:class="[urgencyClass(item.deadline), strikeMap[itemKey(item)] ? 'striking' : '']">
|
||||
<div class="float-item-title" @click="reopenFor(item)">
|
||||
<el-tag :type="typeTag(item.business_type)" size="mini">{{ typeLabel(item.business_type) }}</el-tag>
|
||||
<span class="title-text">{{ shorten(item.business_title, 22) || ('#' + item.business_id) }}</span>
|
||||
<el-tag v-if="overdueDays(item.deadline) >= 30" type="danger" size="mini" effect="dark" class="boss-tag">已通知高总</el-tag>
|
||||
</div>
|
||||
<div class="float-item-meta">
|
||||
已超期 {{ overdueDays(item.deadline) }} 天
|
||||
<el-button type="text" size="mini" class="quick-done" @click.stop="quickComplete(item)">
|
||||
<i class="el-icon-check"></i> 完成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float-footer">点标题打开处理窗 · 点"完成"直接标记完成</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listMyOverdue, postponeComplete, postponeCancel, postpone } from '@/api/oa/postpone'
|
||||
|
||||
export default {
|
||||
name: 'OverdueGuard',
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
floatVisible: false,
|
||||
pending: [],
|
||||
totalCount: 0,
|
||||
doneCount: 0,
|
||||
submitting: false,
|
||||
strikeMap: {}, // 正在划掉动画的项 key
|
||||
form: { action: '', newDeadline: '', reason: '' },
|
||||
rules: {
|
||||
newDeadline: [{ required: true, message: '请选择新截止日期', trigger: 'change' }],
|
||||
reason: [{ required: true, message: '请填写理由', trigger: 'blur' }]
|
||||
},
|
||||
pickerOpts: { disabledDate: (d) => d.getTime() < Date.now() - 86400000 }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
current() { return this.pending[0] || null },
|
||||
remainCount() { return this.pending.length }
|
||||
},
|
||||
mounted() {
|
||||
this.boot()
|
||||
},
|
||||
methods: {
|
||||
async boot() {
|
||||
try {
|
||||
const res = await listMyOverdue()
|
||||
const list = res.data || []
|
||||
if (!list.length) return
|
||||
this.pending = list
|
||||
this.totalCount = list.length
|
||||
this.doneCount = 0
|
||||
this.resetForm()
|
||||
this.visible = true
|
||||
this.floatVisible = false
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
resetForm() {
|
||||
this.form = { action: '', newDeadline: '', reason: '' }
|
||||
},
|
||||
skip() {
|
||||
this.visible = false
|
||||
this.floatVisible = true
|
||||
},
|
||||
reopenFor(item) {
|
||||
// 把点击的事项挪到队首,然后打开弹窗
|
||||
const idx = this.pending.indexOf(item)
|
||||
if (idx > 0) {
|
||||
this.pending.splice(idx, 1)
|
||||
this.pending.unshift(item)
|
||||
}
|
||||
this.resetForm()
|
||||
this.visible = true
|
||||
this.floatVisible = false
|
||||
},
|
||||
async submit() {
|
||||
if (!this.current) return
|
||||
const cur = this.current
|
||||
this.submitting = true
|
||||
try {
|
||||
if (this.form.action === 'complete') {
|
||||
await postponeComplete(cur.business_type, cur.business_id)
|
||||
this.$modal.msgSuccess('已标记完成')
|
||||
} else if (this.form.action === 'cancel') {
|
||||
if (!this.form.reason || !this.form.reason.trim()) {
|
||||
this.$modal.msgWarning('请填写取消理由')
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
await postponeCancel(cur.business_type, cur.business_id, this.form.reason.trim())
|
||||
this.$modal.msgSuccess('已取消')
|
||||
} else if (this.form.action === 'postpone') {
|
||||
if (!this.form.newDeadline) {
|
||||
this.$modal.msgWarning('请选择新截止日期')
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
if (!this.form.reason || !this.form.reason.trim()) {
|
||||
this.$modal.msgWarning('请填写顺延理由')
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
const res = await postpone({
|
||||
businessType: cur.business_type,
|
||||
businessId: cur.business_id,
|
||||
businessTitle: cur.business_title,
|
||||
newDeadline: this.form.newDeadline,
|
||||
reason: this.form.reason.trim()
|
||||
})
|
||||
const r = res.data || {}
|
||||
if (r.needApproval) {
|
||||
this.$modal.msgWarning('第 ' + r.postponeSeq + ' 次顺延,已提交项目负责人审批,通过后生效')
|
||||
} else {
|
||||
this.$modal.msgSuccess('已顺延(第 ' + r.postponeSeq + ' 次)')
|
||||
}
|
||||
} else {
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
this.pending.shift()
|
||||
this.doneCount++
|
||||
this.resetForm()
|
||||
if (!this.pending.length) {
|
||||
this.visible = false
|
||||
this.floatVisible = false
|
||||
}
|
||||
} catch (e) {
|
||||
// 错就停留,让用户重试
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
typeLabel(t) {
|
||||
return ({ task: '任务', step: '进度步骤', requirement: '采购需求' })[t] || t
|
||||
},
|
||||
typeTag(t) {
|
||||
return ({ task: '', step: 'warning', requirement: 'success' })[t] || ''
|
||||
},
|
||||
goDetail(item) {
|
||||
const url = this.detailUrl(item)
|
||||
if (url) {
|
||||
const full = this.$router.resolve({ path: url, query: { projectId: item.project_id } })
|
||||
window.open(full.href, '_blank')
|
||||
}
|
||||
},
|
||||
detailUrl(item) {
|
||||
switch (item.business_type) {
|
||||
case 'task': return '/task/task'
|
||||
case 'step': return '/step/step'
|
||||
case 'requirement': return '/hint/requirement'
|
||||
default: return null
|
||||
}
|
||||
},
|
||||
fmtDateTime(d) {
|
||||
if (!d) return '-'
|
||||
const s = String(d)
|
||||
return s.length >= 19 ? s.substring(0, 19).replace('T', ' ') : s
|
||||
},
|
||||
overdueDays(d) {
|
||||
if (!d) return 0
|
||||
const t = new Date(String(d).replace('T', ' ')).getTime()
|
||||
if (!t || isNaN(t)) return 0
|
||||
return Math.max(1, Math.floor((Date.now() - t) / 86400000))
|
||||
},
|
||||
urgencyClass(d) {
|
||||
const days = this.overdueDays(d)
|
||||
if (days <= 3) return 'fresh' // 新超期 红
|
||||
if (days <= 14) return 'mid' // 中等 黄
|
||||
return 'old' // 陈年 灰
|
||||
},
|
||||
shorten(s, n) {
|
||||
if (!s) return ''
|
||||
const t = String(s)
|
||||
return t.length > n ? t.substring(0, n) + '…' : t
|
||||
},
|
||||
itemKey(item) {
|
||||
return item.business_type + '-' + item.business_id
|
||||
},
|
||||
async quickComplete(item) {
|
||||
const key = this.itemKey(item)
|
||||
if (this.strikeMap[key]) return
|
||||
try {
|
||||
await postponeComplete(item.business_type, item.business_id)
|
||||
// 视觉划掉,1 秒后从列表移除
|
||||
this.$set(this.strikeMap, key, true)
|
||||
setTimeout(() => {
|
||||
const idx = this.pending.findIndex(x => this.itemKey(x) === key)
|
||||
if (idx >= 0) {
|
||||
this.pending.splice(idx, 1)
|
||||
this.doneCount++
|
||||
}
|
||||
this.$delete(this.strikeMap, key)
|
||||
if (!this.pending.length) {
|
||||
this.visible = false
|
||||
this.floatVisible = false
|
||||
}
|
||||
}, 900)
|
||||
} catch (e) {
|
||||
this.$modal.msgError('完成失败,请稍后再试')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 弹窗 */
|
||||
.overdue-dialog .head-meta { font-size: 14px; color: #303133; margin-bottom: 14px; line-height: 1.7; }
|
||||
.overdue-dialog .head-meta b { color: #f56c6c; font-size: 16px; margin: 0 3px; }
|
||||
.overdue-dialog .head-meta .hint-30 {
|
||||
background: #fef0f0; color: #f56c6c; padding: 1px 6px; border-radius: 3px;
|
||||
font-weight: 500; font-size: 13px;
|
||||
}
|
||||
.overdue-dialog .item-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; margin-bottom: 4px; }
|
||||
.overdue-dialog .item-head { margin-bottom: 8px; }
|
||||
.overdue-dialog .item-head .item-title { margin-left: 8px; font-size: 15px; font-weight: 600; color: #303133; }
|
||||
.overdue-dialog .item-meta { font-size: 13px; color: #606266; margin-bottom: 12px; display: flex; gap: 18px; align-items: center; }
|
||||
.overdue-dialog .item-meta .bad { color: #f56c6c; font-weight: 500; }
|
||||
.overdue-dialog .form-area { margin-top: 8px; }
|
||||
.overdue-dialog .footer { display: flex; justify-content: space-between; align-items: center; }
|
||||
.overdue-dialog .footer .muted { color: #909399; font-size: 12px; }
|
||||
|
||||
/* 浮窗 */
|
||||
.overdue-float {
|
||||
position: fixed; right: 16px; bottom: 16px; z-index: 2000;
|
||||
width: 320px; max-height: 60vh;
|
||||
background: #fff; border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(245, 108, 108, 0.25), 0 0 0 2px rgba(245, 108, 108, 0.45);
|
||||
display: flex; flex-direction: column;
|
||||
animation: overduePulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes overduePulse {
|
||||
0%, 100% { box-shadow: 0 8px 32px rgba(245, 108, 108, 0.25), 0 0 0 2px rgba(245, 108, 108, 0.45); }
|
||||
50% { box-shadow: 0 8px 32px rgba(245, 108, 108, 0.45), 0 0 0 3px rgba(245, 108, 108, 0.8); }
|
||||
}
|
||||
.overdue-float .float-head {
|
||||
padding: 12px 14px; background: #fef0f0; border-radius: 10px 10px 0 0;
|
||||
font-size: 14px; font-weight: 600; color: #f56c6c;
|
||||
display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #fde2e2;
|
||||
}
|
||||
.overdue-float .float-head i { font-size: 18px; }
|
||||
.overdue-float .float-list { padding: 4px 0; overflow-y: auto; flex: 1; }
|
||||
.overdue-float .float-item {
|
||||
padding: 8px 14px; cursor: pointer; border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.overdue-float .float-item:hover { background: #fafafa; }
|
||||
.overdue-float .float-item.fresh { border-left-color: #f56c6c; }
|
||||
.overdue-float .float-item.mid { border-left-color: #e6a23c; }
|
||||
.overdue-float .float-item.old { border-left-color: #c0c4cc; }
|
||||
.overdue-float .float-item-title {
|
||||
font-size: 13px; color: #303133;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.overdue-float .float-item-title .title-text { flex: 1; }
|
||||
.overdue-float .float-item-title .boss-tag { margin-left: 2px; }
|
||||
.overdue-float .float-item-meta {
|
||||
font-size: 11px; color: #909399; margin-top: 3px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.overdue-float .float-item-meta .quick-done { padding: 2px 8px; color: #67c23a; font-weight: 500; }
|
||||
.overdue-float .float-item-meta .quick-done:hover { color: #5daf34; }
|
||||
/* 划掉动画 */
|
||||
.overdue-float .float-item.striking {
|
||||
opacity: 0.55; transform: translateX(8px); transition: opacity .9s, transform .9s;
|
||||
}
|
||||
.overdue-float .float-item.striking .title-text {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #67c23a;
|
||||
text-decoration-thickness: 2px;
|
||||
color: #909399;
|
||||
transition: all .9s ease;
|
||||
}
|
||||
.overdue-float .float-item.fresh .float-item-meta { color: #f56c6c; }
|
||||
.overdue-float .float-item.mid .float-item-meta { color: #e6a23c; }
|
||||
.overdue-float .float-footer {
|
||||
padding: 6px 14px; font-size: 11px; color: #909399;
|
||||
border-top: 1px solid #f0f0f0; text-align: center;
|
||||
}
|
||||
|
||||
/* 浮窗动画 */
|
||||
.float-slide-enter-active, .float-slide-leave-active { transition: all 0.3s; }
|
||||
.float-slide-enter, .float-slide-leave-to { opacity: 0; transform: translateY(20px); }
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@
|
||||
</right-panel>
|
||||
</div>
|
||||
<tutorial-guide />
|
||||
<overdue-guard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +21,7 @@
|
||||
import RightPanel from '@/components/RightPanel'
|
||||
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
|
||||
import TutorialGuide from './components/TutorialGuide.vue'
|
||||
import OverdueGuard from './components/OverdueGuard.vue'
|
||||
import ResizeMixin from './mixin/ResizeHandler'
|
||||
import { mapState } from 'vuex'
|
||||
import variables from '@/assets/styles/variables.scss'
|
||||
@@ -31,6 +33,7 @@ export default {
|
||||
Navbar,
|
||||
RightPanel,
|
||||
TutorialGuide,
|
||||
OverdueGuard,
|
||||
Settings,
|
||||
Sidebar,
|
||||
TagsView,
|
||||
|
||||
227
ruoyi-ui/src/views/oa/docs/panorama/index.vue
Normal file
227
ruoyi-ui/src/views/oa/docs/panorama/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="app-container docs-page">
|
||||
<h1>项目全景流 · 使用说明</h1>
|
||||
<el-alert type="info" :closable="false" show-icon
|
||||
title="本系统两个"看大局"的入口:①「集团驾驶舱」一眼看全公司所有项目的红黄绿灯;点进任意一个项目就到 ②「项目全景」,这一页把销售/合同/进度/采购/财务/任务/审批…全部串起来。"
|
||||
style="margin-bottom: 20px;" />
|
||||
|
||||
<!-- 1. 总览图 -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>一、两层结构</b></div>
|
||||
<div class="svg-wrap">
|
||||
<svg viewBox="0 0 1100 360" class="flow-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#606266"/></marker>
|
||||
</defs>
|
||||
|
||||
<!-- 第一层:驾驶舱 -->
|
||||
<rect x="20" y="20" width="1060" height="110" rx="8" fill="#eef9ff" stroke="#91d5ff" />
|
||||
<text x="40" y="55" class="layer">第 1 层 · 集团驾驶舱</text>
|
||||
<text x="40" y="75" class="layer-sub">全公司全部项目一览,红黄绿灯一眼看清谁要救火 / 谁正常 / 谁完成</text>
|
||||
|
||||
<g class="proj">
|
||||
<rect x="60" y="90" width="120" height="28" rx="4" />
|
||||
<text x="120" y="108" class="proj-t">● 项目A(红)</text>
|
||||
</g>
|
||||
<g class="proj">
|
||||
<rect x="200" y="90" width="120" height="28" rx="4" />
|
||||
<text x="260" y="108" class="proj-t">● 项目B(黄)</text>
|
||||
</g>
|
||||
<g class="proj">
|
||||
<rect x="340" y="90" width="120" height="28" rx="4" />
|
||||
<text x="400" y="108" class="proj-t">● 项目C(绿)</text>
|
||||
</g>
|
||||
<text x="490" y="108" class="dots">…… N 个项目</text>
|
||||
|
||||
<!-- 下钻 -->
|
||||
<text x="540" y="160" class="dive">点任意一行 → 下钻到详情</text>
|
||||
<line x1="490" y1="170" x2="540" y2="200" stroke="#606266" stroke-width="1.6" marker-end="url(#arr)" />
|
||||
|
||||
<!-- 第二层:项目全景 -->
|
||||
<rect x="20" y="180" width="1060" height="160" rx="8" fill="#fff7e6" stroke="#ffd591" />
|
||||
<text x="40" y="215" class="layer">第 2 层 · 项目全景(单项目)</text>
|
||||
<text x="40" y="235" class="layer-sub">把这一个项目跨越所有部门的关键信息全聚在一页,每张卡有"详情→"按钮跳到原模块</text>
|
||||
|
||||
<g class="card">
|
||||
<rect x="50" y="250" width="120" height="40" rx="4" />
|
||||
<text x="110" y="266" class="cc">健康度</text>
|
||||
<text x="110" y="282" class="cc-sub">红 / 黄 / 绿</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="180" y="250" width="100" height="40" rx="4" />
|
||||
<text x="230" y="274" class="cc">合同</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="290" y="250" width="100" height="40" rx="4" />
|
||||
<text x="340" y="274" class="cc">进度</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="400" y="250" width="100" height="40" rx="4" />
|
||||
<text x="450" y="274" class="cc">采购</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="510" y="250" width="100" height="40" rx="4" />
|
||||
<text x="560" y="274" class="cc">库房</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="620" y="250" width="100" height="40" rx="4" />
|
||||
<text x="670" y="274" class="cc">财务</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="730" y="250" width="100" height="40" rx="4" />
|
||||
<text x="780" y="274" class="cc">任务/团队</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="840" y="250" width="100" height="40" rx="4" />
|
||||
<text x="890" y="274" class="cc">审批活动</text>
|
||||
</g>
|
||||
<g class="card">
|
||||
<rect x="950" y="250" width="100" height="40" rx="4" />
|
||||
<text x="1000" y="274" class="cc">制造主线</text>
|
||||
</g>
|
||||
|
||||
<text x="540" y="320" class="dive">每张卡 → "详情→"跳到原模块编辑</text>
|
||||
</svg>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 2. 红绿灯怎么判 -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>二、红绿灯怎么判(决定老板要不要管这个项目)</b></div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="state-card red">
|
||||
<h3>🔴 红灯 · 必须立刻介入</h3>
|
||||
<ul>
|
||||
<li>项目已过工期但状态还是「进行中」</li>
|
||||
<li>进度计划已超期</li>
|
||||
<li>≥ 5 个任务超期</li>
|
||||
<li>≥ 5 张审批单待办堆积</li>
|
||||
</ul>
|
||||
<p class="hint">任意一条命中就是红灯。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card yellow">
|
||||
<h3>🟡 黄灯 · 值得关注</h3>
|
||||
<ul>
|
||||
<li>累计延期 ≥ 2 次</li>
|
||||
<li>1~4 个任务超期</li>
|
||||
<li>1~4 张审批单待办</li>
|
||||
<li>已支出 > 已收款(现金净流出)</li>
|
||||
</ul>
|
||||
<p class="hint">没有红灯但命中黄灯条件就是黄灯。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card green">
|
||||
<h3>🟢 绿灯 · 进展正常</h3>
|
||||
<ul>
|
||||
<li>红黄都没命中</li>
|
||||
</ul>
|
||||
<p class="hint">系统会一句话告诉老板"该项目进展正常,无需立即介入"。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 3. 三类角色怎么用 -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>三、不同角色怎么用这两页</b></div>
|
||||
<el-table :data="roleSteps" border size="small">
|
||||
<el-table-column label="你是" prop="role" width="120" />
|
||||
<el-table-column label="先打开" prop="entry" width="220" />
|
||||
<el-table-column label="然后看" prop="focus" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 4. 全景页"九张卡" -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>四、全景页那 9 张卡分别讲什么</b></div>
|
||||
<el-table :data="cards" border size="small">
|
||||
<el-table-column label="卡片" prop="name" width="120" />
|
||||
<el-table-column label="它显示什么" prop="what" />
|
||||
<el-table-column label="点哪里下钻" prop="drill" width="160" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 5. FAQ -->
|
||||
<el-card class="doc-card" shadow="never">
|
||||
<div slot="header"><b>五、常见问题</b></div>
|
||||
<el-collapse>
|
||||
<el-collapse-item title="为什么我看到的项目少?">
|
||||
驾驶舱默认只显示「进行中」的项目(最常用)。把顶部「状态」筛选改成「全部」或「已完结」可看其他状态的项目。打开「只看异常」开关可只看红黄灯项目。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="红绿灯什么时候更新?我刚处理完任务,灯还是红的">
|
||||
打开页面时算的。处理完了点页面上的「刷新」就会重算。不会自动轮询(避免给数据库压力)。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="净现金那个数字能当利润看吗?">
|
||||
<b>不能。</b> 它就是简单「已收款 − 已记成本」,不含税、不含未到的应收/应付、不区分项目阶段。仅当"这个项目当前是不是在烧钱"参考。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="制造主线那张卡某些项目不显示?">
|
||||
只有挂了「轧机厂相关数据」(安装进度 / 调试记录 / 验收清单)的项目才会出现这张卡。普通项目不显示是正常的。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="点"详情→"跳错了页">
|
||||
有些跳转链接走的是新写的路由,如果你的角色没勾这些菜单权限就跳不进去。让管理员去角色管理给你开权限即可。
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'OaDocPanorama',
|
||||
data() {
|
||||
return {
|
||||
roleSteps: [
|
||||
{ role: '老板/总监', entry: '集团驾驶舱', focus: '先看顶部 6 个全局指标(在跑/超期/审批堆积);再扫表格 — 重点看红灯/黄灯行;点行进项目全景看细节。' },
|
||||
{ role: '项目负责人', entry: '项目全景', focus: '搜索自己负责的项目;最上方"健康度大卡"告诉你这单要不要救火;再看每张分卡。' },
|
||||
{ role: '一线员工', entry: '原业务模块', focus: '日常在原模块(合同/采购/任务)干活;偶尔来全景页看看自己的项目整体走到哪儿。' }
|
||||
],
|
||||
cards: [
|
||||
{ name: '健康度', what: '红/黄/绿灯 + 一句话总结 + 命中的红灯/黄灯信号', drill: '—' },
|
||||
{ name: 'KPI 大字', what: '项目金额 / 已收款 / 已记成本 / 净现金 / 进度% / 待审批 / 超期任务 / 延期次数', drill: '部分可点跳' },
|
||||
{ name: '合同', what: '该项目所有合同的列表 + 金额合计 + 每份的审批状态', drill: '"查看全部→"' },
|
||||
{ name: '进度计划', what: '前 6 步的甘特,已完成/进行中/未开始;是否超期;延期申请次数', drill: '"查看完整→"' },
|
||||
{ name: '采购需求', what: '该项目下采购需求 4 种状态分布 + 最近 5 条 + 审批状态', drill: '"查看全部→"' },
|
||||
{ name: '到货 & 库房', what: '采购到货 3 种状态分布 + 库房申请状态分布', drill: '"查看到货明细→"' },
|
||||
{ name: '财务', what: '合同总金额 / 计划收款 / 已收 / 待收 / 已记成本 / 净现金', drill: '"查看流水→"' },
|
||||
{ name: '任务', what: '执行中 / 待验收 / 完成 / 延期申请 4 种状态计数 + 超期数', drill: '"查看全部→"' },
|
||||
{ name: '团队', what: '项目负责人 + 所有参与任务的人', drill: '—' },
|
||||
{ name: '报告 & 会议', what: '最近工作日志 + 会议纪要', drill: '"日志→"' },
|
||||
{ name: '制造主线', what: '(仅轧机厂项目)安装进度 / 调试记录 / 验收清单', drill: '—' },
|
||||
{ name: '审批活动', what: '这个项目下所有审批单的统一时间线', drill: '"审批中心→"' },
|
||||
{ name: '操作日志', what: '该项目最近 20 条操作流水', drill: '—' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.docs-page { max-width: 1240px; }
|
||||
.docs-page h1 { font-size: 22px; margin: 0 0 16px; }
|
||||
.doc-card { margin-bottom: 16px; }
|
||||
|
||||
.svg-wrap { width: 100%; overflow-x: auto; }
|
||||
.flow-svg { width: 100%; min-width: 1000px; height: auto; display: block; }
|
||||
.flow-svg .layer { font-size: 16px; font-weight: 700; fill: #303133; }
|
||||
.flow-svg .layer-sub { font-size: 12px; fill: #606266; }
|
||||
.flow-svg .proj rect { fill: #fff; stroke: #91d5ff; stroke-width: 1.2; }
|
||||
.flow-svg .proj .proj-t { font-size: 12px; fill: #303133; text-anchor: middle; }
|
||||
.flow-svg .card rect { fill: #fff; stroke: #ffa940; stroke-width: 1.2; }
|
||||
.flow-svg .card .cc { font-size: 13px; fill: #303133; text-anchor: middle; font-weight: 600; }
|
||||
.flow-svg .card .cc-sub { font-size: 10px; fill: #909399; text-anchor: middle; }
|
||||
.flow-svg .dots { font-size: 13px; fill: #909399; }
|
||||
.flow-svg .dive { font-size: 12px; fill: #606266; font-style: italic; }
|
||||
|
||||
.state-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; height: 100%; }
|
||||
.state-card.red { background: #fef0f0; }
|
||||
.state-card.yellow { background: #fdf6ec; }
|
||||
.state-card.green { background: #f0f9eb; }
|
||||
.state-card h3 { margin: 0 0 8px; font-size: 14px; color: #303133; }
|
||||
.state-card ul { padding-left: 22px; margin: 8px 0; line-height: 1.8; color: #606266; font-size: 13px; }
|
||||
.state-card .hint { color: #909399; font-size: 12px; margin: 4px 0 0; }
|
||||
</style>
|
||||
258
ruoyi-ui/src/views/oa/docs/performance/index.vue
Normal file
258
ruoyi-ui/src/views/oa/docs/performance/index.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="docs">
|
||||
<div class="title-bar">
|
||||
<h1>绩效计算标准</h1>
|
||||
<span class="muted">每月 1 号自动归零;周期 = 自然月</span>
|
||||
</div>
|
||||
|
||||
<!-- 1. 公式 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>1. 总分模型</b></div>
|
||||
<div class="card-body">
|
||||
<div class="formula">
|
||||
<span class="part bg-blue">基础 80</span>
|
||||
<span class="op">+</span>
|
||||
<span class="part bg-green">系统加分 +N</span>
|
||||
<span class="op">−</span>
|
||||
<span class="part bg-red">系统扣分 -N</span>
|
||||
<span class="op">+</span>
|
||||
<span class="part bg-purple">高总主观 ±20</span>
|
||||
<span class="op">=</span>
|
||||
<span class="part bg-grey">总分 0~100</span>
|
||||
</div>
|
||||
<el-alert type="success" :closable="false" show-icon>
|
||||
<b>初始默认:基础 80 → 等级 B+</b>。员工没做错也没做好 = 80 分。<br/>
|
||||
要往 A 升,得自己加分(完成任务/报工/出差)+ 高总评价;想跌到 C/D 就是被扣分了。
|
||||
</el-alert>
|
||||
<h4>等级标尺(100 分制)</h4>
|
||||
<div class="grade-line">
|
||||
<div class="g-cell g-a">A+ ≥ 95</div>
|
||||
<div class="g-cell g-a">A ≥ 90</div>
|
||||
<div class="g-cell g-a">A- ≥ 85</div>
|
||||
<div class="g-cell g-b">B+ ≥ 80</div>
|
||||
<div class="g-cell g-b">B ≥ 75</div>
|
||||
<div class="g-cell g-b">B- ≥ 70</div>
|
||||
<div class="g-cell g-c">C+ ≥ 65</div>
|
||||
<div class="g-cell g-c">C ≥ 60</div>
|
||||
<div class="g-cell g-c">C- ≥ 55</div>
|
||||
<div class="g-cell g-d">D < 55</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 加分 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>2. 系统自动加分</b></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="rewards" border>
|
||||
<el-table-column label="行为" prop="name" width="160" />
|
||||
<el-table-column label="分值" prop="pts" width="100" align="center">
|
||||
<template slot-scope="{row}"><b class="ok">{{ row.pts }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="月度上限" prop="cap" width="100" align="center" />
|
||||
<el-table-column label="触发时机" prop="how" />
|
||||
</el-table>
|
||||
<p class="hint">加分会把总分推上去,<b>没有 60 那个 cap 了</b>(之前模型才有),现在所有加扣全部直接进总分,封顶 100。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 扣分 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>3. 系统自动扣分</b></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="penalties" border>
|
||||
<el-table-column label="行为" prop="name" width="180" />
|
||||
<el-table-column label="扣分" prop="pts" width="100" align="center">
|
||||
<template slot-scope="{row}"><b class="bad">{{ row.pts }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否粘性" prop="stick" width="120" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag size="mini" :type="row.stick==='是' ? 'danger' : 'info'">{{ row.stick }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" prop="how" />
|
||||
</el-table>
|
||||
<p class="hint">
|
||||
<b>粘性 = 一旦扣了不能事后补救</b>(比如忘了报工,21:00 检测时就扣,第二天补写也不退)。<br/>
|
||||
只有「日逾期」是非粘性的 — 你今天解决了,明天就不再继续扣。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 高总主观分 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>4. 高总主观分(-20 ~ +20)</b></div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
每月由高总在「全员绩效」页给每个员工手动评价:
|
||||
</p>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<div class="state-card ok">
|
||||
<h4>+1 ~ +20</h4>
|
||||
<p>员工表现突出 / 系统错扣需要回血 / 重大贡献。</p>
|
||||
<p class="hint">这就是"<b>20 分冗余</b>"—— 项目多被误扣的可以由高总评价补回来。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card">
|
||||
<h4>0(默认)</h4>
|
||||
<p>未评价 = 中性,对总分无影响。</p>
|
||||
<p class="hint">列表里显示「待评」灰标签,但不影响员工的客观分数。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="state-card bad">
|
||||
<h4>-1 ~ -20</h4>
|
||||
<p>态度问题 / 客户投诉 / 系统未覆盖的严重过错。</p>
|
||||
<p class="hint">注意:系统扣分已经覆盖了大部分客观错误,主观负分要慎重。</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<h4 style="margin-top: 16px;">参考维度</h4>
|
||||
<div class="dim-grid">
|
||||
<div class="dim"><b>业务贡献</b><br/>客户开发 / 销售业绩 / 重大项目突破</div>
|
||||
<div class="dim"><b>协作配合</b><br/>跨部门协助 / 主动补位 / 同事评价</div>
|
||||
<div class="dim"><b>主动担当</b><br/>主动揽事 / 解决难题 / 顶班</div>
|
||||
<div class="dim"><b>学习成长</b><br/>技能精进 / 流程改进 / 分享经验</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 算例 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>5. 算例</b></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="examples" border>
|
||||
<el-table-column label="员工" prop="role" width="160" />
|
||||
<el-table-column label="自动加" align="center" width="100">
|
||||
<template slot-scope="{row}"><span class="ok">+{{ row.reward }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="自动扣" align="center" width="100">
|
||||
<template slot-scope="{row}"><span class="bad">-{{ row.ded }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="高总主观" align="center" width="100">
|
||||
<template slot-scope="{row}">
|
||||
<span :style="row.bonus > 0 ? 'color:#67c23a' : (row.bonus < 0 ? 'color:#f56c6c' : '')">
|
||||
{{ row.bonus > 0 ? '+' : '' }}{{ row.bonus }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总分" align="center" width="100">
|
||||
<template slot-scope="{row}"><b>{{ row.total }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" align="center" width="80">
|
||||
<template slot-scope="{row}"><el-tag size="mini" :type="row.tag">{{ row.grade }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="点评" prop="note" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. 常见问题 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><b>6. 常见问题</b></div>
|
||||
<div class="card-body">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="为什么我刚入职就是 B+?">
|
||||
B+ 是「中性」起点。没扣分 = 80 = B+。要上 A 系列得靠自己加分(完成任务/报工/全勤)+ 高总评价。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="项目特别多,被扣分会不会太狠?">
|
||||
高总有 ±20 的"冗余"可以加回来。月底高总评价时,错扣的部分可以补到 +20。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="我做了很多任务但等级没上来?">
|
||||
完成任务每条 +1 上限 +10/月。加上报工 +6 / 出差 +6 / 全勤 +3 = 最多自动加 31 分,足够把 80 推到 100(A+)。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="忘了报工怎么办?补写能退分吗?">
|
||||
<b>不能</b>。系统每天 21:00 检测,那一刻没有当天报工就扣 1 分,事后补录不退分。这是"粘性扣分"。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="逾期任务每天都扣,会不会越扣越多?">
|
||||
会。每天每条逾期事项扣 1 分,没有月度上限。但<b>当天解决了第二天就不扣了</b>。所以一发现逾期赶紧处理或者主动顺延。
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="顺延超过 3 次为什么要走审批?">
|
||||
防止"装死" — 一项事故顺延一次可以,反复推就要让高总知情,必须经过审批通过才能继续顺延,过不了就要被迫面对扣分。
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'OaDocPerformance',
|
||||
data() {
|
||||
return {
|
||||
rewards: [
|
||||
{ name: '完成任务', pts: '+1', cap: '+10', how: '任务状态推到「完成」时按执行人计入' },
|
||||
{ name: '完成进度步骤', pts: '+1', cap: '+6', how: '步骤状态推到「完成」时按进度负责人计入' },
|
||||
{ name: '提交工作日报', pts: '+1', cap: '+6', how: '在系统提交报工记录' },
|
||||
{ name: '出差登记', pts: '+1/天', cap: '+6', how: '工作日报里勾选"是否出差"' },
|
||||
{ name: '月度全勤', pts: '+3', cap: '+3', how: '当月考勤打卡 ≥ 18 天,月初算上月' }
|
||||
],
|
||||
penalties: [
|
||||
{ name: '顺延一次', pts: '-1', stick: '是', how: '每次顺延独立扣 1 分;第 3 次起需高总审批' },
|
||||
{ name: '超期 ≥ 30 天', pts: '-20', stick: '是', how: '同一事项一次性扣 20 分,并 IM 通知高总' },
|
||||
{ name: '当天没报工', pts: '-1', stick: '是', how: '每天 21:00 检测今天是否有报工;漏了就扣,补写不退' },
|
||||
{ name: '逾期事项 / 天', pts: '-1', stick: '否', how: '每天扫描,仍处于逾期状态的事项每条 -1。次日解决就不再扣' },
|
||||
{ name: '高总手动扣分', pts: '自定义', stick: '是', how: '高总/陆总/admin 在「全员绩效」页可手动扣分(带原因留痕)' }
|
||||
],
|
||||
examples: [
|
||||
{ role: '正常员工', reward: 0, ded: 0, bonus: 0, total: 80, grade: 'B+', tag: '', note: '初始状态,谁都不偏不倚' },
|
||||
{ role: '勤奋员工', reward: 20, ded: 0, bonus: 5, total: 100, grade: 'A+', tag: 'success', note: '完成任务多 + 高总评价好' },
|
||||
{ role: '正常员工 + 高总加分', reward: 0, ded: 0, bonus: 10, total: 90, grade: 'A', tag: 'success', note: '协作好、被高总看在眼里' },
|
||||
{ role: '项目多被错扣 + 高总回血', reward: 5, ded: 18, bonus: 13, total: 80, grade: 'B+', tag: '', note: '高总用主观分把错扣回血' },
|
||||
{ role: '忘报工 5 次 + 顺延 3 次', reward: 0, ded: 8, bonus: 0, total: 72, grade: 'B-', tag: 'warning', note: '日常不严谨,掉到 B-' },
|
||||
{ role: '超期 30 天 + 多次顺延', reward: 0, ded: 26, bonus: -5, total: 49, grade: 'D', tag: 'danger', note: '严重 + 高总差评,D 级' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.docs { padding: 16px 20px; background: #f5f7fa; min-height: calc(100vh - 50px); }
|
||||
.title-bar { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
|
||||
.title-bar h1 { font-size: 22px; margin: 0; }
|
||||
.muted { color: #909399; font-size: 13px; }
|
||||
.ok { color: #67c23a; }
|
||||
.bad { color: #f56c6c; }
|
||||
|
||||
.card { background: #fff; border-radius: 10px; padding: 18px 22px; margin-bottom: 14px; }
|
||||
.card-head { font-size: 16px; margin-bottom: 12px; color: #303133; }
|
||||
.card-body p { line-height: 1.8; color: #606266; }
|
||||
.card-body h4 { margin: 14px 0 8px; font-size: 14px; }
|
||||
.hint { color: #909399; font-size: 12px; margin-top: 10px; line-height: 1.8; }
|
||||
|
||||
.formula {
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px;
|
||||
font-size: 13px; font-weight: 600; padding: 18px;
|
||||
background: #fafafa; border-radius: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.formula .part { padding: 6px 12px; border-radius: 6px; color: #fff; white-space: nowrap; }
|
||||
.formula .part.bg-blue { background: linear-gradient(135deg, #409EFF, #337ecc); }
|
||||
.formula .part.bg-green { background: linear-gradient(135deg, #67c23a, #5daf34); }
|
||||
.formula .part.bg-red { background: linear-gradient(135deg, #f56c6c, #d65b5b); }
|
||||
.formula .part.bg-purple { background: linear-gradient(135deg, #b37feb, #9254de); }
|
||||
.formula .part.bg-grey { background: linear-gradient(135deg, #606266, #303133); }
|
||||
.formula .op { color: #909399; font-size: 16px; }
|
||||
|
||||
.grade-line { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.g-cell {
|
||||
padding: 4px 10px; border-radius: 4px; font-size: 12px; color: #fff; font-weight: 600;
|
||||
}
|
||||
.g-cell.g-a { background: #67c23a; }
|
||||
.g-cell.g-b { background: #409EFF; }
|
||||
.g-cell.g-c { background: #e6a23c; }
|
||||
.g-cell.g-d { background: #f56c6c; }
|
||||
|
||||
.state-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; height: 100%; }
|
||||
.state-card.ok { background: #f0f9eb; }
|
||||
.state-card.bad { background: #fef0f0; }
|
||||
.state-card h4 { margin: 0 0 8px; }
|
||||
.state-card p { font-size: 13px; }
|
||||
|
||||
.dim-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 14px 0; }
|
||||
.dim { background: #fafafa; border-left: 3px solid #409EFF; border-radius: 6px; padding: 12px 16px; font-size: 12px; color: #606266; line-height: 1.7; }
|
||||
.dim b { color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
218
ruoyi-ui/src/views/oa/performance/mine/index.vue
Normal file
218
ruoyi-ui/src/views/oa/performance/mine/index.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="perf">
|
||||
<div class="toolbar">
|
||||
<el-date-picker v-model="period" type="month" format="yyyy-MM" value-format="yyyy-MM"
|
||||
placeholder="选择月份" size="small" @change="load" style="width: 180px;" />
|
||||
<el-button size="small" icon="el-icon-refresh" @click="load">刷新</el-button>
|
||||
<span class="muted">本月规则:基础 60 + 高总主观 0~40 − 系统自动扣分 = 总分</span>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="hero">
|
||||
<div class="card-grade" :class="gradeClass(score.grade)">
|
||||
<div class="grade-letter">{{ score.grade || '-' }}</div>
|
||||
<div class="grade-period">{{ period }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-breakdown">
|
||||
<div class="break-title">总分构成</div>
|
||||
<div class="break-rows">
|
||||
<div class="brow">
|
||||
<span class="b-lbl">客观基础</span>
|
||||
<span class="b-bar"><span class="b-fill b1" :style="{width: pct(score.baseScore || 60, 60)}"></span></span>
|
||||
<span class="b-val">{{ score.baseScore || 60 }}</span>
|
||||
</div>
|
||||
<div class="brow">
|
||||
<span class="b-lbl bad">系统扣分</span>
|
||||
<span class="b-bar"><span class="b-fill b2" :style="{width: pct(score.deduction || 0, 60)}"></span></span>
|
||||
<span class="b-val bad">-{{ score.deduction || 0 }}</span>
|
||||
</div>
|
||||
<div class="brow">
|
||||
<span class="b-lbl">高总主观</span>
|
||||
<span class="b-bar"><span class="b-fill b3" :style="{width: pct(score.bonus || 0, 40)}"></span></span>
|
||||
<span class="b-val">+{{ score.bonus || 0 }} / 40</span>
|
||||
</div>
|
||||
<div class="brow strong">
|
||||
<span class="b-lbl">总分</span>
|
||||
<span class="b-bar"><span class="b-fill b4" :style="{width: pct(score.totalScore || 0, 100)}"></span></span>
|
||||
<span class="b-val" :class="totalClass(score.totalScore)">{{ score.totalScore }} / 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card panel">
|
||||
<div class="panel-head">
|
||||
<b>本月扣分流水</b>
|
||||
<span class="muted">{{ deductions.length }} 条</span>
|
||||
</div>
|
||||
<el-empty v-if="!deductions.length" :image-size="60" description="本月没有扣分记录" />
|
||||
<el-table v-else :data="deductions" size="small" border>
|
||||
<el-table-column label="时间" prop="createTime" width="160" />
|
||||
<el-table-column label="类型" width="110" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag :type="srcTag(row.source)" size="mini">{{ srcLabel(row.source) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务" width="100" align="center">
|
||||
<template slot-scope="{row}">{{ bizLabel(row.sourceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="扣分" width="80" align="center">
|
||||
<template slot-scope="{row}"><b style="color:#f56c6c">-{{ row.points }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" prop="reason" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="card panel">
|
||||
<div class="panel-head"><b>规则说明</b></div>
|
||||
<div class="rules">
|
||||
<div class="rule-card">
|
||||
<div class="r-num">60</div>
|
||||
<div class="r-text"><b>客观基础</b><br/>系统根据顺延 / 超期自动扣分</div>
|
||||
</div>
|
||||
<div class="rule-card">
|
||||
<div class="r-num">0~40</div>
|
||||
<div class="r-text"><b>高总主观</b><br/>每月由高总打分,默认满分 40</div>
|
||||
</div>
|
||||
<div class="rule-card warn">
|
||||
<div class="r-num">-1</div>
|
||||
<div class="r-text"><b>每次顺延</b><br/>第 3 次起需高总审批</div>
|
||||
</div>
|
||||
<div class="rule-card bad">
|
||||
<div class="r-num">-20</div>
|
||||
<div class="r-text"><b>超期 ≥ 30 天</b><br/>同一事项只扣一次,并通知高总</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { myPerformance } from '@/api/oa/performance'
|
||||
|
||||
export default {
|
||||
name: 'OaPerformanceMine',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
period: '',
|
||||
score: { baseScore: 60, deduction: 0, bonus: 40, totalScore: 100, grade: 'A+' },
|
||||
deductions: []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const d = new Date()
|
||||
this.period = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
myPerformance(this.period).then(res => {
|
||||
const d = res.data || {}
|
||||
this.score = d.score || this.score
|
||||
this.deductions = d.deductions || []
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
pct(v, max) {
|
||||
if (!max) return '0%'
|
||||
const p = Math.max(0, Math.min(100, (v / max) * 100))
|
||||
return p + '%'
|
||||
},
|
||||
gradeClass(g) {
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'g-a'
|
||||
if (g.startsWith('B')) return 'g-b'
|
||||
if (g.startsWith('C')) return 'g-c'
|
||||
return 'g-d'
|
||||
},
|
||||
totalClass(n) {
|
||||
n = Number(n) || 0
|
||||
if (n >= 90) return 'ok'
|
||||
if (n >= 75) return 'warn'
|
||||
return 'bad'
|
||||
},
|
||||
srcLabel(s) { return ({ postpone:'顺延', overdue30:'超期30天', manual:'手动', manual_subjective:'高总打分' })[s] || s },
|
||||
srcTag(s) { return ({ postpone:'warning', overdue30:'danger', manual:'info', manual_subjective:'success' })[s] || '' },
|
||||
bizLabel(t) { return ({ task:'任务', step:'步骤', requirement:'采购需求' })[t] || (t || '-') }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.perf {
|
||||
padding: 16px 20px; background: #f5f7fa;
|
||||
min-height: calc(100vh - 50px);
|
||||
}
|
||||
.muted { color: #909399; font-size: 12px; }
|
||||
.ok { color: #67c23a; }
|
||||
.warn { color: #e6a23c; }
|
||||
.bad { color: #f56c6c; }
|
||||
|
||||
.toolbar {
|
||||
background: #fff; padding: 12px 16px; border-radius: 8px; margin-bottom: 14px;
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid; grid-template-columns: 320px 1fr; gap: 14px; margin-bottom: 14px;
|
||||
}
|
||||
.card-grade {
|
||||
background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%);
|
||||
border-radius: 10px; padding: 28px 20px; text-align: center;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
.card-grade.g-a { background: linear-gradient(135deg, #f0f9eb 0%, #fff 100%); }
|
||||
.card-grade.g-b { background: linear-gradient(135deg, #ecf5ff 0%, #fff 100%); }
|
||||
.card-grade.g-c { background: linear-gradient(135deg, #fdf6ec 0%, #fff 100%); }
|
||||
.card-grade.g-d { background: linear-gradient(135deg, #fef0f0 0%, #fff 100%); }
|
||||
.grade-letter {
|
||||
font-size: 110px; font-weight: 800; line-height: 1;
|
||||
color: #67c23a;
|
||||
}
|
||||
.card-grade.g-a .grade-letter { color: #67c23a; }
|
||||
.card-grade.g-b .grade-letter { color: #409EFF; }
|
||||
.card-grade.g-c .grade-letter { color: #e6a23c; }
|
||||
.card-grade.g-d .grade-letter { color: #f56c6c; }
|
||||
.grade-period { color: #909399; margin-top: 8px; font-size: 14px; }
|
||||
|
||||
.card-breakdown {
|
||||
background: #fff; border-radius: 10px; padding: 22px 24px;
|
||||
}
|
||||
.break-title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 18px; }
|
||||
.brow {
|
||||
display: grid; grid-template-columns: 80px 1fr 110px; align-items: center;
|
||||
gap: 14px; margin-bottom: 14px; font-size: 13px;
|
||||
}
|
||||
.brow.strong { border-top: 1px dashed #ebeef5; padding-top: 14px; margin-top: 6px; }
|
||||
.brow.strong .b-lbl, .brow.strong .b-val { font-weight: 700; font-size: 15px; }
|
||||
.b-lbl { color: #606266; }
|
||||
.b-val { text-align: right; font-family: Menlo, Consolas, monospace; color: #303133; }
|
||||
.b-bar {
|
||||
height: 14px; background: #f4f4f5; border-radius: 7px; overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.b-fill { display: block; height: 100%; border-radius: 7px; transition: width .5s; }
|
||||
.b1 { background: #c0c4cc; }
|
||||
.b2 { background: #f56c6c; }
|
||||
.b3 { background: #67c23a; }
|
||||
.b4 { background: linear-gradient(90deg, #409EFF 0%, #67c23a 100%); }
|
||||
|
||||
.card.panel { background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 14px; }
|
||||
.panel-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
|
||||
.rules { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.rule-card {
|
||||
background: #fafafa; border-radius: 8px; padding: 16px;
|
||||
display: flex; gap: 12px; align-items: center; border-left: 3px solid #409EFF;
|
||||
}
|
||||
.rule-card.warn { border-left-color: #e6a23c; }
|
||||
.rule-card.bad { border-left-color: #f56c6c; }
|
||||
.rule-card .r-num {
|
||||
font-size: 26px; font-weight: 700; color: #409EFF; min-width: 60px; text-align: center;
|
||||
}
|
||||
.rule-card.warn .r-num { color: #e6a23c; }
|
||||
.rule-card.bad .r-num { color: #f56c6c; }
|
||||
.rule-card .r-text { font-size: 12px; line-height: 1.6; color: #606266; }
|
||||
.rule-card .r-text b { color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
521
ruoyi-ui/src/views/oa/performance/rank/index.vue
Normal file
521
ruoyi-ui/src/views/oa/performance/rank/index.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="rank">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="left">
|
||||
<el-date-picker v-model="query.period" type="month" format="yyyy-MM" value-format="yyyy-MM"
|
||||
size="small" @change="load" style="width:160px;" />
|
||||
<el-input v-model="query.nameLike" placeholder="搜索姓名" clearable size="small" style="width:200px;"
|
||||
@keyup.enter.native="load" @clear="load">
|
||||
<i slot="prefix" class="el-input__icon el-icon-search"></i>
|
||||
</el-input>
|
||||
<el-button size="small" type="primary" icon="el-icon-search" @click="load">查询</el-button>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="filter-tabs">
|
||||
<span class="tab" :class="{active: filter==='all'}" @click="filter='all'">全部 {{ list.length }}</span>
|
||||
<span class="tab ok" :class="{active: filter==='A'}" @click="filter='A'">A {{ countByLetter('A') }}</span>
|
||||
<span class="tab b" :class="{active: filter==='B'}" @click="filter='B'">B {{ countByLetter('B') }}</span>
|
||||
<span class="tab c" :class="{active: filter==='C'}" @click="filter='C'">C {{ countByLetter('C') }}</span>
|
||||
<span class="tab d" :class="{active: filter==='D'}" @click="filter='D'">D {{ countByLetter('D') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 前三名领奖台 -->
|
||||
<div v-if="!filter || filter==='all'" class="podium" v-loading="loading">
|
||||
<template v-for="(row, idx) in podium">
|
||||
<div :key="row.user_id" class="podium-slot" :class="'p-' + (idx+1)">
|
||||
<div class="medal">
|
||||
<i class="el-icon-trophy"></i>
|
||||
<span class="rank-text">No.{{ idx+1 }}</span>
|
||||
</div>
|
||||
<div class="podium-card" :class="cardClass(row.grade)">
|
||||
<div class="p-grade" :class="gradeClass(row.grade)">{{ row.grade }}</div>
|
||||
<div class="p-name">{{ row.user_nick }}</div>
|
||||
<div class="p-score">
|
||||
<span class="num" :class="totalClass(row.total_score)">{{ row.total_score }}</span>
|
||||
<span class="div">/ 100</span>
|
||||
</div>
|
||||
<div class="p-mini">
|
||||
<span>基础 {{ row.base_score }}</span>
|
||||
<span v-if="row.reward > 0" class="ok">+{{ row.reward }}</span>
|
||||
<span v-if="row.deduction > 0" class="bad">-{{ row.deduction }}</span>
|
||||
<span>主观 {{ row.bonus }}</span>
|
||||
</div>
|
||||
<div class="p-actions" v-if="canScore">
|
||||
<el-button size="mini" type="primary" plain @click="openScore(row)">打分</el-button>
|
||||
<el-button size="mini" type="warning" plain @click="openAdjust(row)">加减分</el-button>
|
||||
</div>
|
||||
<el-button size="mini" type="text" @click="openDetail(row)">查看流水 →</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 其余排名表格 -->
|
||||
<div class="rest" v-loading="loading">
|
||||
<el-table :data="restList" border stripe :row-class-name="rowClass">
|
||||
<el-table-column label="排名" type="index" width="70" align="center" :index="indexFn" />
|
||||
<el-table-column label="员工" prop="user_nick" min-width="140">
|
||||
<template slot-scope="{row}">
|
||||
<span class="user-cell">
|
||||
<span class="dot" :class="gradeDot(row.grade)"></span>
|
||||
{{ row.user_nick }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span class="grade-tag" :class="gradeClass(row.grade)">{{ row.grade }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总分" width="100" align="center" sortable prop="total_score">
|
||||
<template slot-scope="{row}">
|
||||
<b :class="totalClass(row.total_score)">{{ row.total_score }}</b>
|
||||
<span class="of">/100</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="基础" prop="base_score" width="80" align="center" />
|
||||
<el-table-column label="加" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span v-if="row.reward > 0" class="ok">+{{ row.reward }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="扣" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span v-if="row.deduction > 0" class="bad">-{{ row.deduction }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主观" width="110" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<span :style="(row.bonus||0) > 0 ? 'color:#67c23a' : (row.bonus||0) < 0 ? 'color:#f56c6c' : ''">
|
||||
{{ (row.bonus || 0) > 0 ? '+' : '' }}{{ row.bonus || 0 }}
|
||||
</span>
|
||||
<el-tag v-if="row.subjective_set !== 1 && row.subjectiveSet !== 1"
|
||||
size="mini" type="info" effect="plain" style="margin-left:4px;">待评</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" align="center" fixed="right">
|
||||
<template slot-scope="{row}">
|
||||
<el-button type="text" size="mini" @click="openDetail(row)">流水</el-button>
|
||||
<el-button type="text" size="mini" style="color:#409EFF" @click="openScore(row)" v-if="canScore">打分</el-button>
|
||||
<el-button type="text" size="mini" style="color:#e6a23c" @click="openAdjust(row)" v-if="canScore">加/扣</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && !restList.length && !podium.length" description="本周期暂无数据" />
|
||||
</div>
|
||||
|
||||
<!-- 加减分弹窗 -->
|
||||
<el-dialog title="手动加减分" :visible.sync="adjustOpen" width="500px" append-to-body :close-on-click-modal="false">
|
||||
<div v-if="adjustForm.userId" class="form-body">
|
||||
<div class="form-user">
|
||||
<div class="u-name">{{ adjustForm.userName }}</div>
|
||||
<div class="u-period">{{ query.period }} 月度</div>
|
||||
</div>
|
||||
<el-form ref="adjustFormRef" :model="adjustForm" :rules="adjustRules" label-width="90px" size="small">
|
||||
<el-form-item label="操作">
|
||||
<el-radio-group v-model="adjustForm.dir">
|
||||
<el-radio-button :label="-1"><i class="el-icon-plus"></i> 加分</el-radio-button>
|
||||
<el-radio-button :label="1"><i class="el-icon-minus"></i> 扣分</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="分值" prop="absPoints">
|
||||
<el-input-number v-model="adjustForm.absPoints" :min="1" :max="40" size="small" />
|
||||
<span class="preview" :class="adjustForm.dir < 0 ? 'ok' : 'bad'">
|
||||
{{ adjustForm.dir < 0 ? '+' : '−' }}{{ adjustForm.absPoints }} 分
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因" prop="reason" required>
|
||||
<el-input v-model="adjustForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit
|
||||
placeholder="必填:会写入流水留痕,员工可以在「我的绩效」看到" />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作人">
|
||||
<span class="op-name">{{ currentUserName }}(自动)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<el-button @click="adjustOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="adjustSubmitting" @click="submitAdjust">
|
||||
{{ adjustForm.dir < 0 ? '保存(加分)' : '保存(扣分)' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 高总主观分弹窗 -->
|
||||
<el-dialog title="高总主观打分" :visible.sync="scoreOpen" width="520px" append-to-body :close-on-click-modal="false">
|
||||
<div v-if="scoreForm.userId" class="form-body">
|
||||
<div class="form-user">
|
||||
<div class="u-name">{{ scoreForm.userName }}</div>
|
||||
<div class="u-period">{{ query.period }} 月度</div>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-tile">
|
||||
<div class="i-lbl">基础</div>
|
||||
<div class="i-val">{{ scoreForm.baseScore }}</div>
|
||||
</div>
|
||||
<div class="info-tile">
|
||||
<div class="i-lbl">扣分</div>
|
||||
<div class="i-val bad">-{{ scoreForm.deduction }}</div>
|
||||
</div>
|
||||
<div class="info-tile">
|
||||
<div class="i-lbl">加分</div>
|
||||
<div class="i-val ok">+{{ scoreForm.reward || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slider-label">主观分(-20 ~ +20,默认 0 = 未评价/不调节)</div>
|
||||
<el-slider v-model="scoreForm.score" :min="-20" :max="20" show-input
|
||||
:marks="{'-20':'-20','-10':'-10','0':'0','10':'+10','20':'+20'}" />
|
||||
|
||||
<div class="preview-total">
|
||||
预估总分 =
|
||||
<span class="calc">{{ scoreForm.baseScore }} - {{ scoreForm.deduction }} + {{ scoreForm.reward || 0 }} + {{ scoreForm.score }}</span>
|
||||
=
|
||||
<b :class="totalClass(previewTotal)">{{ previewTotal }}</b>
|
||||
({{ gradeOf(previewTotal) }})
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<el-button @click="scoreOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="scoreSubmitting" @click="submitScore">保存打分</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 流水弹窗 -->
|
||||
<el-dialog title="扣分 / 加分 流水" :visible.sync="detailOpen" width="700px" append-to-body>
|
||||
<div v-if="detail.user" class="detail-head">
|
||||
<span class="u-name">{{ detail.user }}</span>
|
||||
<span class="u-period">{{ query.period }}</span>
|
||||
</div>
|
||||
<el-empty v-if="!detail.list.length" :image-size="60" description="无流水" />
|
||||
<el-table v-else :data="detail.list" size="small" border>
|
||||
<el-table-column label="时间" prop="createTime" width="160" />
|
||||
<el-table-column label="来源" width="120" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag size="mini" :type="srcTag(row.source)">{{ srcLabel(row.source) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务" width="80" align="center">
|
||||
<template slot-scope="{row}">{{ bizLabel(row.sourceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分值" width="70" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<b :style="{color: row.points > 0 ? '#f56c6c' : '#67c23a'}">
|
||||
{{ row.points > 0 ? '-' : '+' }}{{ Math.abs(row.points) }}
|
||||
</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" prop="reason" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { rankPerformance, userPerformance, setSubjective, manualAdjust } from '@/api/oa/performance'
|
||||
|
||||
// 可以打分的人员白名单:高总 + 陆永强
|
||||
const SCORER_USER_IDS = ['1859252208375152641', '1858417253738815490']
|
||||
|
||||
export default {
|
||||
name: 'OaPerformanceRank',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
query: { period: '', nameLike: '' },
|
||||
filter: 'all',
|
||||
list: [],
|
||||
detailOpen: false,
|
||||
detail: { user: '', list: [] },
|
||||
scoreOpen: false,
|
||||
scoreSubmitting: false,
|
||||
scoreForm: { userId: null, userName: '', baseScore: 80, deduction: 0, reward: 0, score: 0 },
|
||||
adjustOpen: false,
|
||||
adjustSubmitting: false,
|
||||
adjustForm: { userId: null, userName: '', dir: -1, absPoints: 5, reason: '' },
|
||||
adjustRules: {
|
||||
reason: [{ required: true, message: '请填写原因(必填)', trigger: 'blur' },
|
||||
{ min: 3, message: '原因至少 3 个字', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canScore() {
|
||||
const uid = this.$store.getters.userId || (this.$store.state.user && this.$store.state.user.userId)
|
||||
const uidStr = String(uid || '')
|
||||
const roles = (this.$store.state.user && this.$store.state.user.roles) || []
|
||||
return SCORER_USER_IDS.includes(uidStr)
|
||||
|| roles.includes('admin')
|
||||
|| (this.$store.state.user && this.$store.state.user.name === 'admin')
|
||||
},
|
||||
currentUserName() {
|
||||
return (this.$store.state.user && (this.$store.state.user.nickName || this.$store.state.user.name)) || '当前操作人'
|
||||
},
|
||||
filteredList() {
|
||||
if (this.filter === 'all') return this.list
|
||||
return this.list.filter(r => r.grade && r.grade.charAt(0) === this.filter)
|
||||
},
|
||||
podium() {
|
||||
return this.filteredList.slice(0, 3)
|
||||
},
|
||||
restList() {
|
||||
return this.filteredList.slice(3)
|
||||
},
|
||||
previewTotal() {
|
||||
const t = (this.scoreForm.baseScore || 0) - (this.scoreForm.deduction || 0)
|
||||
+ (this.scoreForm.reward || 0) + (this.scoreForm.score || 0)
|
||||
return Math.max(0, Math.min(100, t))
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const d = new Date()
|
||||
this.query.period = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
rankPerformance(this.query).then(res => {
|
||||
this.list = res.data || []
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
countByLetter(letter) {
|
||||
return this.list.filter(r => r.grade && r.grade.charAt(0) === letter).length
|
||||
},
|
||||
indexFn(i) { return i + 4 },
|
||||
cardClass(g) {
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'card-a'
|
||||
if (g.startsWith('B')) return 'card-b'
|
||||
if (g.startsWith('C')) return 'card-c'
|
||||
return 'card-d'
|
||||
},
|
||||
gradeClass(g) {
|
||||
if (!g) return ''
|
||||
if (g.startsWith('A')) return 'g-a'
|
||||
if (g.startsWith('B')) return 'g-b'
|
||||
if (g.startsWith('C')) return 'g-c'
|
||||
return 'g-d'
|
||||
},
|
||||
gradeDot(g) { return this.gradeClass(g) },
|
||||
rowClass({ row }) {
|
||||
if (!row.grade) return ''
|
||||
if (row.grade.startsWith('D')) return 'row-d'
|
||||
return ''
|
||||
},
|
||||
totalClass(n) {
|
||||
n = Number(n) || 0
|
||||
if (n >= 90) return 'ok'
|
||||
if (n >= 75) return 'warn'
|
||||
return 'bad'
|
||||
},
|
||||
gradeOf(n) {
|
||||
if (n >= 95) return 'A+'; if (n >= 90) return 'A'; if (n >= 85) return 'A-'
|
||||
if (n >= 80) return 'B+'; if (n >= 75) return 'B'; if (n >= 70) return 'B-'
|
||||
if (n >= 65) return 'C+'; if (n >= 60) return 'C'; if (n >= 55) return 'C-'
|
||||
return 'D'
|
||||
},
|
||||
openDetail(row) {
|
||||
userPerformance(row.user_id, this.query.period).then(res => {
|
||||
const d = res.data || {}
|
||||
this.detail = { user: row.user_nick, list: d.deductions || [] }
|
||||
this.detailOpen = true
|
||||
})
|
||||
},
|
||||
openScore(row) {
|
||||
this.scoreForm = {
|
||||
userId: row.user_id, userName: row.user_nick,
|
||||
baseScore: row.base_score || 80,
|
||||
deduction: row.deduction || 0,
|
||||
reward: row.reward || 0,
|
||||
score: row.bonus == null ? 0 : row.bonus
|
||||
}
|
||||
this.scoreOpen = true
|
||||
},
|
||||
submitScore() {
|
||||
this.scoreSubmitting = true
|
||||
setSubjective(this.scoreForm.userId, this.scoreForm.userName, this.query.period, this.scoreForm.score)
|
||||
.then(() => {
|
||||
this.$modal.msgSuccess('已保存')
|
||||
this.scoreOpen = false
|
||||
this.load()
|
||||
}).finally(() => { this.scoreSubmitting = false })
|
||||
},
|
||||
openAdjust(row) {
|
||||
this.adjustForm = {
|
||||
userId: row.user_id, userName: row.user_nick,
|
||||
dir: -1, absPoints: 5, reason: ''
|
||||
}
|
||||
this.adjustOpen = true
|
||||
},
|
||||
submitAdjust() {
|
||||
this.$refs.adjustFormRef.validate(valid => {
|
||||
if (!valid) {
|
||||
this.$modal.msgWarning('请填写原因(必填)')
|
||||
return
|
||||
}
|
||||
this.adjustSubmitting = true
|
||||
const points = this.adjustForm.dir * this.adjustForm.absPoints
|
||||
const fullReason = '[' + this.currentUserName + '] ' + this.adjustForm.reason.trim()
|
||||
manualAdjust(this.adjustForm.userId, this.adjustForm.userName, points, fullReason)
|
||||
.then(() => {
|
||||
this.$modal.msgSuccess(points > 0 ? '已扣分' : '已加分')
|
||||
this.adjustOpen = false
|
||||
this.load()
|
||||
}).finally(() => { this.adjustSubmitting = false })
|
||||
})
|
||||
},
|
||||
srcLabel(s) { return ({ postpone:'顺延', overdue30:'超期30天', overdue_daily:'日逾期', noreport:'忘报工', manual:'手动', manual_subjective:'高总打分', reward_task:'完成任务', reward_step:'完成步骤', reward_report:'报工', reward_trip:'出差', reward_attend:'全勤' })[s] || s },
|
||||
srcTag(s) {
|
||||
if (s && s.startsWith('reward_')) return 'success'
|
||||
if (s === 'manual') return 'info'
|
||||
return 'danger'
|
||||
},
|
||||
bizLabel(t) { return ({ task:'任务', step:'步骤', requirement:'采购需求' })[t] || (t || '-') }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rank {
|
||||
padding: 16px 20px; background: #f5f7fa; min-height: calc(100vh - 50px);
|
||||
}
|
||||
.muted { color: #909399; font-size: 12px; }
|
||||
.ok { color: #67c23a; }
|
||||
.warn { color: #e6a23c; }
|
||||
.bad { color: #f56c6c; }
|
||||
.of { color: #909399; font-size: 11px; margin-left: 2px; }
|
||||
|
||||
/* ====== Toolbar ====== */
|
||||
.toolbar {
|
||||
background: #fff; padding: 12px 16px; border-radius: 10px; margin-bottom: 14px;
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.left { display: flex; gap: 10px; align-items: center; }
|
||||
.filter-tabs { display: flex; gap: 4px; background: #f4f4f5; padding: 4px; border-radius: 18px; }
|
||||
.tab {
|
||||
padding: 4px 14px; border-radius: 14px; font-size: 12px; cursor: pointer;
|
||||
color: #606266; transition: all .15s; user-select: none;
|
||||
}
|
||||
.tab.active { background: #fff; color: #303133; box-shadow: 0 1px 3px rgba(0,0,0,.1); font-weight: 600; }
|
||||
.tab.ok.active { color: #67c23a; }
|
||||
.tab.b.active { color: #409EFF; }
|
||||
.tab.c.active { color: #e6a23c; }
|
||||
.tab.d.active { color: #f56c6c; }
|
||||
|
||||
/* ====== Podium 前三名 ====== */
|
||||
.podium {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-bottom: 14px;
|
||||
}
|
||||
.podium-slot { position: relative; }
|
||||
.podium-slot.p-1 { order: 2; } /* 第一名居中 */
|
||||
.podium-slot.p-2 { order: 1; }
|
||||
.podium-slot.p-3 { order: 3; }
|
||||
.podium-card {
|
||||
background: #fff; border-radius: 14px; padding: 22px 18px 16px; text-align: center;
|
||||
position: relative; box-shadow: 0 2px 10px rgba(0,0,0,.04);
|
||||
transition: all .2s;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.podium-slot.p-1 .podium-card { margin-top: 0; padding: 30px 18px 18px; }
|
||||
.podium-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,.1); transform: translateY(-3px); }
|
||||
.podium-card.card-a { background: linear-gradient(180deg, #f0f9eb 0%, #fff 50%); border-top: 4px solid #67c23a; }
|
||||
.podium-card.card-b { background: linear-gradient(180deg, #ecf5ff 0%, #fff 50%); border-top: 4px solid #409EFF; }
|
||||
.podium-card.card-c { background: linear-gradient(180deg, #fdf6ec 0%, #fff 50%); border-top: 4px solid #e6a23c; }
|
||||
.podium-card.card-d { background: linear-gradient(180deg, #fef0f0 0%, #fff 50%); border-top: 4px solid #f56c6c; }
|
||||
|
||||
.medal {
|
||||
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 2; display: flex; align-items: center; gap: 4px;
|
||||
padding: 6px 14px; border-radius: 20px; color: #fff;
|
||||
font-size: 12px; font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||
}
|
||||
.p-1 .medal { background: linear-gradient(135deg, #ffd700, #ff9500); top: -16px; }
|
||||
.p-2 .medal { background: linear-gradient(135deg, #d0d4d8, #909399); }
|
||||
.p-3 .medal { background: linear-gradient(135deg, #cd7f32, #a0522d); }
|
||||
.medal i { font-size: 14px; }
|
||||
|
||||
.p-grade {
|
||||
font-size: 60px; font-weight: 800; line-height: 1; margin: 6px 0 10px;
|
||||
color: #67c23a;
|
||||
}
|
||||
.podium-slot.p-1 .p-grade { font-size: 72px; }
|
||||
.p-grade.g-a { color: #67c23a; }
|
||||
.p-grade.g-b { color: #409EFF; }
|
||||
.p-grade.g-c { color: #e6a23c; }
|
||||
.p-grade.g-d { color: #f56c6c; }
|
||||
|
||||
.p-name { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 6px; }
|
||||
.p-score { display: flex; justify-content: center; align-items: baseline; gap: 3px; margin-bottom: 10px; }
|
||||
.p-score .num { font-size: 26px; font-weight: 700; }
|
||||
.p-score .num.ok { color: #67c23a; }
|
||||
.p-score .num.warn { color: #e6a23c; }
|
||||
.p-score .num.bad { color: #f56c6c; }
|
||||
.p-score .div { font-size: 12px; color: #909399; }
|
||||
.p-mini {
|
||||
display: flex; justify-content: center; gap: 8px; font-size: 11px;
|
||||
color: #606266; padding: 8px 0; border-top: 1px dashed #ebeef5;
|
||||
border-bottom: 1px dashed #ebeef5; margin-bottom: 10px;
|
||||
}
|
||||
.p-actions { display: flex; gap: 6px; justify-content: center; margin-bottom: 8px; }
|
||||
|
||||
/* ====== Rest Table ====== */
|
||||
.rest { background: #fff; border-radius: 10px; padding: 10px 14px; }
|
||||
.user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: #c0c4cc; }
|
||||
.dot.g-a { background: #67c23a; }
|
||||
.dot.g-b { background: #409EFF; }
|
||||
.dot.g-c { background: #e6a23c; }
|
||||
.dot.g-d { background: #f56c6c; }
|
||||
|
||||
.grade-tag {
|
||||
display: inline-block; padding: 2px 12px; border-radius: 12px;
|
||||
color: #fff; font-weight: 600; font-size: 12px;
|
||||
}
|
||||
.grade-tag.g-a { background: #67c23a; }
|
||||
.grade-tag.g-b { background: #409EFF; }
|
||||
.grade-tag.g-c { background: #e6a23c; }
|
||||
.grade-tag.g-d { background: #f56c6c; }
|
||||
|
||||
::v-deep .row-d { background: #fff5f5 !important; }
|
||||
|
||||
/* ====== Dialogs ====== */
|
||||
.form-body { padding: 0 8px; }
|
||||
.form-user {
|
||||
display: flex; align-items: baseline; gap: 12px; margin-bottom: 18px;
|
||||
padding-bottom: 14px; border-bottom: 1px dashed #ebeef5;
|
||||
}
|
||||
.form-user .u-name { font-size: 18px; font-weight: 600; color: #303133; }
|
||||
.form-user .u-period { color: #909399; font-size: 13px; }
|
||||
.preview { margin-left: 14px; font-size: 15px; font-weight: 600; }
|
||||
.op-name { color: #606266; }
|
||||
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 18px; }
|
||||
.info-tile { background: #fafafa; border-radius: 6px; padding: 10px; text-align: center; }
|
||||
.i-lbl { font-size: 11px; color: #909399; margin-bottom: 3px; }
|
||||
.i-val { font-size: 20px; font-weight: 700; color: #303133; }
|
||||
.i-val.ok { color: #67c23a; }
|
||||
.i-val.bad { color: #f56c6c; }
|
||||
|
||||
.slider-label { color: #303133; font-weight: 500; margin-bottom: 14px; font-size: 13px; }
|
||||
.preview-total {
|
||||
margin-top: 24px; padding: 12px 16px; background: #fafafa;
|
||||
border-radius: 8px; font-size: 13px; color: #606266; line-height: 1.7;
|
||||
}
|
||||
.preview-total .calc { color: #909399; font-family: Menlo, Consolas, monospace; }
|
||||
.preview-total b { font-size: 22px; margin: 0 4px; }
|
||||
.preview-total b.ok { color: #67c23a; }
|
||||
.preview-total b.warn { color: #e6a23c; }
|
||||
.preview-total b.bad { color: #f56c6c; }
|
||||
|
||||
.detail-head { display: flex; gap: 14px; align-items: baseline; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px dashed #ebeef5; }
|
||||
.detail-head .u-name { font-size: 16px; font-weight: 600; }
|
||||
.detail-head .u-period { color: #909399; }
|
||||
</style>
|
||||
271
ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue
Normal file
271
ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="cockpit">
|
||||
<!-- ============ 顶部全局 KPI ============ -->
|
||||
<div class="hero">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-tile">
|
||||
<div class="hero-num">{{ summary.running || 0 }}</div>
|
||||
<div class="hero-lbl">进行中项目</div>
|
||||
</div>
|
||||
<div class="hero-tile">
|
||||
<div class="hero-num muted">{{ summary.finished || 0 }}</div>
|
||||
<div class="hero-lbl">已完结项目</div>
|
||||
</div>
|
||||
<div class="hero-tile" :class="(summary.overdue_running||0)>0 ? 'danger':''">
|
||||
<div class="hero-num">{{ summary.overdue_running || 0 }}</div>
|
||||
<div class="hero-lbl">在跑已超期</div>
|
||||
</div>
|
||||
<div class="hero-tile" :class="(summary.total_pending_approvals||0)>0 ? 'warning':''">
|
||||
<div class="hero-num">{{ summary.total_pending_approvals || 0 }}</div>
|
||||
<div class="hero-lbl">全局待审批</div>
|
||||
</div>
|
||||
<div class="hero-tile" :class="(summary.total_overdue_tasks||0)>0 ? 'danger':''">
|
||||
<div class="hero-num">{{ summary.total_overdue_tasks || 0 }}</div>
|
||||
<div class="hero-lbl">超期任务总数</div>
|
||||
</div>
|
||||
<div class="hero-tile big">
|
||||
<div class="hero-num">¥{{ fmtMoney(summary.running_funds) }}</div>
|
||||
<div class="hero-lbl">在跑项目总金额</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 筛选条 ============ -->
|
||||
<div class="toolbar">
|
||||
<div class="left-tools">
|
||||
<el-select v-model="query.status" placeholder="状态" clearable size="small" style="width:120px;" @change="load">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="进行中" value="0" />
|
||||
<el-option label="已完结" value="1" />
|
||||
<el-option label="已暂停" value="2" />
|
||||
</el-select>
|
||||
<el-input v-model="query.nameLike" placeholder="项目名/编号" clearable size="small" style="width:240px;"
|
||||
@keyup.enter.native="load" @clear="load">
|
||||
<i slot="prefix" class="el-input__icon el-icon-search"></i>
|
||||
</el-input>
|
||||
<el-button size="small" type="primary" icon="el-icon-search" @click="load">查询</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="load">刷新</el-button>
|
||||
<div class="filter-pills">
|
||||
<span class="pill" :class="{active: filter==='all'}" @click="filter='all'">全部 {{ projects.length }}</span>
|
||||
<span class="pill red" :class="{active: filter==='red'}" @click="filter='red'">红 {{ levelCount('red') }}</span>
|
||||
<span class="pill yellow" :class="{active: filter==='yellow'}" @click="filter='yellow'">黄 {{ levelCount('yellow') }}</span>
|
||||
<span class="pill green" :class="{active: filter==='green'}" @click="filter='green'">绿 {{ levelCount('green') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 项目卡片网格 ============ -->
|
||||
<div v-loading="loading" class="grid-wrap">
|
||||
<div class="grid">
|
||||
<div v-for="row in filteredProjects" :key="row.project_id"
|
||||
class="proj-card" :class="'lv-' + (row.health && row.health.level || 'green')"
|
||||
@click="openPanorama(row)">
|
||||
<div class="card-head">
|
||||
<span class="dot" :class="row.health && row.health.level"></span>
|
||||
<span v-if="row.project_code" class="code">{{ row.project_code }}</span>
|
||||
<span class="status">{{ projectStatusText(row.project_status) }}</span>
|
||||
</div>
|
||||
<div class="card-title">{{ row.project_name }}</div>
|
||||
<div class="card-meta">
|
||||
<span><i class="el-icon-user"></i> {{ row.functionary_nick || '—' }}</span>
|
||||
<span><i class="el-icon-date"></i> {{ fmtDate(row.begin_time) }} ~ {{ fmtDate(row.finish_time) }}</span>
|
||||
<span v-if="row.postpone_count > 0" class="warn"><i class="el-icon-warning-outline"></i> 延期{{ row.postpone_count }}</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<div class="stat">
|
||||
<div class="s-lbl">合同</div>
|
||||
<div class="s-val">¥{{ fmtMoney(row.funds) }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="s-lbl">已收</div>
|
||||
<div class="s-val ok">¥{{ fmtMoney(row.payment_done) }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="s-lbl">成本</div>
|
||||
<div class="s-val">¥{{ fmtMoney(row.cost) }}</div>
|
||||
</div>
|
||||
<div class="stat" :class="(row.pending_approvals||0)>0 ? 'attn':''">
|
||||
<div class="s-lbl">待审批</div>
|
||||
<div class="s-val">{{ row.pending_approvals || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat" :class="(row.overdue_tasks||0)>0 ? 'bad':''">
|
||||
<div class="s-lbl">超期</div>
|
||||
<div class="s-val">{{ row.overdue_tasks || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.health && row.health.reasons && row.health.reasons.length" class="reason-row">
|
||||
<i class="el-icon-warning"></i>
|
||||
{{ row.health.reasons.join(' · ') }}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span>全景视图</span>
|
||||
<i class="el-icon-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!loading && !filteredProjects.length" description="没有匹配的项目" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getProjectDashboardOverview } from '@/api/oa/projectOverview'
|
||||
|
||||
export default {
|
||||
name: 'OaProjectDashboardOverview',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
query: { status: '0', nameLike: '', limit: 200 },
|
||||
filter: 'all',
|
||||
summary: {},
|
||||
projects: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredProjects() {
|
||||
if (this.filter === 'all') return this.projects
|
||||
return this.projects.filter(p => p.health && p.health.level === this.filter)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
getProjectDashboardOverview(this.query).then(res => {
|
||||
const d = res.data || {}
|
||||
this.summary = d.summary || {}
|
||||
this.projects = d.projects || []
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
openPanorama(row) {
|
||||
this.$router.push({ path: '/project/panorama', query: { projectId: row.project_id } })
|
||||
},
|
||||
levelCount(lv) {
|
||||
return this.projects.filter(p => p.health && p.health.level === lv).length
|
||||
},
|
||||
fmtDate(d) { if (!d) return '-'; const s = String(d); return s.length >= 10 ? s.substring(0, 10) : s },
|
||||
fmtMoney(v) {
|
||||
if (v == null) return '0'
|
||||
const n = parseFloat(v)
|
||||
if (isNaN(n)) return v
|
||||
if (n >= 100000000) return (n/100000000).toFixed(2) + ' 亿'
|
||||
if (n >= 10000) return (n/10000).toFixed(2) + ' 万'
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
},
|
||||
projectStatusText(s) { return ({ '0':'进行中','1':'已完结','2':'已暂停' })[String(s)] || s }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cockpit {
|
||||
padding: 16px 20px;
|
||||
background: #f5f7fa;
|
||||
min-height: calc(100vh - 50px);
|
||||
}
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero { margin-bottom: 14px; }
|
||||
.hero-grid {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px;
|
||||
}
|
||||
.hero-tile {
|
||||
background: #fff; border-radius: 8px; padding: 18px 20px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
border-left: 4px solid #409EFF;
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
}
|
||||
.hero-tile:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.05); }
|
||||
.hero-tile.danger { border-left-color: #f56c6c; }
|
||||
.hero-tile.warning { border-left-color: #e6a23c; }
|
||||
.hero-tile.big .hero-num { font-size: 24px; }
|
||||
.hero-num {
|
||||
font-size: 32px; font-weight: 700; color: #303133; line-height: 1.1;
|
||||
}
|
||||
.hero-tile.danger .hero-num { color: #f56c6c; }
|
||||
.hero-tile.warning .hero-num { color: #e6a23c; }
|
||||
.hero-num.muted { color: #909399; }
|
||||
.hero-lbl { font-size: 13px; color: #909399; }
|
||||
|
||||
/* ===== TOOLBAR ===== */
|
||||
.toolbar {
|
||||
background: #fff; padding: 12px 16px; border-radius: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.left-tools { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.filter-pills { margin-left: 12px; display: flex; gap: 6px; }
|
||||
.pill {
|
||||
padding: 4px 12px; border-radius: 14px; font-size: 12px; cursor: pointer;
|
||||
background: #f4f4f5; color: #606266; user-select: none; transition: all .2s;
|
||||
}
|
||||
.pill:hover { background: #e9eaeb; }
|
||||
.pill.active { background: #409EFF; color: #fff; }
|
||||
.pill.red { background: #fef0f0; color: #f56c6c; }
|
||||
.pill.red.active { background: #f56c6c; color: #fff; }
|
||||
.pill.yellow { background: #fdf6ec; color: #e6a23c; }
|
||||
.pill.yellow.active { background: #e6a23c; color: #fff; }
|
||||
.pill.green { background: #f0f9eb; color: #67c23a; }
|
||||
.pill.green.active { background: #67c23a; color: #fff; }
|
||||
|
||||
/* ===== 项目卡片网格 ===== */
|
||||
.grid-wrap { background: transparent; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 14px; }
|
||||
.proj-card {
|
||||
background: #fff; border-radius: 10px; padding: 16px 18px; cursor: pointer;
|
||||
border-top: 4px solid #67c23a; transition: all .2s;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.03);
|
||||
}
|
||||
.proj-card:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,.08); }
|
||||
.proj-card.lv-red { border-top-color: #f56c6c; }
|
||||
.proj-card.lv-yellow { border-top-color: #e6a23c; }
|
||||
.proj-card.lv-green { border-top-color: #67c23a; }
|
||||
|
||||
.card-head { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #909399; margin-bottom: 6px; }
|
||||
.card-head .dot { width: 10px; height: 10px; border-radius: 50%; background: #c0c4cc; }
|
||||
.card-head .dot.red { background: #f56c6c; }
|
||||
.card-head .dot.yellow { background: #e6a23c; }
|
||||
.card-head .dot.green { background: #67c23a; }
|
||||
.card-head .code { font-family: Menlo, Consolas, monospace; background: #f4f4f5; padding: 1px 6px; border-radius: 3px; color: #606266; }
|
||||
.card-head .status { margin-left: auto; }
|
||||
|
||||
.card-title {
|
||||
font-size: 16px; font-weight: 600; color: #303133;
|
||||
line-height: 1.4; margin-bottom: 10px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; word-break: break-all;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 10px 14px; font-size: 12px; color: #606266; margin-bottom: 12px;
|
||||
}
|
||||
.card-meta i { margin-right: 3px; color: #909399; }
|
||||
.card-meta .warn { color: #e6a23c; font-weight: 500; }
|
||||
.card-meta .warn i { color: #e6a23c; }
|
||||
|
||||
.card-stats {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; padding: 10px 0;
|
||||
border-top: 1px dashed #ebeef5; border-bottom: 1px dashed #ebeef5;
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.s-lbl { font-size: 11px; color: #909399; margin-bottom: 2px; }
|
||||
.s-val { font-size: 13px; font-weight: 600; color: #303133; }
|
||||
.s-val.ok { color: #67c23a; }
|
||||
.stat.bad .s-val { color: #f56c6c; }
|
||||
.stat.attn .s-val { color: #e6a23c; }
|
||||
|
||||
.reason-row {
|
||||
font-size: 12px; color: #f56c6c; padding: 8px 0 0;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.proj-card.lv-yellow .reason-row { color: #e6a23c; }
|
||||
.proj-card.lv-green .reason-row { display: none; }
|
||||
|
||||
.card-foot {
|
||||
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
|
||||
color: #409EFF; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
624
ruoyi-ui/src/views/oa/project/panorama/index.vue
Normal file
624
ruoyi-ui/src/views/oa/project/panorama/index.vue
Normal file
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<div class="overview">
|
||||
<!-- 顶部操作条 -->
|
||||
<div class="topbar" v-if="projectId">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回项目列表</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="load">刷新</el-button>
|
||||
<span v-if="lastLoadAt" class="muted">最后加载 {{ lastLoadAt }}</span>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!projectId" description="请从项目列表点击「全景」进入">
|
||||
<el-button type="primary" @click="goBack">前往项目列表</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div v-else v-loading="loading">
|
||||
<!-- ============ 健康度卡 ============ -->
|
||||
<el-card v-if="data.health && data.health.level !== 'green'"
|
||||
class="health" :class="'health--' + data.health.level" shadow="never">
|
||||
<div class="health-main">
|
||||
<div class="health-icon">
|
||||
<i v-if="data.health.level==='red'" class="el-icon-warning"></i>
|
||||
<i v-else class="el-icon-warning-outline"></i>
|
||||
</div>
|
||||
<div class="health-text">
|
||||
<div v-if="data.health.redSignals && data.health.redSignals.length" class="signal-row">
|
||||
<el-tag v-for="(s,i) in data.health.redSignals" :key="'r-'+i" type="danger" size="small" style="margin: 2px 6px 2px 0;">{{ s }}</el-tag>
|
||||
</div>
|
||||
<div v-if="data.health.yellowSignals && data.health.yellowSignals.length" class="signal-row">
|
||||
<el-tag v-for="(s,i) in data.health.yellowSignals" :key="'y-'+i" type="warning" size="small" style="margin: 2px 6px 2px 0;">{{ s }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 项目头 + 大 KPI ============ -->
|
||||
<el-card class="header" shadow="never" v-if="data.header">
|
||||
<div class="header-top">
|
||||
<div class="header-name">
|
||||
<h1>{{ data.header.project_name }}</h1>
|
||||
<div class="meta">
|
||||
<span v-if="data.header.project_code">编号 {{ data.header.project_code }}</span>
|
||||
<span v-if="data.header.functionary_nick">负责人 {{ data.header.functionary_nick }}</span>
|
||||
<span v-if="data.customer && data.customer.name">客户 {{ data.customer.name }}</span>
|
||||
<span>工期 {{ fmtDate(data.header.begin_time) }} → {{ fmtDate(data.header.finish_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="text" icon="el-icon-edit" @click="goEditProject">查看 / 编辑项目</el-button>
|
||||
</div>
|
||||
|
||||
<div class="kpi-row">
|
||||
<kpi-tile label="项目金额" :value="'¥'+fmtMoney(data.header.funds)" />
|
||||
<kpi-tile label="已收款" :value="'¥'+fmtMoney(data.finance.paymentDone)" tone="ok" />
|
||||
<kpi-tile label="已记成本" :value="'¥'+fmtMoney(data.finance.cost)" tone="warn" />
|
||||
<kpi-tile label="净现金" :value="'¥'+fmtMoney(data.finance.netCash)" :tone="netCashTone" />
|
||||
<kpi-tile label="进度" :value="progressText" :sub="progressSub" :tone="scheduleTone" />
|
||||
<kpi-tile label="待审批" :value="String(pendingApprovalCount)" :tone="pendingApprovalCount>0?'warn':'mute'" :clickable="pendingApprovalCount>0" @click="goApproval" />
|
||||
<kpi-tile label="超期任务" :value="String(data.tasks.overdueCount||0)" :tone="(data.tasks.overdueCount||0)>0?'bad':'mute'" :clickable="(data.tasks.overdueCount||0)>0" @click="goTask" />
|
||||
<kpi-tile label="延期次数" :value="String(data.header.postpone_count||0)" :tone="(data.header.postpone_count||0)>=2?'bad':((data.header.postpone_count||0)>=1?'warn':'mute')" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 主分卡(每张都有跳转)============ -->
|
||||
<el-row :gutter="12">
|
||||
<!-- A 合同 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>合同</b>
|
||||
<el-tag v-if="data.contracts.length" size="mini" type="info" style="margin-left: 6px;">
|
||||
{{ data.contracts.length }} 份 / ¥{{ fmtMoney(data.finance.contractAmount) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
<el-button type="text" @click="goContract">查看全部 →</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!data.contracts.length" :image-size="60" description="无合同" />
|
||||
<el-table v-else :data="data.contracts" size="mini" border>
|
||||
<el-table-column label="合同名称" prop="contract_name" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template slot-scope="{row}">¥{{ fmtMoney(row.contract_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签订" width="100">
|
||||
<template slot-scope="{row}">{{ fmtDate(row.sign_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审批" width="70" align="center">
|
||||
<template slot-scope="{row}"><approval-tag :v="row.approval_status" /></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- B 进度 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>进度计划</b>
|
||||
<span v-if="data.schedule && data.schedule.steps" class="muted">
|
||||
· 共 {{ data.schedule.totalSteps }} 步 / 已完成 {{ data.schedule.doneSteps }}
|
||||
</span>
|
||||
<el-tag v-if="data.schedule && data.schedule.overdue" size="mini" type="danger" style="margin-left:6px;">已超期</el-tag>
|
||||
<el-tag v-if="data.schedule && data.schedule.delayCount > 0" size="mini" type="warning" style="margin-left:4px;">延期 {{ data.schedule.delayCount }} 次</el-tag>
|
||||
</span>
|
||||
<el-button type="text" @click="goSchedule">查看完整 →</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!data.schedule || !data.schedule.steps || !data.schedule.steps.length" :image-size="60" description="未创建计划" />
|
||||
<el-steps v-else direction="vertical" :active="Math.max(0, data.schedule.doneSteps)" finish-status="success">
|
||||
<el-step v-for="s in data.schedule.steps.slice(0, 6)" :key="s.track_id"
|
||||
:title="s.step_name"
|
||||
:description="(s.plan_end ? '计划 ' + fmtDate(s.plan_end) : '') + (stepStatusText(s.status) ? ' · ' + stepStatusText(s.status) : '')" />
|
||||
</el-steps>
|
||||
<div v-if="data.schedule && data.schedule.steps && data.schedule.steps.length > 6" class="muted" style="text-align:center; padding-top:8px;">
|
||||
还有 {{ data.schedule.steps.length - 6 }} 步 · <el-link type="primary" @click="goSchedule">查看完整</el-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- C 采购需求 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>采购需求</b><span class="muted"> · 最近 {{ (data.requirements.recent || []).length }} 条</span></span>
|
||||
<el-button type="text" @click="goRequirement">查看全部 →</el-button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill label="未采购" :n="data.requirements.byStatus[0]" />
|
||||
<stat-pill label="采购中" :n="data.requirements.byStatus[1]" type="warning" />
|
||||
<stat-pill label="完成" :n="data.requirements.byStatus[2]" type="success" />
|
||||
<stat-pill label="取消" :n="data.requirements.byStatus[3]" type="info" />
|
||||
</div>
|
||||
<el-table v-if="data.requirements.recent && data.requirements.recent.length"
|
||||
:data="data.requirements.recent.slice(0, 5)" size="mini" border style="margin-top:8px;">
|
||||
<el-table-column label="需求" prop="title" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template slot-scope="{row}">{{ reqStatusText(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审批" width="70" align="center">
|
||||
<template slot-scope="{row}"><approval-tag :v="row.approval_status" /></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- D 到货 + 库房 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>到货 & 库房</b></span>
|
||||
<el-button type="text" @click="goArrival">查看到货明细 →</el-button>
|
||||
</div>
|
||||
<div class="sub-title">到货明细</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill label="待发货" :n="data.arrivals.byStatus[0]" />
|
||||
<stat-pill label="在途" :n="data.arrivals.byStatus[1]" type="warning" />
|
||||
<stat-pill label="已到货" :n="data.arrivals.byStatus[2]" type="success" />
|
||||
</div>
|
||||
<div class="sub-title" style="margin-top:12px;">库房申请</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill v-for="(v, k) in data.warehouse.byStatus" :key="'wh-'+k"
|
||||
:label="warehouseStatusText(k)" :n="v" />
|
||||
<span v-if="!data.warehouse.byStatus || !Object.keys(data.warehouse.byStatus).length" class="muted">暂无</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- E 财务 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>财务</b></span>
|
||||
<el-button type="text" @click="goFinance">查看流水 →</el-button>
|
||||
</div>
|
||||
<table class="fin-table">
|
||||
<tr><td>合同总金额</td><td class="num">¥{{ fmtMoney(data.finance.contractAmount) }}</td></tr>
|
||||
<tr><td>计划收款总额</td><td class="num">¥{{ fmtMoney(data.finance.paymentTotal) }}</td></tr>
|
||||
<tr><td>已收款</td><td class="num ok">¥{{ fmtMoney(data.finance.paymentDone) }}</td></tr>
|
||||
<tr><td>待收款</td><td class="num warn">¥{{ fmtMoney(data.finance.paymentRemain) }}</td></tr>
|
||||
<tr><td>已记成本</td><td class="num">¥{{ fmtMoney(data.finance.cost) }}</td></tr>
|
||||
<tr class="strong"><td>净现金(收款 − 成本)</td><td class="num" :class="netCashTone">¥{{ fmtMoney(data.finance.netCash) }}</td></tr>
|
||||
</table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- F 任务 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>任务</b>
|
||||
<el-tag v-if="data.tasks.overdueCount > 0" size="mini" type="danger" style="margin-left:6px;">超期 {{ data.tasks.overdueCount }}</el-tag>
|
||||
</span>
|
||||
<el-button type="text" @click="goTask">发放任务 →</el-button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<stat-pill label="执行中" :n="data.tasks.byState[0]" type="warning" />
|
||||
<stat-pill label="待验收" :n="data.tasks.byState[1]" />
|
||||
<stat-pill label="完成" :n="data.tasks.byState[2]" type="success" />
|
||||
<stat-pill label="延期申请" :n="data.tasks.byState[15]" type="danger" />
|
||||
</div>
|
||||
<el-table v-if="data.tasks.recent && data.tasks.recent.length"
|
||||
:data="data.tasks.recent.slice(0, 6)" size="mini" border style="margin-top:8px;">
|
||||
<el-table-column label="任务" prop="task_title" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="执行人" prop="worker_nick" width="90" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template slot-scope="{row}">
|
||||
<el-tag size="mini" :type="taskStateTag(row.state)">{{ taskStateText(row.state) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- G 团队 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>团队</b><span class="muted"> · {{ data.team.memberCount }} 人参与</span></span>
|
||||
</div>
|
||||
<p><b>项目负责人</b>:{{ data.team.functionaryNick || '未指定' }}</p>
|
||||
<div>
|
||||
<el-tag v-for="m in data.team.members" :key="m.user_id"
|
||||
size="small" style="margin: 2px 6px 2px 0;">{{ m.nick_name }}</el-tag>
|
||||
<span v-if="!data.team.members || !data.team.members.length" class="muted">暂无成员</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- H 报告 + 会议 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>报告 & 会议</b></span>
|
||||
<el-button type="text" @click="goReport">日志 →</el-button>
|
||||
</div>
|
||||
<div class="sub-title">最近工作日志</div>
|
||||
<ul class="list-tight" v-if="data.reports && data.reports.length">
|
||||
<li v-for="r in data.reports" :key="'rp-'+r.id">
|
||||
<span class="muted">{{ fmtDateTime(r.create_time) }}</span>
|
||||
<span> · {{ r.user_name || r.user_id }}</span>
|
||||
<span> · {{ shortText(r.content, 60) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">暂无日志</p>
|
||||
|
||||
<div class="sub-title" style="margin-top:12px;">最近会议纪要</div>
|
||||
<ul class="list-tight" v-if="data.meetings && data.meetings.length">
|
||||
<li v-for="m in data.meetings" :key="'mt-'+m.id">
|
||||
<span class="muted">{{ fmtDate(m.meeting_date) }}</span>
|
||||
<span> · {{ m.subject }}</span>
|
||||
<span v-if="m.location" class="muted"> @{{ m.location }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">暂无会议纪要</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- ============ 制造主线(fad_rm)============ -->
|
||||
<el-card v-if="data.manufacturing" class="card mfg" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>制造主线(轧机厂)</b>
|
||||
<el-tag v-if="data.manufacturing.checklistTotal" size="mini" type="info" style="margin-left:6px;">
|
||||
验收清单 {{ data.manufacturing.checklistDone }}/{{ data.manufacturing.checklistTotal }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<div class="sub-title">安装进度</div>
|
||||
<el-empty v-if="!data.manufacturing.install.length" :image-size="50" description="无" />
|
||||
<el-table v-else :data="data.manufacturing.install" size="mini" border>
|
||||
<el-table-column label="工项" prop="item_name" show-overflow-tooltip />
|
||||
<el-table-column label="计划完成" width="100">
|
||||
<template slot-scope="{row}">{{ fmtDate(row.plan_end) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="70" align="center">
|
||||
<template slot-scope="{row}"><installStatusTag :v="row.status" /></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="sub-title">调试记录(近 10 条)</div>
|
||||
<el-empty v-if="!data.manufacturing.commissioning.length" :image-size="50" description="无" />
|
||||
<ul class="list-tight" v-else>
|
||||
<li v-for="r in data.manufacturing.commissioning" :key="'cm-'+r.record_id">
|
||||
<span class="muted">{{ fmtDate(r.record_date) }}</span>
|
||||
· {{ r.param_name }} = <b>{{ r.param_value }}</b>
|
||||
<el-tag size="mini" :type="r.result==='Y'||r.result==='1' ? 'success':'danger'" style="margin-left:4px;">
|
||||
{{ (r.result==='Y'||r.result==='1') ? '合格' : '不合格' }}
|
||||
</el-tag>
|
||||
</li>
|
||||
</ul>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="sub-title">验收清单</div>
|
||||
<el-empty v-if="!data.manufacturing.checklist.length" :image-size="50" description="无" />
|
||||
<ul class="checklist" v-else>
|
||||
<li v-for="c in data.manufacturing.checklist" :key="'ck-'+c.check_id"
|
||||
:class="(c.is_checked==='Y'||c.is_checked==='1') ? 'done' : ''">
|
||||
<i :class="(c.is_checked==='Y'||c.is_checked==='1') ? 'el-icon-success ok' : 'el-icon-circle-check'"></i>
|
||||
{{ c.item_text }}
|
||||
</li>
|
||||
</ul>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 审批时间线 ============ -->
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>审批活动</b></span>
|
||||
<el-button type="text" @click="goApproval">审批中心 →</el-button>
|
||||
</div>
|
||||
<el-empty v-if="!data.approvals || !data.approvals.length" :image-size="60" description="暂无审批活动" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="a in data.approvals" :key="a.id"
|
||||
:timestamp="fmtDateTime(a.apply_time)"
|
||||
:type="approvalTimelineType(a.status)">
|
||||
<b>{{ businessName(a.business_type) }} · {{ a.business_title || ('#' + a.business_id) }}</b>
|
||||
<div>
|
||||
申请人:{{ a.apply_user_name || '-' }}
|
||||
<span style="margin-left:8px;">{{ a.sign_type === 1 ? '或签' : '会签' }}</span>
|
||||
<approval-tag :v="a.status" style="margin-left:8px;" />
|
||||
<span v-if="a.finish_time" class="muted" style="margin-left:8px;">终结于 {{ fmtDateTime(a.finish_time) }}</span>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
|
||||
<!-- ============ 操作日志 ============ -->
|
||||
<el-card class="card" shadow="never">
|
||||
<div slot="header" class="card-head">
|
||||
<span><b>项目操作日志</b></span>
|
||||
</div>
|
||||
<el-empty v-if="!data.operationLog || !data.operationLog.length" :image-size="60" description="暂无日志" />
|
||||
<el-table v-else :data="data.operationLog" size="mini" border>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template slot-scope="{row}">{{ fmtDateTime(row.operate_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对象" prop="target_name" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" prop="operation_desc" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作人" prop="operator" width="100" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getProjectOverview } from '@/api/oa/projectOverview'
|
||||
|
||||
// ============ 内联小组件 ============
|
||||
const ApprovalTag = {
|
||||
props: ['v'],
|
||||
render(h) {
|
||||
if (this.v == null) return h('span', { class: 'muted' }, '-')
|
||||
const map = { 0: ['待审','warning'], 1: ['通过','success'], 2: ['驳回','danger'], 3: ['撤回','info'] }
|
||||
const m = map[this.v] || ['-', 'info']
|
||||
return h('el-tag', { props: { size: 'mini', type: m[1] } }, m[0])
|
||||
}
|
||||
}
|
||||
const StatPill = {
|
||||
props: ['label', 'n', 'type'],
|
||||
render(h) {
|
||||
const count = this.n == null ? 0 : this.n
|
||||
return h('div', { class: 'stat-pill stat-pill--' + (this.type || 'default') }, [
|
||||
h('div', { class: 'pill-n' }, String(count)),
|
||||
h('div', { class: 'pill-label' }, this.label)
|
||||
])
|
||||
}
|
||||
}
|
||||
const KpiTile = {
|
||||
props: ['label', 'value', 'sub', 'tone', 'clickable'],
|
||||
render(h) {
|
||||
return h('div', {
|
||||
class: ['kpi-tile', 'kpi-tile--' + (this.tone || 'default'), this.clickable ? 'kpi-tile--click' : ''],
|
||||
on: { click: () => this.clickable && this.$emit('click') }
|
||||
}, [
|
||||
h('div', { class: 'kpi-label' }, this.label),
|
||||
h('div', { class: 'kpi-val' }, this.value),
|
||||
this.sub ? h('div', { class: 'kpi-sub' }, this.sub) : null
|
||||
])
|
||||
}
|
||||
}
|
||||
const InstallStatusTag = {
|
||||
props: ['v'],
|
||||
render(h) {
|
||||
const map = { 'planned': ['未开始','info'], 'doing': ['进行中','warning'], 'done': ['完成','success'], 'delayed': ['延期','danger'] }
|
||||
const m = map[this.v] || [this.v || '-', 'info']
|
||||
return h('el-tag', { props: { size: 'mini', type: m[1] } }, m[0])
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'OaProjectPanorama',
|
||||
components: { ApprovalTag, StatPill, KpiTile, InstallStatusTag },
|
||||
data() {
|
||||
return {
|
||||
projectId: null,
|
||||
loading: false,
|
||||
data: this.emptyData(),
|
||||
lastLoadAt: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.$route.query.projectId) {
|
||||
this.projectId = String(this.$route.query.projectId)
|
||||
this.load()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.query.projectId'(v) {
|
||||
if (v) { this.projectId = String(v); this.load() }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
netCashTone() {
|
||||
const v = this.data.finance && this.data.finance.netCash
|
||||
const n = parseFloat(v)
|
||||
if (isNaN(n)) return 'mute'
|
||||
return n >= 0 ? 'ok' : 'bad'
|
||||
},
|
||||
progressText() {
|
||||
const s = this.data.schedule
|
||||
if (!s || !s.totalSteps) return '—'
|
||||
const pct = Math.round((s.doneSteps / s.totalSteps) * 100)
|
||||
return pct + '%'
|
||||
},
|
||||
progressSub() {
|
||||
const s = this.data.schedule
|
||||
if (!s || !s.totalSteps) return null
|
||||
return s.doneSteps + ' / ' + s.totalSteps + ' 步'
|
||||
},
|
||||
scheduleTone() {
|
||||
const s = this.data.schedule
|
||||
if (!s) return 'mute'
|
||||
if (s.overdue) return 'bad'
|
||||
if (s.delayCount > 0) return 'warn'
|
||||
return 'ok'
|
||||
},
|
||||
pendingApprovalCount() {
|
||||
return (this.data.approvals || []).filter(a => a.status === 0).length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emptyData() {
|
||||
return {
|
||||
header: null, finance: {}, requirements: { byStatus: {}, recent: [] },
|
||||
arrivals: { byStatus: {} }, warehouse: { byStatus: {}, recent: [] },
|
||||
tasks: { byState: {}, overdueCount: 0 }, team: { members: [] },
|
||||
schedule: {}, contracts: [], reports: [], meetings: [], approvals: [],
|
||||
operationLog: [], health: null, manufacturing: null
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
this.$router.push({ path: '/project/project' })
|
||||
},
|
||||
load() {
|
||||
if (!this.projectId) return
|
||||
this.loading = true
|
||||
getProjectOverview(this.projectId).then(res => {
|
||||
const d = res.data || {}
|
||||
// 兜底
|
||||
;['finance','requirements','arrivals','warehouse','tasks','team','schedule'].forEach(k => {
|
||||
if (!d[k]) d[k] = {}
|
||||
})
|
||||
;['contracts','reports','meetings','approvals','operationLog'].forEach(k => {
|
||||
if (!d[k]) d[k] = []
|
||||
})
|
||||
if (!d.requirements.byStatus) d.requirements.byStatus = {}
|
||||
if (!d.requirements.recent) d.requirements.recent = []
|
||||
if (!d.arrivals.byStatus) d.arrivals.byStatus = {}
|
||||
if (!d.warehouse.byStatus) d.warehouse.byStatus = {}
|
||||
if (!d.warehouse.recent) d.warehouse.recent = []
|
||||
if (!d.tasks.byState) d.tasks.byState = {}
|
||||
if (!d.team.members) d.team.members = []
|
||||
this.data = d
|
||||
this.lastLoadAt = this.fmtDateTime(new Date())
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
// ===== 跳转(实际菜单路径) =====
|
||||
goEditProject() {
|
||||
this.$router.push({ path: '/project/project', query: { projectId: this.projectId } })
|
||||
},
|
||||
goContract() {
|
||||
this.$router.push({ path: '/project/proContract', query: { projectId: this.projectId } })
|
||||
},
|
||||
goSchedule() {
|
||||
this.$router.push({ path: '/step/step', query: { projectId: this.projectId } })
|
||||
},
|
||||
goRequirement() {
|
||||
this.$router.push({ path: '/hint/requirement', query: { projectId: this.projectId } })
|
||||
},
|
||||
goArrival() {
|
||||
this.$router.push({ path: '/hint/requirement', query: { projectId: this.projectId } })
|
||||
},
|
||||
goFinance() {
|
||||
this.$router.push({ path: '/finance/finance', query: { projectId: this.projectId } })
|
||||
},
|
||||
goTask() {
|
||||
this.$router.push({ path: '/task/task/allocation', query: { projectId: this.projectId } })
|
||||
},
|
||||
goApproval() {
|
||||
this.$router.push({ path: '/approval/pending' })
|
||||
},
|
||||
goReport() {
|
||||
this.$router.push({ path: '/hint/projectReport', query: { projectId: this.projectId } })
|
||||
},
|
||||
// ===== 文案 =====
|
||||
fmtDate(d) { if (!d) return '-'; const s = String(d); return s.length >= 10 ? s.substring(0, 10) : s },
|
||||
fmtDateTime(d) {
|
||||
if (!d) return '-'
|
||||
if (d instanceof Date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
const s = String(d)
|
||||
return s.length >= 19 ? s.substring(0, 19).replace('T', ' ') : s
|
||||
},
|
||||
fmtMoney(v) {
|
||||
if (v == null) return '0.00'
|
||||
const n = parseFloat(v)
|
||||
if (isNaN(n)) return v
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
},
|
||||
shortText(s, n) {
|
||||
if (!s) return ''
|
||||
// 富文本去 HTML 标签 + 反转义实体 + 压空白
|
||||
let t = String(s).replace(/<[^>]+>/g, ' ')
|
||||
t = t.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
t = t.replace(/\s+/g, ' ').trim()
|
||||
return t.length > n ? t.substring(0, n) + '…' : t
|
||||
},
|
||||
stepStatusText(s) { return ({ 0: '未开始', 1: '进行中', 2: '已完成' })[s] || '' },
|
||||
taskStateText(s) { return ({ 0: '执行中', 1: '待验收', 2: '完成', 15: '延期申请' })[s] || s },
|
||||
taskStateTag(s) { return ({ 0: 'warning', 1: '', 2: 'success', 15: 'danger' })[s] || 'info' },
|
||||
reqStatusText(s) { return ({ 0: '未采购', 1: '采购中', 2: '完成', 3: '取消' })[s] || s },
|
||||
warehouseStatusText(k) {
|
||||
return ({ '0':'待审/未派','1':'处理中','2':'已完成','3':'取消' })[String(k)] || '状态 ' + k
|
||||
},
|
||||
businessName(t) { return ({ purchase_req: '采购需求', contract: '合同' })[t] || t },
|
||||
approvalTimelineType(s) { return ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' })[s] || '' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overview { padding: 16px 20px; background: #f5f7fa; min-height: calc(100vh - 50px); }
|
||||
.overview .picker { margin-bottom: 12px; }
|
||||
.overview .muted { color: #909399; font-size: 12px; }
|
||||
.overview .ok { color: #67c23a; }
|
||||
.overview .warn { color: #e6a23c; }
|
||||
.overview .bad { color: #f56c6c; }
|
||||
|
||||
/* ===== 健康度大卡 ===== */
|
||||
.health { margin-bottom: 12px; border-left: 8px solid transparent; }
|
||||
.health--red { background: linear-gradient(90deg,#fef0f0 0%,#fff 60%); border-left-color:#f56c6c; }
|
||||
.health--yellow { background: linear-gradient(90deg,#fdf6ec 0%,#fff 60%); border-left-color:#e6a23c; }
|
||||
.health--green { background: linear-gradient(90deg,#f0f9eb 0%,#fff 60%); border-left-color:#67c23a; }
|
||||
.health-main { display: flex; align-items: center; gap: 18px; }
|
||||
.health-icon { font-size: 50px; line-height: 1; }
|
||||
.health--red .health-icon i { color: #f56c6c; }
|
||||
.health--yellow .health-icon i { color: #e6a23c; }
|
||||
.health--green .health-icon i { color: #67c23a; }
|
||||
.health-text { flex: 1; }
|
||||
.health-title { font-size: 18px; font-weight: 600; color: #303133; }
|
||||
.health-line { font-size: 15px; color: #606266; margin-top: 4px; line-height: 1.7; }
|
||||
.health-signals { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #ebeef5; }
|
||||
.signal-row { line-height: 1.9; font-size: 13px; }
|
||||
.signal-row b { color: #606266; margin-right: 6px; }
|
||||
|
||||
/* ===== 项目头 + KPI ===== */
|
||||
.header { margin-bottom: 12px; background: linear-gradient(135deg, #f0f9ff 0%, #fff 100%); }
|
||||
.header-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
|
||||
.header-name h1 { font-size: 22px; margin: 0 0 6px; color: #303133; line-height: 1.3; }
|
||||
.header-name .meta { color: #606266; font-size: 13px; }
|
||||
.header-name .meta span { margin-right: 18px; }
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(8, 1fr); gap: 10px; }
|
||||
.kpi-tile {
|
||||
background: #fff; border: 1px solid #ebeef5; border-radius: 6px;
|
||||
padding: 12px 10px; text-align: center; transition: transform .15s;
|
||||
}
|
||||
.kpi-tile--click { cursor: pointer; }
|
||||
.kpi-tile--click:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,.06); }
|
||||
.kpi-tile .kpi-label { font-size: 12px; color: #909399; }
|
||||
.kpi-tile .kpi-val { font-size: 22px; font-weight: 700; color: #303133; margin: 4px 0 2px; word-break: break-all; line-height: 1.2; }
|
||||
.kpi-tile .kpi-sub { font-size: 11px; color: #909399; }
|
||||
.kpi-tile--ok .kpi-val { color: #67c23a; }
|
||||
.kpi-tile--warn .kpi-val { color: #e6a23c; }
|
||||
.kpi-tile--bad .kpi-val { color: #f56c6c; }
|
||||
.kpi-tile--mute .kpi-val { color: #606266; }
|
||||
|
||||
/* ===== 卡片 ===== */
|
||||
.card { margin-bottom: 12px; }
|
||||
.card .card-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card .card-head .el-button--text { padding: 0; }
|
||||
.card .sub-title { color: #606266; font-size: 12px; font-weight: 500; margin-bottom: 6px; }
|
||||
.card .hint { color: #909399; font-size: 12px; margin: 8px 0 0; }
|
||||
|
||||
.stat-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.stat-pill { background: #f4f4f5; border-radius: 6px; padding: 8px 14px; min-width: 80px; text-align: center; }
|
||||
.stat-pill .pill-n { font-size: 20px; font-weight: 600; color: #303133; line-height: 1.1; }
|
||||
.stat-pill .pill-label { font-size: 12px; color: #606266; margin-top: 2px; }
|
||||
.stat-pill--success { background: #f0f9eb; }
|
||||
.stat-pill--success .pill-n { color: #67c23a; }
|
||||
.stat-pill--warning { background: #fdf6ec; }
|
||||
.stat-pill--warning .pill-n { color: #e6a23c; }
|
||||
.stat-pill--danger { background: #fef0f0; }
|
||||
.stat-pill--danger .pill-n { color: #f56c6c; }
|
||||
.stat-pill--info { background: #f4f4f5; }
|
||||
.stat-pill--info .pill-n { color: #909399; }
|
||||
|
||||
.fin-table { width: 100%; border-collapse: collapse; }
|
||||
.fin-table td { padding: 6px 4px; border-bottom: 1px dashed #ebeef5; font-size: 13px; }
|
||||
.fin-table td.num { text-align: right; font-family: Menlo,Consolas,monospace; }
|
||||
.fin-table tr.strong td { font-weight: 600; border-top: 1px solid #303133; border-bottom: none; padding-top: 10px; }
|
||||
|
||||
.list-tight { padding-left: 18px; margin: 4px 0; }
|
||||
.list-tight li { line-height: 1.7; font-size: 13px; color: #606266; }
|
||||
|
||||
/* ===== 制造主线 ===== */
|
||||
.mfg .checklist { list-style: none; padding: 0; margin: 0; }
|
||||
.mfg .checklist li { padding: 4px 0; font-size: 13px; color: #606266; line-height: 1.6; }
|
||||
.mfg .checklist li i { margin-right: 4px; color: #c0c4cc; }
|
||||
.mfg .checklist li.done { color: #67c23a; text-decoration: line-through; }
|
||||
.mfg .checklist li.done i.ok { color: #67c23a; }
|
||||
</style>
|
||||
@@ -179,6 +179,9 @@
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" style="color:#67c23a" icon="el-icon-data-board"
|
||||
@click="handlePanorama(scope.row)">全景
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)"
|
||||
v-hasPermi="['oa:project:remove']">删除
|
||||
</el-button>
|
||||
@@ -922,6 +925,9 @@ export default {
|
||||
console.log(row)
|
||||
this.$router.push('/customer/detail/' + row.customerId);
|
||||
},
|
||||
handlePanorama (row) {
|
||||
this.$router.push({ path: '/project/panorama', query: { projectId: row.projectId } })
|
||||
},
|
||||
handleDetail (row) {
|
||||
this.loading = true;
|
||||
this.detailShow = true;
|
||||
|
||||
Reference in New Issue
Block a user