Files
wenzi/frontend/admin/src/views/RewardsView.vue
Your Name 91a0b77f7a test(cache): 修复CacheConfigTest边界值测试
- 修改 shouldVerifyCacheManager_withMaximumIntegerTtl 为 shouldVerifyCacheManager_withMaximumAllowedTtl
- 使用正确的最大TTL值(10080分钟,7天)而不是 Integer.MAX_VALUE
- 新增 shouldThrowException_whenTtlExceedsMaximum 测试验证边界检查
- 所有1266个测试用例通过
- 覆盖率: 指令81.89%, 行88.48%, 分支51.55%

docs: 添加项目状态报告
- 生成 PROJECT_STATUS_REPORT.md 详细记录项目当前状态
- 包含质量指标、已完成功能、待办事项和技术债务
2026-03-02 13:31:54 +08:00

261 lines
9.2 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>
<section class="space-y-6">
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
<template #title>奖励发放</template>
<template #subtitle>查看奖励发放状态与明细</template>
<template #filters>
<input class="mos-input !py-1 !px-2 !text-xs w-48" v-model="query" placeholder="搜索用户" />
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="startDate" />
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="endDate" />
</template>
<template #actions>
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAll">
{{ allSelected ? '取消全选' : '全选' }}
</button>
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchIssue">批量发放</button>
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchRollback">批量回滚</button>
<input class="mos-input !py-1 !px-2 !text-xs w-44" v-model="batchReason" placeholder="批量回滚原因" />
</template>
<template #default>
<div class="space-y-3">
<div v-for="reward in pagedRewards" :key="reward.id" class="space-y-2 rounded-xl border border-mosquito-line px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<input
type="checkbox"
class="h-4 w-4"
:checked="selectedIds.includes(reward.id)"
@click.stop
@change.stop="toggleSelect(reward.id)"
/>
<div>
<div class="text-sm font-semibold text-mosquito-ink">{{ reward.userName }}</div>
<div class="mos-muted text-xs">批次{{ reward.batchId }} · {{ reward.batchStatus }}</div>
<div class="mos-muted text-xs">发放时间{{ formatDate(reward.issuedAt) }}</div>
<div v-if="reward.note" class="mos-muted text-xs">备注{{ reward.note }}</div>
</div>
</div>
<div class="flex items-center gap-3 text-xs text-mosquito-ink/70">
<span>{{ reward.points }} 积分</span>
<span class="rounded-full bg-mosquito-accent/10 px-2 py-1 text-[10px] font-semibold text-mosquito-brand">{{ reward.status }}</span>
<button
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
@click="handleActionClick(reward)"
>
{{ actionLabel(reward) }}
</button>
</div>
</div>
<div
v-if="actioningId === reward.id"
class="flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-mosquito-line px-4 py-3 text-xs"
>
<input class="mos-input !py-1 !px-2 !text-xs flex-1" v-model="actionReason" placeholder="请输入原因" />
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="cancelAction">取消</button>
<button class="mos-btn mos-btn-accent !py-1 !px-2 !text-xs" @click="confirmAction(reward)">确认</button>
</div>
</div>
</div>
</template>
<template #footer>
<ExportFieldPanel
title="导出字段"
:fields="exportFields"
:selected="exportSelected"
@update:selected="setExportSelected"
@export="exportRewards"
/>
</template>
</ListSection>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useDataService } from '../services'
import { downloadCsv } from '../utils/export'
import { useAuditStore } from '../stores/audit'
import ListSection from '../components/ListSection.vue'
import ExportFieldPanel, { type ExportField } from '../components/ExportFieldPanel.vue'
import { useExportFields } from '../composables/useExportFields'
import { normalizeRewardReason } from '../utils/reward'
type RewardItem = {
id: string
userName: string
points: number
status: string
issuedAt: string
batchId: string
batchStatus: string
note?: string
}
const rewards = ref<RewardItem[]>([])
const service = useDataService()
const auditStore = useAuditStore()
const query = ref('')
const selectedIds = ref<string[]>([])
const startDate = ref('')
const endDate = ref('')
const batchReason = ref('')
const actioningId = ref<string | null>(null)
const actionType = ref<'rollback' | 'retry' | null>(null)
const actionReason = ref('')
const page = ref(0)
const pageSize = 6
const formatDate = (value: string) => new Date(value).toLocaleDateString('zh-CN')
const exportFields: ExportField[] = [
{ key: 'userName', label: '用户', required: true },
{ key: 'points', label: '积分' },
{ key: 'status', label: '状态' },
{ key: 'issuedAt', label: '发放时间' },
{ key: 'batchId', label: '批次编号' },
{ key: 'batchStatus', label: '批次状态' },
{ key: 'note', label: '备注' }
]
const { selected: exportSelected, setSelected: setExportSelected } = useExportFields(
exportFields,
exportFields.map((field) => field.key)
)
const exportRewards = () => {
const headers = exportFields
.filter((field) => exportSelected.value.includes(field.key))
.map((field) => field.label)
const rows = rewards.value.map((item) =>
exportFields
.filter((field) => exportSelected.value.includes(field.key))
.map((field) => {
if (field.key === 'userName') return item.userName
if (field.key === 'points') return String(item.points)
if (field.key === 'status') return item.status
if (field.key === 'issuedAt') return formatDate(item.issuedAt)
if (field.key === 'batchId') return item.batchId
if (field.key === 'batchStatus') return item.batchStatus
return item.note ?? ''
})
)
downloadCsv('rewards-demo.csv', headers, rows)
}
onMounted(async () => {
rewards.value = await service.getRewards()
})
const applyIssue = (reward: RewardItem) => {
reward.status = '已发放'
reward.note = undefined
auditStore.addLog('发放奖励', reward.userName)
}
const rollbackIssue = (reward: RewardItem, reason: string) => {
reward.status = '待发放'
reward.note = `回滚原因:${reason}`
auditStore.addLog('回滚奖励', `${reward.userName}${reason}`)
}
const retryIssue = (reward: RewardItem, reason: string) => {
reward.status = '已发放'
reward.note = `重试原因:${reason}`
auditStore.addLog('重试发放奖励', `${reward.userName}${reason}`)
}
const actionLabel = (reward: RewardItem) => {
if (reward.status === '已发放') return '回滚'
if (reward.status === '发放失败') return '重试'
return '发放'
}
const handleActionClick = (reward: RewardItem) => {
if (reward.status === '已发放') {
actioningId.value = reward.id
actionType.value = 'rollback'
actionReason.value = ''
return
}
if (reward.status === '发放失败') {
actioningId.value = reward.id
actionType.value = 'retry'
actionReason.value = ''
return
}
applyIssue(reward)
}
const cancelAction = () => {
actioningId.value = null
actionType.value = null
actionReason.value = ''
}
const confirmAction = (reward: RewardItem) => {
const reason = normalizeRewardReason(actionReason.value)
if (actionType.value === 'rollback') {
rollbackIssue(reward, reason)
} else if (actionType.value === 'retry') {
retryIssue(reward, reason)
}
cancelAction()
}
const filteredRewards = computed(() => {
return rewards.value.filter((item) => {
const matchesQuery = item.userName.includes(query.value.trim())
const startOk = startDate.value ? new Date(item.issuedAt).getTime() >= new Date(startDate.value).getTime() : true
const endOk = endDate.value ? new Date(item.issuedAt).getTime() <= new Date(endDate.value).getTime() : true
return matchesQuery && startOk && endOk
})
})
const allSelected = computed(() => {
return filteredRewards.value.length > 0 && filteredRewards.value.every((item) => selectedIds.value.includes(item.id))
})
const toggleSelect = (id: string) => {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((item) => item !== id)
} else {
selectedIds.value = [...selectedIds.value, id]
}
}
const selectAll = () => {
if (allSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = filteredRewards.value.map((item) => item.id)
}
}
const batchIssue = () => {
filteredRewards.value
.filter((item) => selectedIds.value.includes(item.id))
.forEach(applyIssue)
selectedIds.value = []
}
const batchRollback = () => {
const reason = normalizeRewardReason(batchReason.value, '批量回滚')
filteredRewards.value
.filter((item) => selectedIds.value.includes(item.id))
.forEach((item) => rollbackIssue(item, reason))
selectedIds.value = []
batchReason.value = ''
}
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRewards.value.length / pageSize)))
const pagedRewards = computed(() => {
const start = page.value * pageSize
return filteredRewards.value.slice(start, start + pageSize)
})
watch([query, startDate, endDate], () => {
page.value = 0
})
</script>