Files
ReelShortWeb/src/views/commerce/ProductManagementView.vue
T

464 lines
14 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.
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
createProductApi,
deleteProductApi,
pageProductsApi,
updateProductApi,
updateProductStatusApi
} from '@/api/admin/product'
import { useRbac } from '@/stores/rbac'
import { dateCell, formatCount } from '@/utils/format'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const pageIndex = ref(1)
const pageSize = ref(10)
const typeFilter = ref(undefined)
const statusFilter = ref(undefined)
const platformFilter = ref(undefined)
const keyword = ref('')
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({
code: '',
name: '',
type: 'COIN_PACK',
platform: 'ALL',
storeProductId: '',
coinAmount: 0,
bonusCoin: 0,
priceAmount: 0,
currency: 'IDR',
displayPrice: '',
regionCodes: 'ID',
vipDays: 0,
sort: 0,
effectiveAt: null,
expireAt: null
})
const typeOptions = [
{ value: 'COIN_PACK', label: '金币包' },
{ value: 'VIP', label: '会员(第二阶段)' }
]
const platformOptions = [
{ value: 'ALL', label: '全平台通用' },
{ value: 'GOOGLE', label: 'Google Play' },
{ value: 'APPLE', label: 'App Store' }
]
const currencyOptions = ['IDR', 'USD']
function platformLabel(value) {
return platformOptions.find((item) => item.value === value)?.label || value || '-'
}
function priceText(row) {
if (row.displayPrice) return row.displayPrice
if (row.priceAmount === null || row.priceAmount === undefined) return '-'
return `${row.priceAmount} ${row.currency || ''}`.trim()
}
async function load() {
loading.value = true
try {
const res = await pageProductsApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
type: typeFilter.value,
status: statusFilter.value,
platform: platformFilter.value,
keyword: keyword.value.trim() || undefined
})
list.value = res?.data?.list || []
total.value = res?.data?.total || 0
} finally {
loading.value = false
}
}
function search() {
pageIndex.value = 1
load()
}
function resetFilters() {
typeFilter.value = undefined
statusFilter.value = undefined
platformFilter.value = undefined
keyword.value = ''
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function nextSort() {
return list.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
}
function openCreate() {
editingId.value = null
Object.assign(form, {
code: '',
name: '',
type: 'COIN_PACK',
platform: 'ALL',
storeProductId: '',
coinAmount: 0,
bonusCoin: 0,
priceAmount: 0,
currency: 'IDR',
displayPrice: '',
regionCodes: 'ID',
vipDays: 0,
sort: nextSort(),
effectiveAt: null,
expireAt: null
})
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, {
code: row.code,
name: row.name,
type: row.type,
platform: row.platform,
storeProductId: row.storeProductId || '',
coinAmount: row.coinAmount ?? 0,
bonusCoin: row.bonusCoin ?? 0,
priceAmount: row.priceAmount ?? 0,
currency: row.currency || 'IDR',
displayPrice: row.displayPrice || '',
regionCodes: row.regionCodes || '',
vipDays: row.vipDays ?? 0,
sort: row.sort ?? 0,
effectiveAt: row.effectiveAt || null,
expireAt: row.expireAt || null
})
dialogVisible.value = true
}
function validateForm() {
if (!form.code.trim()) return '请填写商品编码'
if (!form.name.trim()) return '请填写商品名称'
if (form.storeProductId.trim() && !['GOOGLE', 'APPLE'].includes(form.platform)) {
return '填写了商店商品 id 时,平台要选 Google Play 或 App Store,否则客户端认不出来'
}
if (form.type === 'VIP') {
if (!form.vipDays || form.vipDays <= 0) return '会员商品的会员天数必须大于 0'
} else if (!form.coinAmount || form.coinAmount <= 0) {
return '金币包的金币数量必须大于 0'
}
return ''
}
async function saveProduct() {
const invalid = validateForm()
if (invalid) return ElMessage.warning(invalid)
const payload = {
code: form.code.trim(),
name: form.name.trim(),
type: form.type,
platform: form.platform,
storeProductId: form.storeProductId.trim(),
coinAmount: form.coinAmount || 0,
bonusCoin: form.bonusCoin || 0,
priceAmount: form.priceAmount || 0,
currency: form.currency,
displayPrice: form.displayPrice.trim(),
regionCodes: form.regionCodes.trim(),
vipDays: form.vipDays || 0,
sort: form.sort || 0,
effectiveAt: form.effectiveAt || null,
expireAt: form.expireAt || null
}
saving.value = true
try {
if (editingId.value) {
await updateProductApi(editingId.value, payload)
ElMessage.success('商品已更新')
} else {
await createProductApi(payload)
ElMessage.success('商品已创建(默认下架,请确认后再上架)')
pageIndex.value = 1
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function toggleStatus(row) {
const next = row.status === 1 ? 0 : 1
await updateProductStatusApi(row.id, next)
await load()
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(`确定删除商品「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await deleteProductApi(row.id)
ElMessage.success('商品已删除')
if (list.value.length === 1 && pageIndex.value > 1) pageIndex.value -= 1
await load()
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="订单与权益"
title="商品配置"
description="维护金币包与商店商品 id,客户端按商店商品 id 对齐发币口径。"
>
<el-button
v-if="can('product:update')"
type="primary"
class="primary-button"
@click="openCreate"
>
<AppIcon :icon="AppIcons.add" />
新建商品
</el-button>
</PageHeading>
<section class="panel library-panel">
<div class="library-toolbar">
<div class="drama-filters">
<el-input
v-model="keyword"
clearable
placeholder="商品编码 / 名称"
@keyup.enter="search"
@clear="search"
/>
<el-select v-model="typeFilter" clearable placeholder="全部类型" @change="search">
<el-option
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select v-model="platformFilter" clearable placeholder="全部平台" @change="search">
<el-option
v-for="item in platformOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="search">
<el-option label="已上架" :value="1" />
<el-option label="已下架" :value="0" />
</el-select>
<el-button @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<span class="table-result-count">共 {{ total }} 个商品</span>
</div>
<el-table
v-loading="loading"
:data="list"
row-key="id"
empty-text="还没有配置商品"
class="drama-el-table"
>
<el-table-column prop="code" label="商品编码" min-width="150" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="typeText" label="类型" width="140" />
<el-table-column label="平台" width="130">
<template #default="{ row }">{{ platformLabel(row.platform) }}</template>
</el-table-column>
<el-table-column label="商店商品 id" min-width="170">
<template #default="{ row }">
<span v-if="row.storeProductId">{{ row.storeProductId }}</span>
<span v-else class="table-empty-text">未配置</span>
</template>
</el-table-column>
<el-table-column label="到账金币" width="110">
<template #default="{ row }">
<span v-if="row.type === 'COIN_PACK'">{{ formatCount(row.totalCoin) }}</span>
<span v-else class="table-empty-text">-</span>
</template>
</el-table-column>
<el-table-column label="价格" width="140">
<template #default="{ row }">{{ priceText(row) }}</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-button
v-if="can('product:update')"
link
:type="row.status === 1 ? 'primary' : 'info'"
@click="toggleStatus(row)"
>{{ row.statusText }}</el-button
>
<el-tag v-else :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
row.statusText
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170" :formatter="dateCell" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="can('product:update')"
type="primary"
size="small"
@click="openEdit(row)"
>编辑</el-button
>
<el-button
v-if="can('product:update')"
type="danger"
size="small"
@click="confirmDelete(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑商品' : '新建商品'" width="720px">
<el-form label-position="top">
<div class="form-row">
<el-form-item label="商品编码" required>
<el-input v-model="form.code" placeholder="全局唯一,例如 coin_500_id" />
</el-form-item>
<el-form-item label="商品名称" required>
<el-input v-model="form.name" placeholder="展示给用户的名字" />
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="类型">
<el-select v-model="form.type">
<el-option
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="平台">
<el-select v-model="form.platform">
<el-option
v-for="item in platformOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="商店商品 id">
<el-input v-model="form.storeProductId" placeholder="Google Play / App Store 的商品 id" />
</el-form-item>
</div>
<div class="form-row">
<el-form-item v-if="form.type === 'COIN_PACK'" label="金币数量" required>
<el-input-number v-model="form.coinAmount" :min="0" :max="1000000" />
</el-form-item>
<el-form-item v-if="form.type === 'COIN_PACK'" label="赠送金币">
<el-input-number v-model="form.bonusCoin" :min="0" :max="1000000" />
</el-form-item>
<el-form-item v-if="form.type === 'VIP'" label="会员天数" required>
<el-input-number v-model="form.vipDays" :min="0" :max="3650" />
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="价格">
<el-input-number v-model="form.priceAmount" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="币种">
<el-select v-model="form.currency">
<el-option v-for="item in currencyOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="展示价">
<el-input v-model="form.displayPrice" placeholder="例如 Rp 9.900" />
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="生效区域">
<el-input v-model="form.regionCodes" placeholder="多个区域用逗号分隔,例如 ID,MY" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="9999" />
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="生效时间">
<el-date-picker
v-model="form.effectiveAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="留空表示立即生效"
/>
</el-form-item>
<el-form-item label="失效时间">
<el-date-picker
v-model="form.expireAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="留空表示长期有效"
/>
</el-form-item>
</div>
<p class="console-hint">
新建商品默认下架,确认无误后再上架;会员商品可以建档,但订阅功能上线前不能上架。
</p>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveProduct">保存</el-button>
</template>
</el-dialog>
</template>