fix: 后台用户按俱乐部分配角色,菜单与多俱乐部权限对齐

This commit is contained in:
XingQue
2026-07-06 19:14:01 +08:00
parent bb540ced45
commit 1c57cea841
11 changed files with 164 additions and 87 deletions

View File

@@ -3,6 +3,7 @@ import Layout from '@/views/Layout.vue'
import request from '@/utils/request'
import {
ensureMenuAccess,
syncClubContextFromServer,
getMenuAccess,
getDefaultLandingPath,
getEffectiveVisiblePaths,
@@ -362,6 +363,10 @@ router.beforeEach(async (to, from, next) => {
}
try {
await syncClubContextFromServer(async () => {
const res = await request.get(JITUAN_API.meContext, { skipErrorToast: true })
return res.code === 0 ? res.data : null
})
await ensureMenuAccess(async () => {
const res = await request.get(JITUAN_API.menuAccess)
return res.code === 0 ? res.data : null

View File

@@ -3,7 +3,7 @@ const CLUB_SCOPE_KEY = 'admin_club_scope';
const CLUB_CONTEXT_KEY = 'admin_club_context';
const MENU_ACCESS_KEY = 'kefu_menu_access';
const MENU_SESSION_KEY = 'kefu_menu_session_v';
const MENU_SESSION_VERSION = '19';
const MENU_SESSION_VERSION = '21';
const DEFAULT_CLUB_ID = 'xq';
let menuAccessInflight = null;
@@ -59,6 +59,10 @@ export function mergeServerClubContext(serverCtx) {
}
}
if (prevClubId && prevClubId !== clubId) {
clearMenuAccess();
}
setAdminClubContext(merged);
return merged;
}
@@ -253,6 +257,21 @@ export function canSwitchClubByMenu() {
return !!access?.can_switch_club;
}
export async function syncClubContextFromServer(fetcher) {
const prevClubId = getAdminClubId();
try {
const data = await fetcher();
if (data) {
mergeServerClubContext(data);
if (getAdminClubId() !== prevClubId) {
clearMenuAccess();
}
}
} catch (e) {
console.error('syncClubContextFromServer failed:', e);
}
}
export async function ensureMenuAccess(fetcher, { force = false } = {}) {
if (!localStorage.getItem('token')) return getMenuAccess();
if (!force && isMenuSessionFresh() && getMenuAccess()) {

View File

@@ -0,0 +1,30 @@
/** 金额罚单状态(与 Django utils/penalty_status.py 一致) */
export const PENALTY_STATUS_MAP = {
1: { text: '待缴纳', type: 'danger' },
2: { text: '已缴纳', type: 'success' },
3: { text: '申诉中', type: 'warning' },
4: { text: '已驳回', type: 'info' },
5: { text: '平台审核中', type: 'warning' },
}
export const penaltyStatusText = (code) => PENALTY_STATUS_MAP[code]?.text || '未知'
export const penaltyStatusType = (code) => PENALTY_STATUS_MAP[code]?.type || ''
/** 列表行展示状态(优先用后端 zhuangtai_text */
export const rowPenaltyStatusText = (row) => {
if (row?.zhuangtai_text) return row.zhuangtai_text
if (row?.platform_audit_pending) return '平台审核中'
return penaltyStatusText(Number(row?.zhuangtai))
}
export const rowPenaltyStatusType = (row) => {
if (row?.platform_audit_pending) return 'warning'
return penaltyStatusType(Number(row?.zhuangtai))
}
/** 是否可平台审核(仅 zhuangtai=5 */
export const isPlatformAuditPending = (row) => {
if (!row) return false
if (row.platform_audit_pending === true) return true
return Number(row.zhuangtai) === 5
}

View File

@@ -43,7 +43,7 @@
<el-button type="primary" @click="fetchUserList">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-button type="success" @click="openAddUserDialog">
<el-button v-if="canAddUser" type="success" @click="openAddUserDialog">
<el-icon><Plus /></el-icon> 添加用户
</el-button>
</div>
@@ -399,6 +399,10 @@ const roleClubLabel = (role) => {
return `[${cname}] ${role.role_name || role.role_code || ''}`
}
const canAddUser = computed(() => {
return canManageClubAssignments.value || manageableClubIds.value.length > 0
})
const canManageRolesForClub = (clubId) => {
const cid = clubId || ''
if (canManageClubAssignments.value) return true
@@ -411,7 +415,7 @@ const visibleRolesForRow = (row) => {
return roles.filter((r) => manageableClubIds.value.includes(r.club_id || ''))
}
// 权限状态
// 权限状态:集团高管 / 俱乐部超管 / 000001 均可访问
const hasPermission = ref(true)
const canManageClubAssignments = ref(false)
const manageableClubIds = ref([])
@@ -605,6 +609,8 @@ const fetchUserList = async () => {
total.value = res.data.total || 0
canManageClubAssignments.value = !!res.data.can_manage_club_assignments
manageableClubIds.value = res.data.manageable_club_ids || []
} else if (res.code === 403) {
hasPermission.value = false
} else {
ElMessage.error(res.msg || '获取用户列表失败')
}
@@ -853,7 +859,7 @@ const confirmAddUser = async () => {
}
}
// 初始化
// 初始化:先拉用户列表(含 manageable_club_ids再拉角色列表
onMounted(async () => {
if (!username) {
ElMessage.error('未获取到用户信息')
@@ -861,7 +867,9 @@ onMounted(async () => {
}
await loadClubOptions()
await fetchUserList()
await fetchRoleList()
if (hasPermission.value) {
await fetchRoleList()
}
})
</script>

View File

@@ -371,6 +371,7 @@ const removePermission = async (perm) => {
username,
action: 'remove_permission',
role_code: currentRole.value.role_code,
target_club_id: currentRole.value.club_id || '',
perm_code: perm.perm_code
})
if (res.code === 0) {

View File

@@ -526,7 +526,7 @@ onMounted(fetchData)
}
.member-data-table :deep(.el-table td.el-table__cell) {
background: rgba(12, 18, 28, 0.55) !important;
color: #d8ebff !important;
color: #ffffff !important;
border-bottom: 1px solid rgba(64, 158, 255, 0.1) !important;
}
.member-data-table :deep(.el-table__body tr:hover > td.el-table__cell) {

View File

@@ -18,7 +18,7 @@
<div v-else>
<div class="header-bar">
<h2>排行榜奖励配置</h2>
<p class="desc">按俱乐部分奖小程序可展示集团总榜奖金仅发给本俱乐部用户结算周期昨日 / 上周 / 上月</p>
<p class="desc">按俱乐部分奖结算周期昨日 / 上周 / 上月可设置活动起止日期超出活动期不可结算与领取</p>
</div>
<el-tabs v-model="activeShenfen" @tab-change="loadSchemes">
@@ -53,6 +53,12 @@
<el-form-item label="领取有效天">
<el-input-number v-model="form.claim_days" :min="1" :max="365" />
</el-form-item>
<el-form-item label="活动开始">
<el-date-picker v-model="form.activity_start" type="date" value-format="YYYY-MM-DD" placeholder="如 2025-01-01" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="活动结束">
<el-date-picker v-model="form.activity_end" type="date" value-format="YYYY-MM-DD" placeholder="如 2026-12-31" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="规则说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
@@ -151,6 +157,8 @@ const form = reactive({
sort_field: 'chengjiao_zongliang',
min_metric: 0,
claim_days: 30,
activity_start: '',
activity_end: '',
description: '',
tiers: [{ rank_from: 1, rank_to: 1, reward_amount: 0, label: '冠军' }],
})
@@ -184,6 +192,8 @@ const selectScheme = () => {
sort_field: hit.sort_field || defaults,
min_metric: hit.min_metric || 0,
claim_days: hit.claim_days || 30,
activity_start: hit.activity_start || '',
activity_end: hit.activity_end || '',
description: hit.description || '',
tiers: hit.tiers?.length
? hit.tiers.map((t) => ({ ...t, reward_amount: Number(t.reward_amount) }))
@@ -196,6 +206,8 @@ const selectScheme = () => {
sort_field: defaults,
min_metric: 0,
claim_days: 30,
activity_start: '',
activity_end: '',
description: '',
tiers: [{ rank_from: 1, rank_to: 1, reward_amount: 0, label: '冠军' }],
})

View File

@@ -7,7 +7,7 @@
<el-descriptions-item label="身份">{{ fadan.shenfen_text }}</el-descriptions-item>
<el-descriptions-item label="金额">¥{{ fadan.fakuanjine }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(fadan.zhuangtai)">{{ statusText(fadan.zhuangtai) }}</el-tag>
<el-tag :type="statusType(fadan)">{{ statusText(fadan) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="影响抢单">{{ fadan.yingxiang_qiangdan ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="罚款理由" :span="2">{{ fadan.chufaliyou }}</el-descriptions-item>
@@ -36,8 +36,14 @@
</div>
</div>
<!-- 处理操作仅申诉中 -->
<div v-if="fadan.zhuangtai === 3" class="actions">
<!-- 平台审核商家提交的罚款状态5 -->
<div v-if="isPlatformAuditPending(fadan)" class="actions">
<el-button type="success" @click="handlePlatformAction('platform_approve')">同意审核进入待缴纳/申诉流程</el-button>
<el-button type="danger" @click="handlePlatformAction('platform_reject')">驳回申请</el-button>
</div>
<!-- 申诉处理状态3 -->
<div v-if="Number(fadan.zhuangtai) === 3" class="actions">
<el-button type="success" @click="handleAction('agree')">同意申诉驳回处罚</el-button>
<el-button type="danger" @click="handleAction('reject')">拒绝申诉</el-button>
</div>
@@ -48,7 +54,7 @@
<el-form-item label="处理理由">
<el-input v-model="reason" type="textarea" :rows="3" placeholder="请输入理由" />
</el-form-item>
<el-form-item label="上传凭证(可选)">
<el-form-item v-if="!isPlatformAction" label="上传凭证(可选)">
<el-upload
:file-list="fileList"
:auto-upload="false"
@@ -73,10 +79,12 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { uploadFiles } from '@/utils/upload'
import { JITUAN_API } from '@/utils/club-context'
import { rowPenaltyStatusText, rowPenaltyStatusType, isPlatformAuditPending } from '@/utils/penalty-status'
const props = defineProps({
visible: Boolean,
@@ -90,16 +98,20 @@ const ossUrl = window.$ossURL || ''
const fullZhengjuUrls = computed(() => (props.fadan?.zhengju_tupian || []).map(url => ossUrl + url))
const fullShensuUrls = computed(() => (props.fadan?.shensu_tupian || []).map(url => ossUrl + url))
// 处理表单
const actionDialog = ref(false)
const actionTitle = ref('')
const currentAction = ref('')
const isPlatformAction = ref(false)
const reason = ref('')
const fileList = ref([])
const selectedFiles = ref([])
const submitting = ref(false)
const statusText = rowPenaltyStatusText
const statusType = rowPenaltyStatusType
const handleAction = (action) => {
isPlatformAction.value = false
currentAction.value = action
actionTitle.value = action === 'agree' ? '同意申诉(处罚将撤销)' : '拒绝申诉'
reason.value = ''
@@ -108,6 +120,16 @@ const handleAction = (action) => {
actionDialog.value = true
}
const handlePlatformAction = (action) => {
isPlatformAction.value = true
currentAction.value = action
actionTitle.value = action === 'platform_approve' ? '同意平台审核' : '驳回罚款申请'
reason.value = ''
fileList.value = []
selectedFiles.value = []
actionDialog.value = true
}
const handleFileChange = (file) => { selectedFiles.value.push(file.raw) }
const handleFileRemove = (file) => {
const idx = selectedFiles.value.indexOf(file.raw)
@@ -117,21 +139,20 @@ const handleFileRemove = (file) => {
const submitAction = async () => {
submitting.value = true
try {
const formData = new FormData()
formData.append('username', localStorage.getItem('username') || '')
formData.append('fadan_id', props.fadan.id)
formData.append('action', currentAction.value)
formData.append('chuli_liyou', reason.value)
selectedFiles.value.forEach(f => formData.append('tupian', f))
// 直接使用 request 发送亦可,这里使用 uploadFiles 并附带字段
// 因为 uploadFiles 接受额外数据和文件,但需要注意文件字段名是 'tupian'
const res = await uploadFiles(JITUAN_API.penaltyAction, selectedFiles.value, {
username: localStorage.getItem('username') || '',
const username = localStorage.getItem('username') || ''
const payload = {
username,
fadan_id: props.fadan.id,
action: currentAction.value,
chuli_liyou: reason.value
}, 'tupian')
}
let res
if (isPlatformAction.value) {
res = await request.post(JITUAN_API.penaltyPlatformAudit, payload)
} else {
res = await uploadFiles(JITUAN_API.penaltyAction, selectedFiles.value, payload, 'tupian')
}
if (res.code === 0) {
ElMessage.success('处理成功')
@@ -148,15 +169,6 @@ const submitAction = async () => {
}
}
const statusText = (code) => ({1:'待缴纳',2:'已缴纳',3:'申诉中',4:'申诉成功'}[code]||'未知')
const statusType = (code) => {
if (code === 1) return 'danger'
if (code === 2) return 'success'
if (code === 3) return 'warning'
if (code === 4) return 'info'
return ''
}
watch(() => props.visible, (val) => { if (!val) actionDialog.value = false })
</script>
@@ -166,5 +178,5 @@ watch(() => props.visible, (val) => { if (!val) actionDialog.value = false })
.section-title { font-weight: 600; margin-bottom: 10px; }
.img-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.grid-img { width: 100px; height: 100px; border-radius: 8px; }
.actions { margin-top: 20px; display: flex; gap: 12px; }
</style>
.actions { margin-top: 20px; display: flex; flex-wrap: wrap; gap: 12px; }
</style>

View File

@@ -8,7 +8,7 @@
<el-table-column prop="chufaliyou" label="罚款理由" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.zhuangtai)">{{ statusText(row.zhuangtai) }}</el-tag>
<el-tag :type="rowPenaltyStatusType(row)">{{ rowPenaltyStatusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="160" />
@@ -35,6 +35,7 @@
<script setup>
import { computed } from 'vue'
import { rowPenaltyStatusText, rowPenaltyStatusType } from '@/utils/penalty-status'
const props = defineProps({
list: Array,
@@ -48,14 +49,8 @@ defineEmits(['page-change', 'size-change', 'detail'])
const currentPage = computed({ get: () => props.page, set: () => {} })
const currentSize = computed({ get: () => props.pageSize, set: () => {} })
const statusText = (code) => ({1:'待缴纳',2:'已缴纳',3:'申诉中',4:'申诉成功'}[code]||'未知')
const statusType = (code) => {
if (code === 1) return 'danger'
if (code === 2) return 'success'
if (code === 3) return 'warning'
if (code === 4) return 'info'
return ''
}
const statusText = rowPenaltyStatusText
const statusType = rowPenaltyStatusType
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
<template>
<div class="fadan-management">
<!-- 统计卡片 -->
<FadanStats :stats="stats" @filter="handleStatsFilter" />
<FadanStats :stats="stats" :active-status="currentStatus" @filter="handleStatsFilter" />
<!-- 操作栏 -->
<el-card class="control-card" shadow="hover">
@@ -45,7 +45,7 @@
:visible="detailVisible"
:fadan="selectedFadan"
@close="detailVisible = false"
@processed="fetchData"
@processed="onProcessed"
/>
<!-- 添加弹窗 -->
@@ -72,7 +72,7 @@ import AddFadan from './AddFadan.vue'
const username = localStorage.getItem('username') || ''
const stats = reactive({
total: 0, daijiaona: 0, shensuzhong: 0, yijiaona: 0, yibohui: 0
total: 0, daijiaona: 0, shensuzhong: 0, yijiaona: 0, yibohui: 0, pingtai_shenhe: 0
})
const list = ref([])
@@ -133,7 +133,9 @@ const search = () => { page.value = 1; fetchData(1) }
const resetSearch = () => {
searchKey.value = ''
filterShenfen.value = null
search()
currentStatus.value = null
page.value = 1
fetchData(1)
}
const handlePageChange = (p) => { page.value = p; fetchData(p) }
@@ -144,6 +146,11 @@ const openDetail = (row) => {
detailVisible.value = true
}
const onProcessed = async () => {
await fetchStats()
fetchData()
}
onMounted(async () => {
await fetchStats()
fetchData()

View File

@@ -1,42 +1,15 @@
<template>
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" :class="{ 'no-permission': !permission }" shadow="hover" @click="emitFilter(null)">
<el-col v-for="item in statItems" :key="item.code === null ? 'all' : item.code" :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card
class="stat-card"
:class="{ active: isActive(item.code), 'no-permission': item.code === null && !permission }"
shadow="hover"
@click="emitFilter(item.code)"
>
<div class="stat-item">
<div class="stat-label">总记录</div>
<div class="stat-value">{{ stats.total || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(1)">
<div class="stat-item">
<div class="stat-label">待缴纳</div>
<div class="stat-value danger">{{ stats.daijiaona || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(3)">
<div class="stat-item">
<div class="stat-label">申诉中</div>
<div class="stat-value warning">{{ stats.shensuzhong || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(2)">
<div class="stat-item">
<div class="stat-label">已缴纳</div>
<div class="stat-value success">{{ stats.yijiaona || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-card class="stat-card" shadow="hover" @click="emitFilter(4)">
<div class="stat-item">
<div class="stat-label">申诉成功</div>
<div class="stat-value info">{{ stats.yibohui || 0 }}</div>
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value" :class="item.cls">{{ item.value }}</div>
</div>
</el-card>
</el-col>
@@ -44,20 +17,35 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
stats: Object,
permission: Boolean
permission: Boolean,
activeStatus: { type: [Number, null], default: null },
})
const emit = defineEmits(['filter'])
const emitFilter = (code) => {
if (code === undefined) code = null
emit('filter', code)
}
const statItems = computed(() => [
{ code: null, label: '总记录', value: props.stats?.total || 0, cls: '' },
{ code: 1, label: '待缴纳', value: props.stats?.daijiaona || 0, cls: 'danger' },
{ code: 3, label: '申诉中', value: props.stats?.shensuzhong || 0, cls: 'warning' },
{ code: 2, label: '已缴纳', value: props.stats?.yijiaona || 0, cls: 'success' },
{ code: 5, label: '平台审核中', value: props.stats?.pingtai_shenhe || 0, cls: 'warning' },
{ code: 4, label: '申诉成功', value: props.stats?.yibohui || 0, cls: 'info' },
])
const isActive = (code) => props.activeStatus === code
</script>
<style scoped>
.stats-row { margin-bottom: 20px; }
.stat-card { border-radius: 12px; cursor: pointer; transition: transform 0.2s; }
.stat-card.active { border: 2px solid #409eff; box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
.stat-card:hover { transform: translateY(-2px); }
.stat-item { text-align: center; padding: 5px 0; }
.stat-label { font-size: 14px; color: #6b7a88; }