feat(admin): 新增商品配置与系统公告两个后台页面
- src/api/admin/product.ts、src/api/admin/notice.ts 对齐后端新接口 - ProductManagementView:列表/筛选/新建编辑/上下架/删除,新建默认下架有提示 - NoticeManagementView:列表/筛选/新建编辑/发布下架/删除 - 状态列一律用接口返回的 statusText,不在前端写死中文 - id 按字符串处理(雪花 id 超过 JS 安全整数范围,数字会被四舍五入)
This commit is contained in:
1 parent
1148458ca1
commit
db313f4aa6
4 files changed
+968
No files matched your search
@@ -0,0 +1,66 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import request from '@/axios'
|
||||||
|
import type { PageResult } from './types'
|
||||||
|
|
||||||
|
/** 系统公告:字段与 /admin/notices 的响应一一对应 */
|
||||||
|
export interface NoticeItem {
|
||||||
|
/** 雪花 id,后端以字符串下发(超过 JS 安全整数范围),原样回传即可 */
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
/** ALL / USER / CREATOR */
|
||||||
|
target: string
|
||||||
|
targetText: string
|
||||||
|
/** SYSTEM / AUDIT / PAYMENT / ACTIVITY */
|
||||||
|
type: string
|
||||||
|
typeText: string
|
||||||
|
/** 1 已发布 / 0 已下架 */
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
publishAt?: string
|
||||||
|
expireAt?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticePageParams {
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: number
|
||||||
|
target?: string
|
||||||
|
type?: string
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeSaveData {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
target: string
|
||||||
|
type: string
|
||||||
|
publishAt?: string | null
|
||||||
|
expireAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pageNoticesApi = (params: NoticePageParams) => {
|
||||||
|
return request.get<PageResult<NoticeItem>>({ url: '/admin/notices/page', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getNoticeApi = (id: string | number) => {
|
||||||
|
return request.get<NoticeItem>({ url: `/admin/notices/${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNoticeApi = (data: NoticeSaveData) => {
|
||||||
|
return request.post<string>({ url: '/admin/notices', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateNoticeApi = (id: string | number, data: NoticeSaveData) => {
|
||||||
|
return request.put<void>({ url: `/admin/notices/${id}`, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateNoticeStatusApi = (id: string | number, status: number) => {
|
||||||
|
return request.put<void>({ url: `/admin/notices/${id}/status`, params: { status } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteNoticeApi = (id: string | number) => {
|
||||||
|
return request.delete<void>({ url: `/admin/notices/${id}` })
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import request from '@/axios'
|
||||||
|
import type { PageResult } from './types'
|
||||||
|
|
||||||
|
/** 商品(金币包):字段与 /admin/products 的响应一一对应 */
|
||||||
|
export interface ProductItem {
|
||||||
|
/** 雪花 id,后端以字符串下发(超过 JS 安全整数范围),原样回传即可 */
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
/** COIN_PACK / VIP */
|
||||||
|
type: string
|
||||||
|
typeText: string
|
||||||
|
/** ALL / GOOGLE / APPLE */
|
||||||
|
platform: string
|
||||||
|
storeProductId: string
|
||||||
|
coinAmount: number
|
||||||
|
bonusCoin: number
|
||||||
|
/** coinAmount + bonusCoin,服务端算好,前端不再自己加 */
|
||||||
|
totalCoin: number
|
||||||
|
priceAmount: number
|
||||||
|
currency: string
|
||||||
|
displayPrice: string
|
||||||
|
regionCodes: string
|
||||||
|
vipDays: number
|
||||||
|
sort: number
|
||||||
|
/** 1 已上架 / 0 已下架 */
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
effectiveAt?: string
|
||||||
|
expireAt?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductPageParams {
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
type?: string
|
||||||
|
status?: number
|
||||||
|
platform?: string
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSaveData {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
platform: string
|
||||||
|
storeProductId: string
|
||||||
|
coinAmount: number
|
||||||
|
bonusCoin: number
|
||||||
|
priceAmount: number
|
||||||
|
currency: string
|
||||||
|
displayPrice: string
|
||||||
|
regionCodes: string
|
||||||
|
vipDays: number
|
||||||
|
sort: number
|
||||||
|
effectiveAt?: string | null
|
||||||
|
expireAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pageProductsApi = (params: ProductPageParams) => {
|
||||||
|
return request.get<PageResult<ProductItem>>({ url: '/admin/products/page', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProductApi = (id: string | number) => {
|
||||||
|
return request.get<ProductItem>({ url: `/admin/products/${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createProductApi = (data: ProductSaveData) => {
|
||||||
|
return request.post<string>({ url: '/admin/products', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateProductApi = (id: string | number, data: ProductSaveData) => {
|
||||||
|
return request.put<void>({ url: `/admin/products/${id}`, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateProductStatusApi = (id: string | number, status: number) => {
|
||||||
|
return request.put<void>({ url: `/admin/products/${id}/status`, params: { status } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteProductApi = (id: string | number) => {
|
||||||
|
return request.delete<void>({ url: `/admin/products/${id}` })
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建商品
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<!-- Author: fruit.com.cn -->
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
createNoticeApi,
|
||||||
|
deleteNoticeApi,
|
||||||
|
pageNoticesApi,
|
||||||
|
updateNoticeApi,
|
||||||
|
updateNoticeStatusApi
|
||||||
|
} from '@/api/admin/notice'
|
||||||
|
import { useRbac } from '@/stores/rbac'
|
||||||
|
import { dateCell } 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 statusFilter = ref(undefined)
|
||||||
|
const targetFilter = ref(undefined)
|
||||||
|
const typeFilter = ref(undefined)
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
target: 'ALL',
|
||||||
|
type: 'SYSTEM',
|
||||||
|
publishAt: null,
|
||||||
|
expireAt: null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 取值与后端 ops_notice 的枚举一致;显示文案由接口的 targetText / typeText 给 */
|
||||||
|
const targetOptions = [
|
||||||
|
{ value: 'ALL', label: '全部用户' },
|
||||||
|
{ value: 'USER', label: '普通用户' },
|
||||||
|
{ value: 'CREATOR', label: '创作者(C 端公告位不展示)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ value: 'SYSTEM', label: '系统' },
|
||||||
|
{ value: 'AUDIT', label: '审核' },
|
||||||
|
{ value: 'PAYMENT', label: '支付' },
|
||||||
|
{ value: 'ACTIVITY', label: '活动' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await pageNoticesApi({
|
||||||
|
pageIndex: pageIndex.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: statusFilter.value,
|
||||||
|
target: targetFilter.value,
|
||||||
|
type: typeFilter.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() {
|
||||||
|
statusFilter.value = undefined
|
||||||
|
targetFilter.value = undefined
|
||||||
|
typeFilter.value = undefined
|
||||||
|
keyword.value = ''
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
pageIndex.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null
|
||||||
|
Object.assign(form, {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
target: 'ALL',
|
||||||
|
type: 'SYSTEM',
|
||||||
|
publishAt: null,
|
||||||
|
expireAt: null
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
editingId.value = row.id
|
||||||
|
Object.assign(form, {
|
||||||
|
title: row.title,
|
||||||
|
content: row.content || '',
|
||||||
|
target: row.target,
|
||||||
|
type: row.type,
|
||||||
|
publishAt: row.publishAt || null,
|
||||||
|
expireAt: row.expireAt || null
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNotice() {
|
||||||
|
if (!form.title.trim()) return ElMessage.warning('请填写公告标题')
|
||||||
|
if (form.publishAt && form.expireAt && form.expireAt <= form.publishAt) {
|
||||||
|
return ElMessage.warning('失效时间必须晚于发布时间')
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
content: form.content,
|
||||||
|
target: form.target,
|
||||||
|
type: form.type,
|
||||||
|
publishAt: form.publishAt || null,
|
||||||
|
expireAt: form.expireAt || null
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateNoticeApi(editingId.value, payload)
|
||||||
|
ElMessage.success('公告已更新')
|
||||||
|
} else {
|
||||||
|
await createNoticeApi(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 updateNoticeStatusApi(row.id, next)
|
||||||
|
ElMessage.success(next === 1 ? '公告已发布' : '公告已下架')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除公告「${row.title}」吗?`, '删除确认', {
|
||||||
|
confirmButtonText: '确认删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonClass: 'el-button--danger'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await deleteNoticeApi(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="发布后客户端公告位立即可见;下架或到期后立即消失。"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
v-if="can('notice:update')"
|
||||||
|
type="primary"
|
||||||
|
class="primary-button"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建公告
|
||||||
|
</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="targetFilter" clearable placeholder="全部目标" @change="search">
|
||||||
|
<el-option
|
||||||
|
v-for="item in targetOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<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="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="title" label="标题" min-width="220" />
|
||||||
|
<el-table-column prop="typeText" label="类型" width="100" />
|
||||||
|
<el-table-column prop="targetText" label="目标" width="120" />
|
||||||
|
<el-table-column prop="content" label="内容" min-width="240" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="publishAt" label="发布时间" width="170" :formatter="dateCell" />
|
||||||
|
<el-table-column prop="expireAt" label="失效时间" width="170" :formatter="dateCell" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="can('notice: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 label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="can('notice:update')"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
>编辑</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
v-if="can('notice: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="680px">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="公告标题" required>
|
||||||
|
<el-input v-model="form.title" maxlength="128" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="公告正文">
|
||||||
|
<el-input
|
||||||
|
v-model="form.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="2000"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<div class="form-row">
|
||||||
|
<el-form-item label="投放目标">
|
||||||
|
<el-select v-model="form.target">
|
||||||
|
<el-option
|
||||||
|
v-for="item in targetOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<el-form-item label="发布时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.publishAt"
|
||||||
|
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="saveNotice">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
Reference in new issue
Block a user