Files
hfb_sys/frontend/src/features/admin/views/AdminPointsMallView.vue
T

374 lines
11 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.
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import {
completePointsRedemption,
createPointsProduct,
fetchAdminPointsProducts,
fetchAdminPointsRedemptions,
updatePointsProduct,
type PointsMallProduct,
type PointsMallProductPayload,
type PointsMallRedemption,
} from '@/features/points/api/pointsMall'
import { useAdminSessionStore } from '@/stores/adminSession'
import { uploadAdminFile } from '@/shared/api/files'
import { readError } from '@/shared/utils/error'
const adminSession = useAdminSessionStore()
const activeTab = ref<'products' | 'redemptions'>('products')
const loading = ref(false)
const submitting = ref(false)
const products = ref<PointsMallProduct[]>([])
const redemptions = ref<PointsMallRedemption[]>([])
const editorVisible = ref(false)
const editingID = ref(0)
const coverUploading = ref(false)
const coverFileInput = ref<HTMLInputElement | null>(null)
const canManage = computed(() => adminSession.hasPermission('points_mall:manage'))
const form = reactive<PointsMallProductPayload>(defaultProduct())
onMounted(load)
async function load() {
loading.value = true
try {
const [nextProducts, nextRedemptions] = await Promise.all([
fetchAdminPointsProducts(),
fetchAdminPointsRedemptions(),
])
products.value = nextProducts
redemptions.value = nextRedemptions
} catch (error) {
ElMessage.error(readError(error, '积分商城数据加载失败'))
} finally {
loading.value = false
}
}
function openCreate() {
editingID.value = 0
Object.assign(form, defaultProduct())
editorVisible.value = true
}
function openEdit(item: PointsMallProduct) {
editingID.value = item.id
Object.assign(form, {
title: item.title,
subtitle: item.subtitle,
description: item.description,
cover_url: item.cover_url,
redemption_instructions: item.redemption_instructions,
points_price: item.points_price,
stock: item.stock,
per_user_limit: item.per_user_limit,
status: item.status === 'online' ? 'online' : 'offline',
sort_order: item.sort_order,
})
editorVisible.value = true
}
async function saveProduct() {
if (!form.title.trim() || !form.description.trim() || !form.redemption_instructions.trim()) {
ElMessage.warning('请填写商品名称、说明和兑换说明')
return
}
submitting.value = true
try {
const payload = { ...form }
if (editingID.value) {
await updatePointsProduct(editingID.value, payload)
} else {
await createPointsProduct(payload)
}
ElMessage.success('积分商品已保存')
editorVisible.value = false
await load()
} catch (error) {
ElMessage.error(readError(error, '积分商品保存失败'))
} finally {
submitting.value = false
}
}
async function completeRedemption(item: PointsMallRedemption) {
const remark = window.prompt('填写发放说明或处理备注(可留空)', item.admin_remark || '')
if (remark === null) return
try {
await completePointsRedemption(item.id, remark)
ElMessage.success('兑换已标记完成')
await load()
} catch (error) {
ElMessage.error(readError(error, '处理兑换失败'))
}
}
function triggerCoverUpload() {
coverFileInput.value?.click()
}
async function handleCoverFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
coverUploading.value = true
try {
const uploaded = await uploadAdminFile(file, 'points-mall')
form.cover_url = uploaded.url
ElMessage.success('商品图片上传成功')
} catch (error) {
ElMessage.error(readError(error, '商品图片上传失败'))
} finally {
coverUploading.value = false
}
}
function defaultProduct(): PointsMallProductPayload {
return {
title: '',
subtitle: '',
description: '',
cover_url: '',
redemption_instructions: '',
points_price: 100,
stock: 10,
per_user_limit: 1,
status: 'offline',
sort_order: 0,
}
}
</script>
<template>
<main class="admin-page">
<header class="page-header">
<div>
<h1>积分商城</h1>
<p>管理虚拟商品、库存和用户兑换发放。</p>
</div>
<el-button v-if="canManage" type="primary" @click="openCreate">新增积分商品</el-button>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="商品管理" name="products">
<el-table v-loading="loading" :data="products" border>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="商品" min-width="180" />
<el-table-column prop="points_price" label="积分价格" width="110" />
<el-table-column prop="stock" label="库存" width="90" />
<el-table-column prop="redeemed_count" label="已兑换" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'online' ? 'success' : 'info'">
{{ row.status === 'online' ? '上架' : '下架' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button v-if="canManage" link type="primary" @click="openEdit(row)">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="兑换记录" name="redemptions">
<el-table v-loading="loading" :data="redemptions" border>
<el-table-column prop="redemption_no" label="兑换单号" min-width="190" />
<el-table-column label="用户" min-width="150">
<template #default="{ row }">
{{ row.user_nickname || '-' }}<br />
<small>{{ row.user_phone || row.user_id }}</small>
</template>
</el-table-column>
<el-table-column prop="product_title" label="商品" min-width="160" />
<el-table-column prop="total_points" label="积分" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : 'warning'">
{{ row.status === 'completed' ? '已发放' : '待处理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="兑换时间" min-width="170" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
v-if="canManage && row.status === 'pending'"
link
type="primary"
@click="completeRedemption(row)"
>
发放
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog
v-model="editorVisible"
:title="editingID ? '编辑积分商品' : '新增积分商品'"
width="680px"
>
<el-form label-position="top">
<el-form-item label="商品名称">
<el-input v-model="form.title" maxlength="128" />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="form.subtitle" maxlength="255" />
</el-form-item>
<el-form-item label="商品说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="商品封面">
<div class="cover-editor">
<div class="cover-preview">
<img v-if="form.cover_url" :src="form.cover_url" alt="商品封面预览" />
<span v-else>暂无图片</span>
</div>
<div class="cover-actions">
<el-button :loading="coverUploading" @click="triggerCoverUpload">
{{ coverUploading ? '上传中' : '上传本地图片' }}
</el-button>
<el-button v-if="form.cover_url" plain @click="form.cover_url = ''"
>移除图片</el-button
>
<el-input v-model="form.cover_url" placeholder="也可以直接填写图片 URL" />
<small>支持 JPG、PNG、WebP,上传后自动保存到商品资料。</small>
</div>
</div>
<input
ref="coverFileInput"
class="hidden-cover-input"
type="file"
accept="image/jpeg,image/png,image/webp"
@change="handleCoverFileChange"
/>
</el-form-item>
<el-form-item label="兑换后发放说明">
<el-input v-model="form.redemption_instructions" type="textarea" :rows="3" />
</el-form-item>
<div class="form-grid">
<el-form-item label="积分价格">
<el-input-number v-model="form.points_price" :min="1" />
</el-form-item>
<el-form-item label="库存">
<el-input-number v-model="form.stock" :min="0" />
</el-form-item>
<el-form-item label="每人限兑">
<el-input-number v-model="form.per_user_limit" :min="1" :max="100" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" />
</el-form-item>
</div>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio-button value="online">上架</el-radio-button>
<el-radio-button value="offline">下架</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editorVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="saveProduct">保存</el-button>
</template>
</el-dialog>
</main>
</template>
<style scoped>
.admin-page {
padding: 22px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.page-header h1 {
margin: 0;
color: #1f2937;
font-size: 24px;
}
.page-header p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.cover-editor {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 14px;
width: 100%;
}
.cover-preview {
display: grid;
width: 180px;
height: 120px;
place-items: center;
overflow: hidden;
border: 1px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: #94a3b8;
font-size: 12px;
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-actions {
display: grid;
align-content: start;
gap: 9px;
}
.cover-actions .el-button {
justify-self: start;
margin-left: 0;
}
.cover-actions small {
color: #94a3b8;
font-size: 11px;
}
.hidden-cover-input {
display: none;
}
@media (max-width: 760px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cover-editor {
grid-template-columns: 1fr;
}
}
</style>