991 lines
27 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="reward-rules-page">
<div class="container">
<!-- 页面标题 -->
<div class="page-header">
<div class="header-left">
<h1 class="page-title">
<Icon name="settings" size="xl" color="#31f08b" 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>
<!-- Tab切换 -->
<a-tabs v-model:activeKey="activeTab" class="reward-tabs">
<!-- 规则管理 -->
<a-tab-pane key="rules" tab="规则管理">
<!-- 筛选 -->
<div class="filter-bar">
<a-space>
<a-select v-model:value="filterType" placeholder="规则类型" style="width: 150px" allowClear @change="loadData">
<a-select-option v-for="opt in RULE_TYPE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
<a-select v-model:value="filterStatus" placeholder="状态" style="width: 120px" allowClear @change="loadData">
<a-select-option value="1">已启用</a-select-option>
<a-select-option value="0">已禁用</a-select-option>
</a-select>
</a-space>
</div>
<!-- 规则列表 -->
<div class="rules-list">
<div v-if="loading" class="loading-container">
<a-spin size="large" />
</div>
<div v-else-if="rules.length === 0" class="empty-state">
<Icon name="inbox" size="xl" color="#6B6B6B" />
<p>暂无奖励规则</p>
<a-button type="primary" @click="showCreateModal">创建规则</a-button>
</div>
<div v-else>
<div v-for="rule in rules" :key="rule.id" class="rule-card">
<div class="rule-header">
<div class="rule-info">
<h3 class="rule-name">{{ rule.name }}</h3>
<a-tag :color="getRuleTypeColor(rule.ruleType)">
{{ getRuleTypeName(rule.ruleType) }}
</a-tag>
</div>
<div class="rule-status">
<a-switch
:checked="rule.enabled === 1"
@change="(checked: boolean) => toggleRule(rule, checked)"
checked-children="启用"
un-checked-children="禁用"
/>
</div>
</div>
<p class="rule-desc">{{ rule.description }}</p>
<div class="rule-details">
<div class="detail-item">
<span class="detail-label">触发条件</span>
<span class="detail-value">{{ formatCondition(rule.triggerCondition) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">奖励</span>
<span class="detail-value reward">+{{ rule.rewardScore }} 积分</span>
</div>
<div class="detail-item">
<span class="detail-label">适用范围</span>
<span class="detail-value">{{ getScopeName(rule.scope) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">触发次数</span>
<span class="detail-value">{{ rule.triggerCount || 0 }} 次</span>
</div>
</div>
<div class="rule-footer">
<span class="rule-time">创建于 {{ formatDate(rule.createTime) }}</span>
<div class="rule-actions">
<a-button size="small" @click="editRule(rule)">编辑</a-button>
<a-popconfirm title="确定删除此规则吗?" @confirm="handleDelete(rule.id)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
show-size-changer
@change="handlePageChange"
/>
</div>
</a-tab-pane>
<!-- 奖励统计 -->
<a-tab-pane key="stats" tab="奖励统计">
<div class="stats-section">
<!-- 时间筛选 -->
<div class="stats-filter">
<a-radio-group v-model:value="statsPeriod" button-style="solid" @change="loadStats">
<a-radio-button value="today">今日</a-radio-button>
<a-radio-button value="week">本周</a-radio-button>
<a-radio-button value="month">本月</a-radio-button>
</a-radio-group>
</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<div class="stat-card">
<div class="stat-value">{{ stats.totalCount || 0 }}</div>
<div class="stat-label">奖励次数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.totalScore || 0 }}</div>
<div class="stat-label">发放积分</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.userCount || 0 }}</div>
<div class="stat-label">获奖人数</div>
</div>
</div>
<!-- 排行榜 -->
<div class="ranking-section">
<a-row :gutter="24">
<a-col :span="12">
<div class="ranking-card">
<h3 class="ranking-title">规则触发排行</h3>
<div class="ranking-list">
<div v-for="(item, index) in (stats.ruleRanking || [])" :key="index" class="ranking-item">
<span class="rank" :class="{ 'top3': index < 3 }">{{ index + 1 }}</span>
<span class="name">{{ item.rule_name || item.ruleName }}</span>
<span class="count">{{ item.trigger_count || item.triggerCount }} 次</span>
</div>
<div v-if="!stats.ruleRanking?.length" class="empty-ranking">暂无数据</div>
</div>
</div>
</a-col>
<a-col :span="12">
<div class="ranking-card">
<h3 class="ranking-title">用户获奖排行</h3>
<div class="ranking-list">
<div v-for="(item, index) in (stats.userRanking || [])" :key="index" class="ranking-item">
<span class="rank" :class="{ 'top3': index < 3 }">{{ index + 1 }}</span>
<span class="name">{{ item.user_name || item.userName }}</span>
<span class="score">+{{ item.total_score || item.totalScore }} 分</span>
</div>
<div v-if="!stats.userRanking?.length" class="empty-ranking">暂无数据</div>
</div>
</div>
</a-col>
</a-row>
</div>
</div>
</a-tab-pane>
<!-- 发放记录 -->
<a-tab-pane key="records" tab="发放记录">
<div class="records-section">
<a-table
:columns="recordColumns"
:data-source="records"
:loading="recordsLoading"
:pagination="{ current: recordPage, pageSize: 20, total: recordTotal, onChange: loadRecords }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rewardValue'">
<span class="reward-value">{{ parseRewardValue(record.rewardValue) }}</span>
</template>
<template v-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
</template>
</a-table>
</div>
</a-tab-pane>
<!-- 徽章管理 -->
<a-tab-pane key="badges" tab="徽章管理">
<div class="badges-section">
<div class="badges-grid">
<div v-for="badge in badges" :key="badge.id" class="badge-card">
<div class="badge-icon" :style="{ backgroundColor: badge.badgeColor }">
{{ badge.badgeName.charAt(0) }}
</div>
<div class="badge-info">
<h4>{{ badge.badgeName }}</h4>
<p>{{ badge.description }}</p>
<a-tag>{{ badge.category }}</a-tag>
</div>
</div>
<div v-if="!badges.length" class="empty-badges">
暂无徽章定义
</div>
</div>
</div>
</a-tab-pane>
<!-- 称号管理 -->
<a-tab-pane key="titles" tab="称号管理">
<div class="titles-section">
<div class="titles-grid">
<div v-for="title in titles" :key="title.id" class="title-card">
<div class="title-name" :style="{ color: title.titleColor }">
{{ title.titleName }}
</div>
<p class="title-desc">{{ title.description }}</p>
<a-tag>{{ title.category }}</a-tag>
</div>
<div v-if="!titles.length" class="empty-titles">
暂无称号定义
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
<!-- 创建/编辑规则弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="isEdit ? '编辑规则' : '新建规则'"
width="700px"
@ok="handleSubmit"
:confirmLoading="submitting"
>
<a-form :model="ruleForm" layout="vertical">
<a-form-item label="规则名称" required>
<a-input v-model:value="ruleForm.name" placeholder="例如:每日任务达人" />
</a-form-item>
<a-form-item label="规则描述">
<a-textarea v-model:value="ruleForm.description" :rows="2" placeholder="描述这条规则的作用" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="规则类型" required>
<a-select v-model:value="ruleForm.ruleType">
<a-select-option v-for="opt in RULE_TYPE_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="适用范围" required>
<a-select v-model:value="ruleForm.scope">
<a-select-option value="all">全体用户</a-select-option>
<a-select-option value="designer">设计师</a-select-option>
<a-select-option value="department">指定部门</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-divider>触发条件</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="条件类型" required>
<a-select v-model:value="ruleForm.conditionType">
<a-select-option v-for="opt in TRIGGER_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="ruleForm.operator">
<a-select-option v-for="opt in OPERATOR_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-input-number v-model:value="ruleForm.conditionValue" :min="1" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-divider>奖励设置</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="奖励积分" required>
<a-input-number v-model:value="ruleForm.rewardScore" :min="1" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="优先级">
<a-input-number v-model:value="ruleForm.priority" :min="0" :max="1000" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } 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 {
getRewardRuleList,
createRewardRule,
updateRewardRule,
deleteRewardRule,
enableRewardRule,
disableRewardRule,
getRewardStats,
getRewardRecords,
getAllBadges,
getAllTitles,
RULE_TYPE_OPTIONS,
TRIGGER_TYPE_OPTIONS,
OPERATOR_OPTIONS,
type RewardRule,
type RewardStats,
type RewardRecord,
type BadgeDefinition,
type TitleDefinition
} from '@/api/rewardRule'
// Tab状态
const activeTab = ref('rules')
// 规则列表状态
const loading = ref(false)
const rules = ref<RewardRule[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
// 弹窗状态
const modalVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const editingId = ref<number | null>(null)
// 规则表单
const ruleForm = ref({
name: '',
description: '',
ruleType: 'daily',
scope: 'all',
conditionType: 'task_count',
operator: '>=',
conditionValue: 5,
rewardScore: 20,
priority: 100
})
// 统计数据
const statsPeriod = ref('month')
const stats = ref<Partial<RewardStats>>({})
// 发放记录
const recordsLoading = ref(false)
const records = ref<RewardRecord[]>([])
const recordPage = ref(1)
const recordTotal = ref(0)
const recordColumns = [
{ title: '用户', dataIndex: 'userName', key: 'userName' },
{ title: '规则', dataIndex: 'ruleName', key: 'ruleName' },
{ title: '奖励类型', dataIndex: 'rewardType', key: 'rewardType' },
{ title: '奖励内容', dataIndex: 'rewardValue', key: 'rewardValue' },
{ title: '发放时间', dataIndex: 'createTime', key: 'createTime' },
]
// 徽章和称号
const badges = ref<BadgeDefinition[]>([])
const titles = ref<TitleDefinition[]>([])
// 加载规则列表
const loadData = async () => {
loading.value = true
try {
const result = await getRewardRuleList({
page: currentPage.value,
pageSize: pageSize.value,
ruleType: filterType.value
})
rules.value = result.records || []
total.value = result.total || 0
} catch (error) {
console.error('加载规则失败:', error)
message.error('加载数据失败')
} finally {
loading.value = false
}
}
// 加载统计数据
const loadStats = async () => {
try {
stats.value = await getRewardStats(statsPeriod.value)
} catch (error) {
console.error('加载统计失败:', error)
}
}
// 加载发放记录
const loadRecords = async (page: number = 1) => {
recordsLoading.value = true
recordPage.value = page
try {
const result = await getRewardRecords({ page, pageSize: 20 })
records.value = result.records || []
recordTotal.value = result.total || 0
} catch (error) {
console.error('加载记录失败:', error)
} finally {
recordsLoading.value = false
}
}
// 加载徽章
const loadBadges = async () => {
try {
badges.value = await getAllBadges()
} catch (error) {
console.error('加载徽章失败:', error)
}
}
// 加载称号
const loadTitles = async () => {
try {
titles.value = await getAllTitles()
} catch (error) {
console.error('加载称号失败:', error)
}
}
// 监听Tab切换
watch(activeTab, (newTab) => {
if (newTab === 'stats') loadStats()
if (newTab === 'records') loadRecords()
if (newTab === 'badges') loadBadges()
if (newTab === 'titles') loadTitles()
})
const showCreateModal = () => {
isEdit.value = false
editingId.value = null
ruleForm.value = {
name: '',
description: '',
ruleType: 'daily',
scope: 'all',
conditionType: 'task_count',
operator: '>=',
conditionValue: 5,
rewardScore: 20,
priority: 100
}
modalVisible.value = true
}
const editRule = (rule: RewardRule) => {
isEdit.value = true
editingId.value = rule.id
let condition = rule.triggerCondition
if (typeof condition === 'string') {
condition = JSON.parse(condition)
}
ruleForm.value = {
name: rule.name,
description: rule.description || '',
ruleType: rule.ruleType,
scope: rule.scope,
conditionType: condition.type || 'task_count',
operator: condition.operator || '>=',
conditionValue: condition.value || 5,
rewardScore: rule.rewardScore,
priority: rule.priority
}
modalVisible.value = true
}
const handleSubmit = async () => {
if (!ruleForm.value.name) {
message.warning('请输入规则名称')
return
}
submitting.value = true
try {
const data = {
name: ruleForm.value.name,
description: ruleForm.value.description,
ruleType: ruleForm.value.ruleType,
scope: ruleForm.value.scope,
triggerCondition: JSON.stringify({
type: ruleForm.value.conditionType,
operator: ruleForm.value.operator,
value: ruleForm.value.conditionValue
}),
rewardType: 'score',
rewardScore: ruleForm.value.rewardScore,
priority: ruleForm.value.priority
}
if (isEdit.value && editingId.value) {
await updateRewardRule(editingId.value, data)
message.success('更新成功')
} else {
await createRewardRule(data)
message.success('创建成功')
}
modalVisible.value = false
loadData()
} catch (error) {
console.error('提交失败:', error)
message.error('操作失败')
} finally {
submitting.value = false
}
}
const toggleRule = async (rule: RewardRule, enabled: boolean) => {
try {
if (enabled) {
await enableRewardRule(rule.id)
message.success('规则已启用')
} else {
await disableRewardRule(rule.id)
message.success('规则已禁用')
}
loadData()
} catch (error) {
message.error('操作失败')
}
}
const handleDelete = async (id: number) => {
try {
await deleteRewardRule(id)
message.success('删除成功')
loadData()
} catch (error) {
message.error('删除失败')
}
}
const handlePageChange = (page: number) => {
currentPage.value = page
loadData()
}
const getRuleTypeName = (type: string) => {
const opt = RULE_TYPE_OPTIONS.find(o => o.value === type)
return opt?.label || type
}
const getRuleTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
daily: 'green',
weekly: 'blue',
monthly: 'purple',
task: 'orange',
rating: 'gold'
}
return colorMap[type] || 'default'
}
const getScopeName = (scope: string) => {
const map: Record<string, string> = {
all: '全体用户',
designer: '设计师',
department: '指定部门'
}
return map[scope] || scope
}
const formatCondition = (condition: any) => {
if (typeof condition === 'string') {
try {
condition = JSON.parse(condition)
} catch {
return condition
}
}
const typeOpt = TRIGGER_TYPE_OPTIONS.find(o => o.value === condition.type)
const opOpt = OPERATOR_OPTIONS.find(o => o.value === condition.operator)
return `${typeOpt?.label || condition.type} ${opOpt?.label || condition.operator} ${condition.value}`
}
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm')
const parseRewardValue = (value: string) => {
try {
const obj = JSON.parse(value)
if (obj.score) return `+${obj.score} 积分`
return value
} catch {
return value
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
@import "@/styles/variables.scss";
.reward-rules-page {
min-height: 100vh;
padding: 24px;
background: $bg-primary;
}
.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: $text-primary;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.page-desc {
color: $text-secondary;
font-size: 14px;
}
.filter-bar {
margin-bottom: 24px;
}
.rules-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.rule-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
transition: all 0.3s;
&:hover {
border-color: $brand-primary;
}
}
.rule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.rule-info {
display: flex;
align-items: center;
gap: 12px;
}
.rule-name {
font-size: 18px;
font-weight: 600;
color: $text-primary;
}
.rule-desc {
color: $text-secondary;
font-size: 14px;
margin-bottom: 16px;
}
.rule-details {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 16px;
padding: 16px;
background: $bg-secondary;
border-radius: 8px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
font-size: 12px;
color: $text-tertiary;
}
.detail-value {
font-size: 14px;
color: $text-primary;
&.reward {
color: $brand-primary;
font-weight: 600;
}
}
.rule-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid $border-primary;
}
.rule-time {
font-size: 12px;
color: $text-tertiary;
}
.rule-actions {
display: flex;
gap: 8px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 24px;
}
.empty-state {
text-align: center;
padding: 48px;
color: $text-tertiary;
p {
margin: 16px 0;
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 48px;
}
// 统计样式
.stats-section {
.stats-filter {
margin-bottom: 24px;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 24px;
text-align: center;
.stat-value {
font-size: 36px;
font-weight: 700;
color: $brand-primary;
}
.stat-label {
font-size: 14px;
color: $text-secondary;
margin-top: 8px;
}
}
}
.ranking-section {
.ranking-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
}
.ranking-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
margin-bottom: 16px;
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.ranking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: $bg-secondary;
border-radius: 8px;
.rank {
width: 24px;
height: 24px;
border-radius: 50%;
background: $bg-tertiary;
color: $text-secondary;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
&.top3 {
background: $brand-primary;
color: #000;
}
}
.name {
flex: 1;
color: $text-primary;
}
.count, .score {
color: $brand-primary;
font-weight: 600;
}
}
.empty-ranking {
text-align: center;
color: $text-tertiary;
padding: 24px;
}
}
// 徽章样式
.badges-section {
.badges-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.badge-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
display: flex;
gap: 16px;
align-items: center;
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 600;
}
.badge-info {
flex: 1;
h4 {
font-size: 16px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
}
p {
font-size: 12px;
color: $text-secondary;
margin-bottom: 8px;
}
}
}
.empty-badges {
text-align: center;
color: $text-tertiary;
padding: 48px;
grid-column: 1 / -1;
}
}
// 称号样式
.titles-section {
.titles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.title-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: 12px;
padding: 20px;
text-align: center;
.title-name {
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
}
.title-desc {
font-size: 12px;
color: $text-secondary;
margin-bottom: 12px;
}
}
.empty-titles {
text-align: center;
color: $text-tertiary;
padding: 48px;
grid-column: 1 / -1;
}
}
// Tabs样式
.reward-tabs {
:deep(.ant-tabs-nav) {
margin-bottom: 24px;
}
}
.reward-value {
color: $brand-primary;
font-weight: 600;
}
</style>