Files
xinghuoapi/frontend/src/views/user/RedeemView.vue
小北 ae18397ca6 feat: 向用户显示管理员调整余额的备注
- 为RedeemCode DTO添加notes字段(仅用于admin_balance/admin_concurrency类型)
- 更新mapper使其有条件地包含备注信息
- 在用户兑换历史UI中显示备注
- 备注以斜体显示,悬停时显示完整内容

用户现在可以看到管理员调整其余额的原因说明。

Changes:
- backend/internal/handler/dto/types.go: RedeemCode添加notes字段
- backend/internal/handler/dto/mappers.go: 条件性填充notes
- frontend/src/api/redeem.ts: TypeScript接口添加notes
- frontend/src/views/user/RedeemView.vue: UI显示备注信息
2026-02-02 17:44:50 +08:00

502 lines
18 KiB
Vue

<template>
<AppLayout>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Current Balance Card -->
<div class="card overflow-hidden">
<div class="bg-gradient-to-br from-primary-500 to-primary-600 px-6 py-8 text-center">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm"
>
<Icon name="creditCard" size="xl" class="text-white" />
</div>
<p class="text-sm font-medium text-primary-100">{{ t('redeem.currentBalance') }}</p>
<p class="mt-2 text-4xl font-bold text-white">
${{ user?.balance?.toFixed(2) || '0.00' }}
</p>
<p class="mt-2 text-sm text-primary-100">
{{ t('redeem.concurrency') }}: {{ user?.concurrency || 0 }} {{ t('redeem.requests') }}
</p>
</div>
</div>
<!-- Redeem Form -->
<div class="card">
<div class="p-6">
<form @submit.prevent="handleRedeem" class="space-y-5">
<div>
<label for="code" class="input-label">
{{ t('redeem.redeemCodeLabel') }}
</label>
<div class="relative mt-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<Icon name="gift" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="code"
v-model="redeemCode"
type="text"
required
:placeholder="t('redeem.redeemCodePlaceholder')"
:disabled="submitting"
class="input py-3 pl-12 text-lg"
/>
</div>
<p class="input-hint">
{{ t('redeem.redeemCodeHint') }}
</p>
</div>
<button
type="submit"
:disabled="!redeemCode || submitting"
class="btn btn-primary w-full py-3"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-5 w-5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="checkCircle" size="md" class="mr-2" />
{{ submitting ? t('redeem.redeeming') : t('redeem.redeemButton') }}
</button>
</form>
</div>
</div>
<!-- Success Message -->
<transition name="fade">
<div
v-if="redeemResult"
class="card border-emerald-200 bg-emerald-50 dark:border-emerald-800/50 dark:bg-emerald-900/20"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 dark:bg-emerald-900/30"
>
<Icon name="checkCircle" size="md" class="text-emerald-600 dark:text-emerald-400" />
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
{{ t('redeem.redeemSuccess') }}
</h3>
<div class="mt-2 text-sm text-emerald-700 dark:text-emerald-400">
<p>{{ redeemResult.message }}</p>
<div class="mt-3 space-y-1">
<p v-if="redeemResult.type === 'balance'" class="font-medium">
{{ t('redeem.added') }}: ${{ redeemResult.value.toFixed(2) }}
</p>
<p v-else-if="redeemResult.type === 'concurrency'" class="font-medium">
{{ t('redeem.added') }}: {{ redeemResult.value }}
{{ t('redeem.concurrentRequests') }}
</p>
<p v-else-if="redeemResult.type === 'subscription'" class="font-medium">
{{ t('redeem.subscriptionAssigned') }}
<span v-if="redeemResult.group_name"> - {{ redeemResult.group_name }}</span>
<span v-if="redeemResult.validity_days">
({{
t('redeem.subscriptionDays', { days: redeemResult.validity_days })
}})</span
>
</p>
<p v-if="redeemResult.new_balance !== undefined">
{{ t('redeem.newBalance') }}:
<span class="font-semibold">${{ redeemResult.new_balance.toFixed(2) }}</span>
</p>
<p v-if="redeemResult.new_concurrency !== undefined">
{{ t('redeem.newConcurrency') }}:
<span class="font-semibold"
>{{ redeemResult.new_concurrency }} {{ t('redeem.requests') }}</span
>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="card border-red-200 bg-red-50 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-red-100 dark:bg-red-900/30"
>
<Icon
name="exclamationCircle"
size="md"
class="text-red-600 dark:text-red-400"
/>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-red-800 dark:text-red-300">
{{ t('redeem.redeemFailed') }}
</h3>
<p class="mt-2 text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</div>
</div>
</transition>
<!-- Information Card -->
<div
class="card border-primary-200 bg-primary-50 dark:border-primary-800/50 dark:bg-primary-900/20"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30"
>
<Icon name="infoCircle" size="md" class="text-primary-600 dark:text-primary-400" />
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-primary-800 dark:text-primary-300">
{{ t('redeem.aboutCodes') }}
</h3>
<ul
class="mt-2 list-inside list-disc space-y-1 text-sm text-primary-700 dark:text-primary-400"
>
<li>{{ t('redeem.codeRule1') }}</li>
<li>{{ t('redeem.codeRule2') }}</li>
<li>
{{ t('redeem.codeRule3') }}
<span
v-if="contactInfo"
class="ml-1.5 inline-flex items-center rounded-md bg-primary-200/50 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-800/40 dark:text-primary-200"
>
{{ contactInfo }}
</span>
</li>
<li>{{ t('redeem.codeRule4') }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('redeem.recentActivity') }}
</h2>
</div>
<div class="p-6">
<!-- Loading State -->
<div v-if="loadingHistory" class="flex items-center justify-center py-8">
<svg class="h-6 w-6 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<!-- History List -->
<div v-else-if="history.length > 0" class="space-y-3">
<div
v-for="item in history"
:key="item.id"
class="flex items-center justify-between rounded-xl bg-gray-50 p-4 dark:bg-dark-800"
>
<div class="flex items-center gap-4">
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-xl',
isBalanceType(item.type)
? item.value >= 0
? 'bg-emerald-100 dark:bg-emerald-900/30'
: 'bg-red-100 dark:bg-red-900/30'
: isSubscriptionType(item.type)
? 'bg-purple-100 dark:bg-purple-900/30'
: item.value >= 0
? 'bg-blue-100 dark:bg-blue-900/30'
: 'bg-orange-100 dark:bg-orange-900/30'
]"
>
<!-- 余额类型图标 -->
<Icon
v-if="isBalanceType(item.type)"
name="dollar"
size="md"
:class="
item.value >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
"
/>
<!-- 订阅类型图标 -->
<Icon
v-else-if="isSubscriptionType(item.type)"
name="badge"
size="md"
class="text-purple-600 dark:text-purple-400"
/>
<!-- 并发类型图标 -->
<Icon
v-else
name="bolt"
size="md"
:class="
item.value >= 0
? 'text-blue-600 dark:text-blue-400'
: 'text-orange-600 dark:text-orange-400'
"
/>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ getHistoryItemTitle(item) }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ formatDateTime(item.used_at) }}
</p>
</div>
</div>
<div class="text-right">
<p
:class="[
'text-sm font-semibold',
isBalanceType(item.type)
? item.value >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
: isSubscriptionType(item.type)
? 'text-purple-600 dark:text-purple-400'
: item.value >= 0
? 'text-blue-600 dark:text-blue-400'
: 'text-orange-600 dark:text-orange-400'
]"
>
{{ formatHistoryValue(item) }}
</p>
<p
v-if="!isAdminAdjustment(item.type)"
class="font-mono text-xs text-gray-400 dark:text-dark-500"
>
{{ item.code.slice(0, 8) }}...
</p>
<p v-else class="text-xs text-gray-400 dark:text-dark-500">
{{ t('redeem.adminAdjustment') }}
</p>
<!-- Display notes for admin adjustments -->
<p
v-if="item.notes"
class="mt-1 text-xs text-gray-500 dark:text-dark-400 italic max-w-[200px] truncate"
:title="item.notes"
>
{{ item.notes }}
</p>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state py-8">
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800"
>
<Icon name="clock" size="xl" class="text-gray-400 dark:text-dark-500" />
</div>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ t('redeem.historyWillAppear') }}
</p>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user)
const redeemCode = ref('')
const submitting = ref(false)
const redeemResult = ref<{
message: string
type: string
value: number
new_balance?: number
new_concurrency?: number
group_name?: string
validity_days?: number
} | null>(null)
const errorMessage = ref('')
// History data
const history = ref<RedeemHistoryItem[]>([])
const loadingHistory = ref(false)
const contactInfo = ref('')
// Helper functions for history display
const isBalanceType = (type: string) => {
return type === 'balance' || type === 'admin_balance'
}
const isSubscriptionType = (type: string) => {
return type === 'subscription'
}
const isAdminAdjustment = (type: string) => {
return type === 'admin_balance' || type === 'admin_concurrency'
}
const getHistoryItemTitle = (item: RedeemHistoryItem) => {
if (item.type === 'balance') {
return t('redeem.balanceAddedRedeem')
} else if (item.type === 'admin_balance') {
return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin')
} else if (item.type === 'concurrency') {
return t('redeem.concurrencyAddedRedeem')
} else if (item.type === 'admin_concurrency') {
return item.value >= 0 ? t('redeem.concurrencyAddedAdmin') : t('redeem.concurrencyReducedAdmin')
} else if (item.type === 'subscription') {
return t('redeem.subscriptionAssigned')
}
return t('common.unknown')
}
const formatHistoryValue = (item: RedeemHistoryItem) => {
if (isBalanceType(item.type)) {
const sign = item.value >= 0 ? '+' : ''
return `${sign}$${item.value.toFixed(2)}`
} else if (isSubscriptionType(item.type)) {
// 订阅类型显示有效天数和分组名称
const days = item.validity_days || Math.round(item.value)
const groupName = item.group?.name || ''
return groupName ? `${days}${t('redeem.days')} - ${groupName}` : `${days}${t('redeem.days')}`
} else {
const sign = item.value >= 0 ? '+' : ''
return `${sign}${item.value} ${t('redeem.requests')}`
}
}
const fetchHistory = async () => {
loadingHistory.value = true
try {
history.value = await redeemAPI.getHistory()
} catch (error) {
console.error('Failed to fetch history:', error)
} finally {
loadingHistory.value = false
}
}
const handleRedeem = async () => {
if (!redeemCode.value.trim()) {
appStore.showError(t('redeem.pleaseEnterCode'))
return
}
submitting.value = true
errorMessage.value = ''
redeemResult.value = null
try {
const result = await redeemAPI.redeem(redeemCode.value.trim())
redeemResult.value = result
// Refresh user data to get updated balance/concurrency
await authStore.refreshUser()
// If subscription type, immediately refresh subscription status
if (result.type === 'subscription') {
try {
await subscriptionStore.fetchActiveSubscriptions(true) // force refresh
} catch (error) {
console.error('Failed to refresh subscriptions after redeem:', error)
appStore.showWarning(t('redeem.subscriptionRefreshFailed'))
}
}
// Clear the input
redeemCode.value = ''
// Refresh history
await fetchHistory()
// Show success toast
appStore.showSuccess(t('redeem.codeRedeemSuccess'))
} catch (error: any) {
errorMessage.value = error.response?.data?.detail || t('redeem.failedToRedeem')
appStore.showError(t('redeem.redeemFailed'))
} finally {
submitting.value = false
}
}
onMounted(async () => {
fetchHistory()
try {
const settings = await authAPI.getPublicSettings()
contactInfo.value = settings.contact_info || ''
} catch (error) {
console.error('Failed to load contact info:', error)
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>