feat: 加权效率算法 + 代人提交/删除权限 + 角色权限同步优化
- 效率算法重构:加权效率=(制作-修改)/工时,新增通过率、熟练度等级 - 团队效率表格展示制作/修改/通过率/日均净产出/熟练度等级 - 新增 submission:proxy 和 submission:delete 权限,支持代人提交和删除记录 - 角色权限同步改为双向对齐(增+减),主管去除财务权限,组长去除创建项目 - 提交列表UI:项目列固定宽度、操作列居中对齐、编辑弹窗内置删除按钮 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
087d4e1a6b
commit
e4ff7763b5
@ -346,11 +346,13 @@ def calc_waste_for_project(project_id: int, db: Session) -> dict:
|
||||
|
||||
def calc_team_efficiency(project_id: int, db: Session) -> list:
|
||||
"""
|
||||
时均产出效率法(展示日均):
|
||||
- 效率对比用时均产出 = 累计秒数 ÷ 累计工时(跨项目公平)
|
||||
- 前端展示日均产出 = 累计秒数 ÷ 提交天数(直观)
|
||||
加权效率算法:
|
||||
- 加权效率 = (制作秒数 - 修改秒数) / 总工时 (综合速度+质量)
|
||||
- 通过率 = (制作秒数 - 修改秒数) / 制作秒数 (纯质量指标)
|
||||
- 日均净产出 = (制作秒数 - 修改秒数) / 活跃天数
|
||||
- 熟练度等级基于加权效率与团队均值的比值
|
||||
"""
|
||||
from sqlalchemy import distinct
|
||||
from sqlalchemy import distinct, case
|
||||
|
||||
per_user = db.query(
|
||||
Submission.user_id,
|
||||
@ -358,6 +360,14 @@ def calc_team_efficiency(project_id: int, db: Session) -> list:
|
||||
sa_func.sum(Submission.hours_spent).label("total_hours"),
|
||||
sa_func.count(distinct(Submission.submit_date)).label("days"),
|
||||
sa_func.count(Submission.id).label("count"),
|
||||
sa_func.sum(case(
|
||||
(Submission.work_type == WorkType.PRODUCTION, Submission.total_seconds),
|
||||
else_=0,
|
||||
)).label("production_secs"),
|
||||
sa_func.sum(case(
|
||||
(Submission.work_type == WorkType.REVISION, Submission.total_seconds),
|
||||
else_=0,
|
||||
)).label("revision_secs"),
|
||||
).filter(
|
||||
Submission.project_id == project_id,
|
||||
Submission.total_seconds > 0,
|
||||
@ -367,10 +377,21 @@ def calc_team_efficiency(project_id: int, db: Session) -> list:
|
||||
return []
|
||||
|
||||
user_data = []
|
||||
for user_id, total_secs, total_hours, days, count in per_user:
|
||||
for user_id, total_secs, total_hours, days, count, prod_secs, rev_secs in per_user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
prod_secs = prod_secs or 0
|
||||
rev_secs = rev_secs or 0
|
||||
net_secs = max(prod_secs - rev_secs, 0)
|
||||
|
||||
# 原有指标(向后兼容,结算页面依赖)
|
||||
daily_avg = total_secs / days if days > 0 else 0
|
||||
hourly_output = total_secs / total_hours if total_hours and total_hours > 0 else 0
|
||||
|
||||
# 新指标
|
||||
first_pass_rate = round(net_secs / prod_secs * 100, 1) if prod_secs > 0 else 0
|
||||
weighted_efficiency = net_secs / total_hours if total_hours and total_hours > 0 else 0
|
||||
daily_net_output = net_secs / days if days > 0 else 0
|
||||
|
||||
user_data.append({
|
||||
"user_id": user_id,
|
||||
"user_name": user.name if user else "未知",
|
||||
@ -380,17 +401,35 @@ def calc_team_efficiency(project_id: int, db: Session) -> list:
|
||||
"active_days": days,
|
||||
"daily_avg": round(daily_avg, 1),
|
||||
"hourly_output": round(hourly_output, 1),
|
||||
# 新字段
|
||||
"production_seconds": round(prod_secs, 1),
|
||||
"revision_seconds": round(rev_secs, 1),
|
||||
"first_pass_rate": first_pass_rate,
|
||||
"weighted_efficiency": round(weighted_efficiency, 1),
|
||||
"daily_net_output": round(daily_net_output, 1),
|
||||
})
|
||||
|
||||
# 效率对比用时均产出(公平)
|
||||
team_hourly_avg = sum(d["hourly_output"] for d in user_data) / len(user_data)
|
||||
# 熟练度等级:基于加权效率与团队均值的比值
|
||||
team_weighted_avg = sum(d["weighted_efficiency"] for d in user_data) / len(user_data)
|
||||
|
||||
for d in user_data:
|
||||
diff = d["hourly_output"] - team_hourly_avg
|
||||
d["team_hourly_avg"] = round(team_hourly_avg, 1)
|
||||
d["efficiency_rate"] = round(diff / team_hourly_avg * 100, 1) if team_hourly_avg > 0 else 0
|
||||
d["team_weighted_avg"] = round(team_weighted_avg, 1)
|
||||
ratio = d["weighted_efficiency"] / team_weighted_avg if team_weighted_avg > 0 else 0
|
||||
# 效率百分比(与团队均值的偏差)
|
||||
d["efficiency_rate"] = round((ratio - 1) * 100, 1) if team_weighted_avg > 0 else 0
|
||||
# 熟练度等级
|
||||
if ratio >= 1.5:
|
||||
d["proficiency_grade"] = "S+"
|
||||
elif ratio >= 1.2:
|
||||
d["proficiency_grade"] = "S"
|
||||
elif ratio >= 0.8:
|
||||
d["proficiency_grade"] = "A"
|
||||
elif ratio >= 0.5:
|
||||
d["proficiency_grade"] = "B"
|
||||
else:
|
||||
d["proficiency_grade"] = "C"
|
||||
|
||||
user_data.sort(key=lambda x: x["hourly_output"], reverse=True)
|
||||
user_data.sort(key=lambda x: x["weighted_efficiency"], reverse=True)
|
||||
return user_data
|
||||
|
||||
|
||||
|
||||
@ -186,14 +186,20 @@ def init_roles_and_admin():
|
||||
db.add(role)
|
||||
print(f"[OK] created role: {role_name}")
|
||||
elif existing.is_system:
|
||||
# 同步内置角色:补充代码中新增的权限
|
||||
# 同步内置角色:完全对齐代码定义(增+减)
|
||||
current = set(existing.permissions or [])
|
||||
defined = set(role_def["permissions"])
|
||||
missing = defined - current
|
||||
if missing:
|
||||
existing.permissions = list(current | missing)
|
||||
added = defined - current
|
||||
removed = current - defined
|
||||
if added or removed:
|
||||
existing.permissions = list(defined)
|
||||
flag_modified(existing, "permissions")
|
||||
print(f"[SYNC] added permissions to {role_name}: {missing}")
|
||||
parts = []
|
||||
if added:
|
||||
parts.append(f"added {added}")
|
||||
if removed:
|
||||
parts.append(f"removed {removed}")
|
||||
print(f"[SYNC] {role_name}: {', '.join(parts)}")
|
||||
db.commit()
|
||||
|
||||
# 迁移旧成本权限 → 细分权限
|
||||
|
||||
@ -28,6 +28,8 @@ ALL_PERMISSIONS = [
|
||||
# 内容提交
|
||||
("submission:view", "查看提交记录", "内容提交"),
|
||||
("submission:create", "新增提交", "内容提交"),
|
||||
("submission:proxy", "代人提交", "内容提交"),
|
||||
("submission:delete", "删除提交记录", "内容提交"),
|
||||
# 成本管理 —— 按类型细分
|
||||
("cost_ai:view", "查看AI工具成本", "成本管理"),
|
||||
("cost_ai:create", "录入AI工具成本", "成本管理"),
|
||||
@ -69,23 +71,21 @@ BUILTIN_ROLES = {
|
||||
"permissions": PERMISSION_KEYS[:], # 全部
|
||||
},
|
||||
"主管": {
|
||||
"description": "管理项目和成本,不可管理用户和角色",
|
||||
"description": "管理项目和提交,可查看AI工具与外包成本",
|
||||
"permissions": [
|
||||
"dashboard:view", "dashboard:view_cost", "dashboard:view_waste", "dashboard:view_profit", "dashboard:view_risk",
|
||||
"dashboard:view", "dashboard:view_waste", "dashboard:view_risk",
|
||||
"project:view", "project:create", "project:edit", "project:complete",
|
||||
"submission:view", "submission:create",
|
||||
"submission:view", "submission:create", "submission:proxy", "submission:delete",
|
||||
"cost_ai:view", "cost_ai:create", "cost_ai:delete",
|
||||
"cost_outsource:view", "cost_outsource:create", "cost_outsource:delete",
|
||||
"cost_overhead:view", "cost_overhead:create", "cost_overhead:delete",
|
||||
"cost_labor:view", "cost_labor:create",
|
||||
"user:view",
|
||||
"settlement:view", "efficiency:view",
|
||||
"efficiency:view",
|
||||
],
|
||||
},
|
||||
"组长": {
|
||||
"description": "管理本组提交和查看成本",
|
||||
"permissions": [
|
||||
"project:view", "project:create",
|
||||
"project:view",
|
||||
"submission:view", "submission:create",
|
||||
"cost_ai:view", "cost_ai:create",
|
||||
"efficiency:view",
|
||||
|
||||
@ -72,6 +72,16 @@ def create_submission(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# 代人提交:有 submission:proxy 权限时可以指定 user_id
|
||||
target_user_id = current_user.id
|
||||
if req.user_id and req.user_id != current_user.id:
|
||||
if not current_user.has_permission("submission:proxy"):
|
||||
raise HTTPException(status_code=403, detail="没有代人提交权限")
|
||||
target_user = db.query(User).filter(User.id == req.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="目标用户不存在")
|
||||
target_user_id = req.user_id
|
||||
|
||||
# 校验项目存在
|
||||
project = db.query(Project).filter(Project.id == req.project_id).first()
|
||||
if not project:
|
||||
@ -100,7 +110,7 @@ def create_submission(
|
||||
)
|
||||
|
||||
sub = Submission(
|
||||
user_id=current_user.id,
|
||||
user_id=target_user_id,
|
||||
project_id=req.project_id,
|
||||
project_phase=PhaseGroup(req.project_phase),
|
||||
work_type=WorkType(req.work_type),
|
||||
@ -222,3 +232,22 @@ def get_submission_history(
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{submission_id}")
|
||||
def delete_submission(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("submission:delete"))
|
||||
):
|
||||
"""删除提交记录(需要 submission:delete 权限)"""
|
||||
sub = db.query(Submission).filter(Submission.id == submission_id).first()
|
||||
if not sub:
|
||||
raise HTTPException(status_code=404, detail="提交记录不存在")
|
||||
# 同时删除关联的修改历史
|
||||
db.query(SubmissionHistory).filter(
|
||||
SubmissionHistory.submission_id == submission_id
|
||||
).delete()
|
||||
db.delete(sub)
|
||||
db.commit()
|
||||
return {"detail": "删除成功"}
|
||||
|
||||
@ -166,6 +166,7 @@ class SubmissionCreate(BaseModel):
|
||||
submit_date: date
|
||||
delay_reason: Optional[str] = None
|
||||
episode_number: Optional[int] = None
|
||||
user_id: Optional[int] = None # 代人提交时指定目标用户
|
||||
|
||||
|
||||
class SubmissionUpdate(BaseModel):
|
||||
|
||||
@ -77,6 +77,7 @@ export const submissionApi = {
|
||||
list: (params) => api.get('/submissions', { params }),
|
||||
create: (data) => api.post('/submissions', data),
|
||||
update: (id, data) => api.put(`/submissions/${id}`, data),
|
||||
delete: (id) => api.delete(`/submissions/${id}`),
|
||||
history: (id) => api.get(`/submissions/${id}/history`),
|
||||
}
|
||||
|
||||
|
||||
@ -261,17 +261,40 @@
|
||||
<div class="card-header"><span class="card-title">团队效率</span></div>
|
||||
<div class="card-body">
|
||||
<el-table :data="efficiency" size="small">
|
||||
<el-table-column prop="user_name" label="成员" width="100" />
|
||||
<el-table-column label="累计产出" align="right"><template #default="{row}">{{ formatSecs(row.total_seconds) }}</template></el-table-column>
|
||||
<el-table-column label="工时" align="right" width="70"><template #default="{row}">{{ row.total_hours }}h</template></el-table-column>
|
||||
<el-table-column label="日均产出" align="right"><template #default="{row}"><strong>{{ formatSecs(row.daily_avg) }}</strong></template></el-table-column>
|
||||
<el-table-column label="效率" align="right" width="100">
|
||||
<el-table-column prop="user_name" label="成员" min-width="80" />
|
||||
<el-table-column label="制作" align="right" min-width="90" prop="production_seconds">
|
||||
<template #default="{row}">{{ formatSecs(row.production_seconds) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="修改" align="right" min-width="80" prop="revision_seconds">
|
||||
<template #default="{row}">{{ formatSecs(row.revision_seconds) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通过率" align="right" min-width="75" prop="first_pass_rate">
|
||||
<template #default="{row}">
|
||||
<span :style="{color: row.first_pass_rate < 75 ? '#F56C6C' : row.first_pass_rate >= 90 ? '#67C23A' : ''}">
|
||||
{{ row.first_pass_rate }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工时" align="right" min-width="60" prop="total_hours">
|
||||
<template #default="{row}">{{ row.total_hours }}h</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日均产出" align="right" min-width="90" prop="daily_net_output">
|
||||
<template #default="{row}">{{ formatSecs(row.daily_net_output) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="效率" align="right" min-width="75" prop="efficiency_rate">
|
||||
<template #default="{row}">
|
||||
<span class="rate-badge" :class="{success: row.efficiency_rate >= 0, danger: row.efficiency_rate < 0}">
|
||||
{{ row.efficiency_rate > 0 ? '+' : '' }}{{ row.efficiency_rate }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="熟练度" align="center" min-width="70" prop="proficiency_grade">
|
||||
<template #default="{row}">
|
||||
<el-tag size="small" :type="row.proficiency_grade === 'S+' || row.proficiency_grade === 'S' ? 'success' : row.proficiency_grade === 'A' ? '' : row.proficiency_grade === 'B' ? 'warning' : 'danger'">
|
||||
{{ row.proficiency_grade }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
<el-table :data="submissions" v-loading="loading" stripe>
|
||||
<el-table-column prop="submit_date" label="日期" width="110" sortable />
|
||||
<el-table-column prop="user_name" label="提交人" width="80" />
|
||||
<el-table-column prop="project_name" label="项目" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="project_name" label="项目" width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="project_phase" label="阶段" width="70" />
|
||||
<el-table-column label="工作类型" width="80">
|
||||
<template #default="{row}">
|
||||
@ -35,7 +35,7 @@
|
||||
<template #default="{row}">{{ row.hours_spent ? row.hours_spent + 'h' : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
||||
<el-table-column v-if="authStore.hasPermission('submission:create')" label="操作" width="70" fixed="right">
|
||||
<el-table-column v-if="authStore.hasPermission('submission:create')" label="操作" width="70" fixed="right" align="center">
|
||||
<template #default="{row}">
|
||||
<el-button text type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
@ -45,6 +45,11 @@
|
||||
<!-- 新增提交对话框 -->
|
||||
<el-dialog v-model="showCreate" title="新增内容提交" width="580px" destroy-on-close>
|
||||
<el-form :model="form" label-width="110px" label-position="left">
|
||||
<el-form-item v-if="authStore.hasPermission('submission:proxy')" label="提交人">
|
||||
<el-select v-model="form.user_id" placeholder="默认为自己" clearable filterable style="width:100%">
|
||||
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属项目" required>
|
||||
<el-select v-model="form.project_id" placeholder="选择项目" style="width:100%">
|
||||
<el-option v-for="p in activeProjects" :key="p.id" :label="p.name" :value="p.id" />
|
||||
@ -232,8 +237,14 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEdit = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editing" @click="handleUpdate">保存修改</el-button>
|
||||
<div style="display:flex;justify-content:space-between;width:100%">
|
||||
<el-button v-if="authStore.hasPermission('submission:delete')" type="danger" plain @click="handleDeleteFromEdit">删除此记录</el-button>
|
||||
<span v-else></span>
|
||||
<div>
|
||||
<el-button @click="showEdit = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editing" @click="handleUpdate">保存修改</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@ -241,8 +252,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
import { submissionApi, projectApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { submissionApi, projectApi, userApi } from '../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@ -258,6 +269,7 @@ const creating = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const submissions = ref([])
|
||||
const projects = ref([])
|
||||
const users = ref([])
|
||||
const dateRange = ref(null)
|
||||
const filter = reactive({ project_id: null, start_date: null, end_date: null })
|
||||
|
||||
@ -268,7 +280,7 @@ const form = reactive({
|
||||
project_id: null, project_phase: '制作', work_type: '制作',
|
||||
content_type: '动画制作', duration_minutes: 0, duration_seconds: 0,
|
||||
hours_spent: null, submit_to: '组长', description: '', submit_date: today,
|
||||
delay_reason: '', episode_number: null,
|
||||
delay_reason: '', episode_number: null, user_id: null,
|
||||
})
|
||||
|
||||
// 选择内容类型时自动设置对应的项目阶段
|
||||
@ -336,6 +348,7 @@ async function handleCreate() {
|
||||
form.description = ''
|
||||
form.delay_reason = ''
|
||||
form.episode_number = null
|
||||
form.user_id = null
|
||||
load()
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
@ -403,9 +416,28 @@ async function handleUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFromEdit() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除此提交记录吗?此操作不可恢复。`,
|
||||
'确认删除',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
await submissionApi.delete(editForm._id)
|
||||
ElMessage.success('删除成功')
|
||||
showEdit.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') { /* axios 拦截器已处理 */ }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try { projects.value = await projectApi.list({}) } catch {}
|
||||
if (authStore.hasPermission('submission:proxy')) {
|
||||
try { users.value = await userApi.brief() } catch {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user