374 lines
11 KiB
Vue
374 lines
11 KiB
Vue
<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>
|