添加了项目前景 绩效 审批配置做了一部分有点晕 我换换脑子继续这个 还有说明菜单

This commit is contained in:
2026-06-17 17:06:01 +08:00
parent 8ad3f2d7dd
commit 88c374952a
38 changed files with 4932 additions and 0 deletions

View 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 }
})
}

View 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 })
}

View 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
})
}

View 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>

View File

@@ -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 }
}
}

View 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>

View File

@@ -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,

View 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>已支出 &gt; 已收款现金净流出</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>

View 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 &lt; 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 推到 100A+
</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>

View 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>

View 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>

View 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>

View 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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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>

View File

@@ -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;