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 详细记录项目当前状态 - 包含质量指标、已完成功能、待办事项和技术债务
This commit is contained in:
260
frontend/admin/src/views/RewardsView.vue
Normal file
260
frontend/admin/src/views/RewardsView.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user