qiangdanxitong/frontend/src/views/admin/ScorePoolManagement.vue
Your Name eeeba7b685 fix: 修复积分显示的浮点数精度问题
使用 Math.round(score * 100) / 100 避免 JavaScript 浮点数计算误差
例如 0.1 × 46 现在正确显示为 4.60 而不是 4.600000000000005
2026-01-15 15:47:59 +08:00

805 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<DefaultLayout>
<div class="score-pool-page">
<div class="container">
<!-- 页面标题 -->
<div class="page-header">
<div class="header-left">
<h1 class="page-title">
<Icon name="sparkles" size="xl" color="#FFD700" style="margin-right: 12px;" />
激励积分池管理
</h1>
<p class="page-desc">管理系统激励积分预算,控制奖励发放</p>
</div>
<div class="header-right">
<a-button type="primary" @click="showCreateModal">
<template #icon><Icon name="plus" size="sm" /></template>
新建积分池
</a-button>
</div>
</div>
<!-- 当前活跃积分池概览 -->
<div class="current-pool-card" v-if="currentPool">
<div class="card-header">
<h3>
<Icon name="chartBar" size="md" color="#31f08b" style="margin-right: 8px;" />
当前活跃积分池
</h3>
<a-tag color="green">进行中</a-tag>
</div>
<div class="card-title">{{ currentPool.name }}</div>
<div class="budget-overview">
<div class="budget-progress">
<div class="progress-info">
<span class="used">已使用 {{ currentPool.usedScore.toLocaleString() }}</span>
<span class="total">/ {{ currentPool.totalBudget.toLocaleString() }} 积分</span>
</div>
<a-progress
:percent="Math.round((currentPool.usedScore / currentPool.totalBudget) * 100)"
:stroke-color="getProgressColor(currentPool)"
:trail-color="'#3A3A3A'"
:show-info="false"
/>
<div class="progress-percent">
{{ Math.round((currentPool.usedScore / currentPool.totalBudget) * 100) }}%
</div>
</div>
<div class="remaining-score">
<span class="remaining-label">剩余可用</span>
<span class="remaining-value">{{ currentPool.remainingScore.toLocaleString() }}</span>
</div>
</div>
<div class="category-breakdown">
<div class="category-item">
<div class="category-header">
<Icon name="document" size="sm" color="#3B82F6" />
<span>任务奖励</span>
</div>
<a-progress
:percent="Math.round((currentPool.taskRewardUsed / currentPool.taskRewardBudget) * 100)"
size="small"
:stroke-color="'#3B82F6'"
/>
<div class="category-info">
{{ currentPool.taskRewardUsed.toLocaleString() }} / {{ currentPool.taskRewardBudget.toLocaleString() }}
</div>
</div>
<div class="category-item">
<div class="category-header">
<Icon name="star" size="sm" color="#F59E0B" />
<span>好评奖励</span>
</div>
<a-progress
:percent="Math.round((currentPool.ratingRewardUsed / currentPool.ratingRewardBudget) * 100)"
size="small"
:stroke-color="'#F59E0B'"
/>
<div class="category-info">
{{ currentPool.ratingRewardUsed.toLocaleString() }} / {{ currentPool.ratingRewardBudget.toLocaleString() }}
</div>
</div>
<div class="category-item">
<div class="category-header">
<Icon name="trophy" size="sm" color="#EF4444" />
<span>竞赛奖励</span>
</div>
<a-progress
:percent="Math.round((currentPool.contestRewardUsed / currentPool.contestRewardBudget) * 100)"
size="small"
:stroke-color="'#EF4444'"
/>
<div class="category-info">
{{ currentPool.contestRewardUsed.toLocaleString() }} / {{ currentPool.contestRewardBudget.toLocaleString() }}
</div>
</div>
<div class="category-item">
<div class="category-header">
<Icon name="calendar" size="sm" color="#31f08b" />
<span>签到奖励</span>
</div>
<a-progress
:percent="Math.round((currentPool.checkinRewardUsed / currentPool.checkinRewardBudget) * 100)"
size="small"
:stroke-color="'#31f08b'"
/>
<div class="category-info">
{{ currentPool.checkinRewardUsed.toLocaleString() }} / {{ currentPool.checkinRewardBudget.toLocaleString() }}
</div>
</div>
</div>
<div class="card-actions">
<a-button @click="viewPoolDetail(currentPool)">查看详情</a-button>
<a-button @click="viewPoolRecords(currentPool)">使用记录</a-button>
</div>
</div>
<!-- 积分池列表 -->
<div class="section">
<div class="section-header">
<h2 class="section-title">所有积分池</h2>
<div class="section-filters">
<a-select v-model:value="filterYear" placeholder="年份" style="width: 120px" allowClear @change="loadData">
<a-select-option v-for="y in yearOptions" :key="y" :value="y">{{ y }}年</a-select-option>
</a-select>
<a-select v-model:value="filterType" placeholder="类型" style="width: 120px" allowClear @change="loadData">
<a-select-option v-for="opt in POOL_TYPE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</div>
</div>
<a-table
:columns="columns"
:data-source="pools"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span class="pool-name">{{ record.name }}</span>
</template>
<template v-else-if="column.key === 'budget'">
<div class="budget-cell">
<span class="used">{{ record.usedScore.toLocaleString() }}</span>
<span class="separator">/</span>
<span class="total">{{ record.totalBudget.toLocaleString() }}</span>
</div>
</template>
<template v-else-if="column.key === 'usage'">
<a-progress
:percent="Math.round((record.usedScore / record.totalBudget) * 100)"
size="small"
:stroke-color="getProgressColor(record)"
/>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusName(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="link" size="small" @click="viewPoolDetail(record)">详情</a-button>
<a-button type="link" size="small" @click="viewPoolRecords(record)">记录</a-button>
<template v-if="record.status === 'active'">
<a-popconfirm title="确定关闭此积分池?" @confirm="handleClosePool(record.id)">
<a-button type="link" size="small" danger>关闭</a-button>
</a-popconfirm>
</template>
<template v-else-if="record.status === 'closed'">
<a-button type="link" size="small" @click="handleSettlePool(record.id)">结算</a-button>
</template>
</a-space>
</template>
</template>
</a-table>
</div>
</div>
</div>
<!-- 创建积分池弹窗 -->
<a-modal
v-model:open="createModalVisible"
title="新建积分池"
width="700px"
@ok="handleCreatePool"
:confirmLoading="creating"
>
<a-form :model="poolForm" layout="vertical">
<a-form-item label="积分池名称" required>
<a-input v-model:value="poolForm.name" placeholder="例如:2025年1月激励积分池" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="积分池类型" required>
<a-select v-model:value="poolForm.poolType" @change="handlePoolTypeChange">
<a-select-option v-for="opt in POOL_TYPE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="年份" required>
<a-select v-model:value="poolForm.year">
<a-select-option v-for="y in yearOptions" :key="y" :value="y">{{ y }}年</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8" v-if="poolForm.poolType === 'monthly'">
<a-form-item label="月份" required>
<a-select v-model:value="poolForm.month">
<a-select-option v-for="m in 12" :key="m" :value="m">{{ m }}月</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8" v-if="poolForm.poolType === 'quarterly'">
<a-form-item label="季度" required>
<a-select v-model:value="poolForm.quarter">
<a-select-option v-for="q in 4" :key="q" :value="q">第{{ q }}季度</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-divider>预算设置</a-divider>
<a-form-item label="总预算积分" required>
<a-input-number v-model:value="poolForm.totalBudget" :min="1000" :step="10000" style="width: 100%" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="任务奖励预算">
<a-input-number v-model:value="poolForm.taskRewardBudget" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="好评奖励预算">
<a-input-number v-model:value="poolForm.ratingRewardBudget" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="竞赛奖励预算">
<a-input-number v-model:value="poolForm.contestRewardBudget" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="超支处理策略">
<a-select v-model:value="poolForm.overflowStrategy">
<a-select-option v-for="opt in OVERFLOW_STRATEGY_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="预警阈值 (%)">
<a-input-number v-model:value="poolForm.alertThreshold" :min="50" :max="100" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
<!-- 积分池详情弹窗 -->
<a-modal
v-model:open="detailModalVisible"
:title="selectedPool?.name"
width="800px"
:footer="null"
>
<div v-if="selectedPool" class="pool-detail">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="积分池类型">
{{ getPoolTypeName(selectedPool.poolType) }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(selectedPool.status)">
{{ getStatusName(selectedPool.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="总预算">
{{ selectedPool.totalBudget.toLocaleString() }} 积分
</a-descriptions-item>
<a-descriptions-item label="已使用">
{{ selectedPool.usedScore.toLocaleString() }} 积分
</a-descriptions-item>
<a-descriptions-item label="剩余">
{{ selectedPool.remainingScore.toLocaleString() }} 积分
</a-descriptions-item>
<a-descriptions-item label="使用率">
{{ Math.round((selectedPool.usedScore / selectedPool.totalBudget) * 100) }}%
</a-descriptions-item>
<a-descriptions-item label="超支策略">
{{ getStrategyName(selectedPool.overflowStrategy) }}
</a-descriptions-item>
<a-descriptions-item label="预警阈值">
{{ selectedPool.alertThreshold }}%
</a-descriptions-item>
</a-descriptions>
</div>
</a-modal>
<!-- 使用记录弹窗 -->
<a-modal
v-model:open="recordsModalVisible"
:title="`${selectedPool?.name} - 使用记录`"
width="900px"
:footer="null"
>
<a-table
:columns="recordColumns"
:data-source="poolRecords"
:loading="loadingRecords"
:pagination="recordPagination"
@change="handleRecordTableChange"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'scoreChange'">
<span :class="record.scoreChange > 0 ? 'text-green' : 'text-red'">
{{ record.scoreChange > 0 ? '+' : '' }}{{ formatScore(record.scoreChange) }}
</span>
</template>
<template v-else-if="column.key === 'rewardType'">
{{ getRewardTypeName(record.rewardType) }}
</template>
</template>
</a-table>
</a-modal>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import Icon from '@/components/Icon.vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import {
getScorePoolList,
getCurrentActivePool,
createScorePool,
closeScorePool,
settleScorePool,
getPoolRecords,
POOL_TYPE_OPTIONS,
OVERFLOW_STRATEGY_OPTIONS,
type ScorePool,
type ScorePoolRecord
} from '@/api/scorePool'
const loading = ref(false)
const pools = ref<ScorePool[]>([])
const currentPool = ref<ScorePool | null>(null)
const filterYear = ref<number | undefined>(new Date().getFullYear())
const filterType = ref<string | undefined>(undefined)
const pagination = ref({
current: 1,
pageSize: 10,
total: 0
})
const yearOptions = computed(() => {
const currentYear = new Date().getFullYear()
return [currentYear - 1, currentYear, currentYear + 1]
})
const columns = [
{ title: '名称', key: 'name', dataIndex: 'name' },
{ title: '预算使用', key: 'budget', width: 180 },
{ title: '使用率', key: 'usage', width: 150 },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'actions', width: 200 }
]
const recordColumns = [
{ title: '时间', dataIndex: 'createTime', key: 'createTime', width: 160 },
{ title: '用户', dataIndex: 'userName', key: 'userName', width: 100 },
{ title: '类型', key: 'rewardType', width: 100 },
{ title: '积分', key: 'scoreChange', width: 80 },
{ title: '池剩余', dataIndex: 'afterRemaining', key: 'afterRemaining', width: 100 },
{ title: '备注', dataIndex: 'remark', key: 'remark' }
]
const createModalVisible = ref(false)
const creating = ref(false)
const poolForm = ref({
name: '',
poolType: 'monthly',
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
quarter: 1,
totalBudget: 100000,
taskRewardBudget: 40000,
ratingRewardBudget: 20000,
contestRewardBudget: 15000,
overflowStrategy: 'alert',
alertThreshold: 80
})
const detailModalVisible = ref(false)
const selectedPool = ref<ScorePool | null>(null)
const recordsModalVisible = ref(false)
const loadingRecords = ref(false)
const poolRecords = ref<ScorePoolRecord[]>([])
const recordPagination = ref({
current: 1,
pageSize: 20,
total: 0
})
// 格式化积分(保留两位小数,修复浮点数精度问题)
const formatScore = (score: number | null | undefined): string => {
if (score === null || score === undefined) return '0.00'
// 先乘以100,四舍五入取整,再除以100,避免浮点数精度问题
return (Math.round(Number(score) * 100) / 100).toFixed(2)
}
const loadData = async () => {
loading.value = true
try {
// 加载当前活跃池
currentPool.value = await getCurrentActivePool()
// 加载列表
const result = await getScorePoolList({
page: pagination.value.current,
pageSize: pagination.value.pageSize,
poolType: filterType.value,
year: filterYear.value
})
pools.value = result.records || []
pagination.value.total = result.total || 0
} catch (error) {
console.error('加载积分池失败:', error)
message.error('加载数据失败')
} finally {
loading.value = false
}
}
const showCreateModal = () => {
const now = new Date()
poolForm.value = {
name: `${now.getFullYear()}年${now.getMonth() + 1}月激励积分池`,
poolType: 'monthly',
year: now.getFullYear(),
month: now.getMonth() + 1,
quarter: 1,
totalBudget: 100000,
taskRewardBudget: 40000,
ratingRewardBudget: 20000,
contestRewardBudget: 15000,
overflowStrategy: 'alert',
alertThreshold: 80
}
createModalVisible.value = true
}
const handlePoolTypeChange = () => {
const type = poolForm.value.poolType
const year = poolForm.value.year
if (type === 'monthly') {
poolForm.value.name = `${year}年${poolForm.value.month}月激励积分池`
} else if (type === 'quarterly') {
poolForm.value.name = `${year}年第${poolForm.value.quarter}季度激励积分池`
} else if (type === 'yearly') {
poolForm.value.name = `${year}年度激励积分池`
}
}
const handleCreatePool = async () => {
if (!poolForm.value.name || !poolForm.value.totalBudget) {
message.warning('请填写必填项')
return
}
creating.value = true
try {
await createScorePool(poolForm.value)
message.success('创建成功')
createModalVisible.value = false
loadData()
} catch (error: any) {
message.error(error.message || '创建失败')
} finally {
creating.value = false
}
}
const viewPoolDetail = (pool: ScorePool) => {
selectedPool.value = pool
detailModalVisible.value = true
}
const viewPoolRecords = async (pool: ScorePool) => {
selectedPool.value = pool
recordsModalVisible.value = true
loadingRecords.value = true
try {
const result = await getPoolRecords(pool.id, {
page: recordPagination.value.current,
pageSize: recordPagination.value.pageSize
})
poolRecords.value = result.records || []
recordPagination.value.total = result.total || 0
} catch (error) {
message.error('加载记录失败')
} finally {
loadingRecords.value = false
}
}
const handleClosePool = async (id: number) => {
try {
await closeScorePool(id)
message.success('积分池已关闭')
loadData()
} catch (error: any) {
message.error(error.message || '操作失败')
}
}
const handleSettlePool = async (id: number) => {
try {
await settleScorePool(id)
message.success('积分池已结算')
loadData()
} catch (error: any) {
message.error(error.message || '操作失败')
}
}
const handleTableChange = (pag: any) => {
pagination.value.current = pag.current
pagination.value.pageSize = pag.pageSize
loadData()
}
const handleRecordTableChange = async (pag: any) => {
recordPagination.value.current = pag.current
if (selectedPool.value) {
await viewPoolRecords(selectedPool.value)
}
}
const getProgressColor = (pool: ScorePool) => {
const percent = (pool.usedScore / pool.totalBudget) * 100
if (percent >= 90) return '#EF4444'
if (percent >= 70) return '#F59E0B'
return '#31f08b'
}
const getStatusColor = (status: string) => {
const map: Record<string, string> = {
active: 'green',
closed: 'orange',
settled: 'default'
}
return map[status] || 'default'
}
const getStatusName = (status: string) => {
const map: Record<string, string> = {
active: '进行中',
closed: '已关闭',
settled: '已结算'
}
return map[status] || status
}
const getPoolTypeName = (type: string) => {
const opt = POOL_TYPE_OPTIONS.find(o => o.value === type)
return opt?.label || type
}
const getStrategyName = (strategy: string) => {
const opt = OVERFLOW_STRATEGY_OPTIONS.find(o => o.value === strategy)
return opt?.label || strategy
}
const getRewardTypeName = (type: string) => {
const map: Record<string, string> = {
task: '任务奖励',
rating: '好评奖励',
contest: '竞赛奖励',
checkin: '签到奖励',
goal: '目标奖励',
other: '其他'
}
return map[type] || type
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.score-pool-page {
min-height: 100vh;
padding: 24px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #ECECEC;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.page-desc {
color: #AFAFAF;
font-size: 14px;
}
.current-pool-card {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid #3A3A3A;
border-radius: 16px;
padding: 24px;
margin-bottom: 32px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h3 {
color: #AFAFAF;
font-size: 14px;
font-weight: normal;
display: flex;
align-items: center;
}
}
.card-title {
font-size: 24px;
font-weight: 600;
color: #ECECEC;
margin-bottom: 24px;
}
.budget-overview {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.budget-progress {
flex: 1;
margin-right: 40px;
}
.progress-info {
display: flex;
gap: 8px;
margin-bottom: 8px;
.used {
color: #31f08b;
font-weight: 600;
}
.total {
color: #AFAFAF;
}
}
.progress-percent {
text-align: right;
color: #AFAFAF;
font-size: 13px;
margin-top: 4px;
}
.remaining-score {
text-align: right;
.remaining-label {
display: block;
color: #6B6B6B;
font-size: 12px;
margin-bottom: 4px;
}
.remaining-value {
font-size: 32px;
font-weight: 700;
color: #FFD700;
}
}
.category-breakdown {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.category-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 12px;
}
.category-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: #ECECEC;
font-size: 13px;
}
.category-info {
font-size: 12px;
color: #6B6B6B;
margin-top: 4px;
}
.card-actions {
display: flex;
gap: 12px;
}
.section {
margin-bottom: 32px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #ECECEC;
}
.section-filters {
display: flex;
gap: 12px;
}
.pool-name {
font-weight: 500;
color: #ECECEC;
}
.budget-cell {
.used {
color: #31f08b;
font-weight: 500;
}
.separator {
color: #6B6B6B;
margin: 0 4px;
}
.total {
color: #AFAFAF;
}
}
.text-green {
color: #31f08b;
}
.text-red {
color: #EF4444;
}
</style>