更新多个功能

This commit is contained in:
2026-06-29 07:35:07 +08:00
parent fa94f52b53
commit cf7e2560b5
41 changed files with 2388 additions and 37 deletions
+34
View File
@@ -0,0 +1,34 @@
import { get, post, put, del } from '@/api/request'
import type { AxiosRequestConfig } from 'axios'
export function getAllBanners(params?: any) {
return get('/api/banner/list', params)
}
export function getActiveBanners() {
return get('/api/banner/active')
}
export function getBannerById(id: number) {
return get(`/api/banner/${id}`)
}
export function createBanner(data: any) {
return post('/api/banner', data)
}
export function updateBanner(id: number, data: any, config?: AxiosRequestConfig) {
return put(`/api/banner/${id}`, data, config)
}
export function deleteBanner(id: number, config?: AxiosRequestConfig) {
return del(`/api/banner/${id}`, config)
}
export function enableBanner(id: number) {
return post(`/api/banner/${id}/enable`)
}
export function disableBanner(id: number, config?: AxiosRequestConfig) {
return post(`/api/banner/${id}/disable`, null, config)
}
+22
View File
@@ -0,0 +1,22 @@
import { get, put } from '@/api/request'
/**
* 获取所有配置列表
*/
export function getAllConfigs(params?: any) {
return get('/api/config', params)
}
/**
* 根据配置键获取配置
*/
export function getConfigByKey(configKey: string) {
return get(`/api/config/key/${configKey}`)
}
/**
* 更新配置
*/
export function updateConfig(id: number, data: any) {
return put(`/api/config/${id}`, data)
}
@@ -53,3 +53,7 @@ export function cancelGroupCourse(id: number) {
export function searchGroupCourses(data: any) {
return post('/api/groupCourse/search', data)
}
export function getGroupCourseQRCode(id: number) {
return get(`/api/groupCourse/${id}/qrcode`)
}
@@ -0,0 +1,9 @@
import { get } from '@/api/request'
export function getRevenueStatistics() {
return get('/api/payment/revenue/statistics')
}
export function getPaymentRecords(params?: any) {
return get('/api/payment/revenue/records', params)
}
@@ -55,12 +55,24 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/groupCourseRecommend/index.vue'),
meta: { title: '推荐团课', icon: 'Star' },
},
{
path: 'banner',
name: 'Banner',
component: () => import('@/views/banner/index.vue'),
meta: { title: '轮播图', icon: 'Picture' },
},
{
path: 'datacount',
name: 'DataCount',
component: () => import('@/views/datacount/index.vue'),
meta: { title: '数据报表', icon: 'DataAnalysis' },
},
{
path: 'payment/revenue',
name: 'PaymentRevenue',
component: () => import('@/views/payment/revenue/index.vue'),
meta: { title: '营收详情', icon: 'Money' },
},
{
path: 'operationLog',
name: 'OperationLog',
+10
View File
@@ -39,6 +39,11 @@ const allMenus: MenuItem[] = [
{ path: '/groupCourse/recommend', name: 'GroupCourseRecommend', meta: { title: '推荐管理', icon: 'Star', permission: 'business:groupCourseRecommend:view' } },
],
},
{
path: '/banner',
name: 'Banner',
meta: { title: '轮播图', icon: 'Picture' },
},
{
path: '/checkIn',
name: 'CheckIn',
@@ -49,6 +54,11 @@ const allMenus: MenuItem[] = [
name: 'DataCount',
meta: { title: '数据报表', icon: 'DataAnalysis', permission: 'business:dataCount:view' },
},
{
path: '/payment/revenue',
name: 'PaymentRevenue',
meta: { title: '营收详情', icon: 'Money', permission: 'business:payment:view' },
},
{
path: '/operationLog',
name: 'OperationLog',
+496
View File
@@ -0,0 +1,496 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import {
getAllBanners,
createBanner,
updateBanner,
deleteBanner,
enableBanner,
disableBanner,
} from '@/api/modules/banner'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Picture, Delete } from '@element-plus/icons-vue'
// ---- Types ----
interface BannerItem {
id: number
imageUrl: string
title: string
subtitle: string
description: string
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
}
// ---- State ----
const loading = ref(false)
const list = ref<BannerItem[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const saving = ref(false)
// 图片上传
const uploading = ref(false)
const form = reactive({
imageUrl: '',
title: '',
subtitle: '',
description: '',
sortOrder: 0,
isActive: true,
adminPassword: '',
})
// ---- Preview ----
const previewVisible = ref(false)
const previewBanner = ref<BannerItem | null>(null)
// ---- Computed ----
const previewStyle = computed(() => {
if (!previewBanner.value) return {}
return {
backgroundImage: `url(${previewBanner.value.imageUrl})`,
}
})
// ---- Methods ----
async function fetchData() {
loading.value = true
try {
const res = await getAllBanners({ sortBy: 'sortOrder', sortOrder: 'desc' })
list.value = Array.isArray(res) ? res : (res?.data || [])
} catch {
list.value = []
} finally {
loading.value = false
}
}
async function handleDelete(row: BannerItem) {
try {
await ElMessageBox.confirm('确定删除该轮播图?', '提示', { type: 'warning' })
} catch {
return
}
try {
const { value: pwd } = await ElMessageBox.prompt('请输入管理员密码以确认删除', '验证密码', {
inputType: 'password',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
if (!pwd) {
ElMessage.warning('请输入验证密码')
return
}
await deleteBanner(row.id, { params: { adminPassword: pwd } })
ElMessage.success('删除成功')
fetchData()
} catch {
ElMessage.warning('操作已取消')
}
}
function openCreate() {
isEdit.value = false
editId.value = null
Object.assign(form, {
imageUrl: '',
title: '',
subtitle: '',
description: '',
sortOrder: 0,
isActive: true,
adminPassword: '',
})
dialogVisible.value = true
}
function openEdit(row: BannerItem) {
isEdit.value = true
editId.value = row.id
Object.assign(form, {
imageUrl: row.imageUrl,
title: row.title || '',
subtitle: row.subtitle || '',
description: row.description || '',
sortOrder: row.sortOrder,
isActive: row.isActive,
adminPassword: '',
})
dialogVisible.value = true
}
async function handleSave() {
if (!form.imageUrl.trim()) {
ElMessage.warning('请上传背景图')
return
}
if (!form.title.trim()) {
ElMessage.warning('请输入主标题')
return
}
saving.value = true
try {
const { adminPassword, ...payload } = form
if (isEdit.value) {
if (!adminPassword) {
ElMessage.warning('请输入验证密码')
saving.value = false
return
}
await updateBanner(editId.value!, payload, { params: { adminPassword } })
ElMessage.success('更新成功')
} else {
await createBanner(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch {
// handled by interceptor
} finally {
saving.value = false
}
}
async function toggleStatus(row: BannerItem) {
try {
if (row.isActive) {
const { value: pwd } = await ElMessageBox.prompt('请输入管理员密码以确认禁用', '验证密码', {
inputType: 'password',
confirmButtonText: '确认禁用',
cancelButtonText: '取消',
})
if (!pwd) {
ElMessage.warning('请输入验证密码')
return
}
await disableBanner(row.id, { params: { adminPassword: pwd } })
ElMessage.success('已禁用')
} else {
await enableBanner(row.id)
ElMessage.success('已启用')
}
fetchData()
} catch {
ElMessage.warning('操作已取消')
}
}
function openPreview(row: BannerItem) {
previewBanner.value = row
previewVisible.value = true
}
// 模拟上传(实际接入后端 OSS 上传)
async function handleUpload() {
// 创建一个临时 file input
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async (e: any) => {
const file = e.target?.files?.[0]
if (!file) return
uploading.value = true
try {
// 临时方案:使用 FileReader 转 base64 预览(正式环境需替换为 OSS 上传)
const reader = new FileReader()
reader.onload = (ev) => {
form.imageUrl = ev.target?.result as string
ElMessage.success('图片已加载(正式环境请接入 OSS 上传)')
}
reader.readAsDataURL(file)
} catch {
ElMessage.error('图片加载失败')
} finally {
uploading.value = false
}
}
input.click()
}
function removeImage() {
form.imageUrl = ''
}
onMounted(() => fetchData())
</script>
<template>
<div class="banner-page">
<h2 style="margin-bottom:16px">轮播图管理</h2>
<el-card class="filter-card" shadow="never">
<div class="filter-row">
<span style="color:#909399;font-size:13px"> {{ list.length }} 条轮播图</span>
<el-button type="primary" @click="openCreate">新增轮播图</el-button>
</div>
</el-card>
<!-- Table -->
<el-card shadow="never">
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="背景图" width="120" align="center">
<template #default="{ row }">
<el-image
v-if="row.imageUrl"
:src="row.imageUrl"
:preview-src-list="[row.imageUrl]"
fit="cover"
style="width:80px;height:50px;border-radius:6px"
/>
<span v-else style="color:#C0C4CC"></span>
</template>
</el-table-column>
<el-table-column prop="title" label="主标题" width="140" show-overflow-tooltip />
<el-table-column prop="subtitle" label="副标题" width="140" show-overflow-tooltip />
<el-table-column prop="description" label="简介" min-width="200" show-overflow-tooltip />
<el-table-column label="排序" width="80" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.sortOrder }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'info'" size="small">
{{ row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openPreview(row)">
<el-icon><Picture /></el-icon> 预览
</el-button>
<el-button type="primary" link @click="openEdit(row)">编辑</el-button>
<el-button
:type="row.isActive ? 'warning' : 'success'"
link
@click="toggleStatus(row)"
>
{{ row.isActive ? '禁用' : '启用' }}
</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="list.length === 0 && !loading" class="empty-hint">
暂无轮播图数据点击"新增轮播图"开始创建
</div>
</el-card>
<!-- Create / Edit Dialog -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑轮播图' : '新增轮播图'"
width="600px"
:close-on-click-modal="false"
@closed="form.adminPassword = ''"
>
<el-form :model="form" label-width="100px">
<!-- 背景图上传 -->
<el-form-item label="背景图" required>
<div class="image-upload">
<div v-if="form.imageUrl" class="image-upload__preview">
<el-image :src="form.imageUrl" fit="cover" style="width:240px;height:135px;border-radius:8px" />
<div class="image-upload__overlay">
<el-button type="danger" :icon="Delete" circle size="small" @click="removeImage" />
</div>
</div>
<div v-else class="image-upload__trigger" @click="handleUpload">
<el-icon :size="32" color="#C0C4CC"><Picture /></el-icon>
<span style="color:#909399;font-size:12px;margin-top:4px">点击上传</span>
</div>
<div v-if="uploading" style="margin-left:12px">
<span style="color:#909399;font-size:12px">加载中...</span>
</div>
</div>
</el-form-item>
<el-form-item label="主标题" required>
<el-input v-model="form.title" placeholder="如:突破自我" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="form.subtitle" placeholder="如:超越极限" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="简介">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="如:科学训练 · 遇见更好的自己"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
<span style="margin-left:8px;color:#909399;font-size:12px">数值越大越靠前</span>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.isActive" />
</el-form-item>
<el-form-item v-if="isEdit" label="验证密码" required>
<el-input v-model="form.adminPassword" type="password" placeholder="请输入验证密码" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">确定</el-button>
</template>
</el-dialog>
<!-- Preview Dialog -->
<el-dialog
v-model="previewVisible"
title="轮播图预览"
width="480px"
:close-on-click-modal="true"
>
<div v-if="previewBanner" class="banner-preview">
<div class="banner-preview__card" :style="previewStyle">
<div class="banner-preview__overlay"></div>
<div class="banner-preview__text">
<span class="banner-preview__title">{{ previewBanner.title }}</span>
<span class="banner-preview__subtitle">{{ previewBanner.subtitle }}</span>
<span class="banner-preview__desc">{{ previewBanner.description }}</span>
</div>
</div>
<div class="banner-preview__info">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="主标题">{{ previewBanner.title }}</el-descriptions-item>
<el-descriptions-item label="副标题">{{ previewBanner.subtitle }}</el-descriptions-item>
<el-descriptions-item label="简介">{{ previewBanner.description }}</el-descriptions-item>
<el-descriptions-item label="排序">{{ previewBanner.sortOrder }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="previewBanner.isActive ? 'success' : 'info'" size="small">
{{ previewBanner.isActive ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.banner-page {
padding: 0;
}
.filter-card {
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.empty-hint {
color: #909399;
font-size: 13px;
text-align: center;
padding: 40px;
}
/* 图片上传 */
.image-upload {
display: flex;
align-items: center;
gap: 12px;
}
.image-upload__preview {
position: relative;
}
.image-upload__overlay {
position: absolute;
top: -8px;
right: -8px;
}
.image-upload__trigger {
width: 240px;
height: 135px;
border: 2px dashed #dcdfe6;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s;
}
.image-upload__trigger:hover {
border-color: #409eff;
}
/* 预览卡片 */
.banner-preview {
display: flex;
flex-direction: column;
gap: 16px;
}
.banner-preview__card {
width: 100%;
height: 240px;
background-size: cover;
background-position: center;
border-radius: 12px;
position: relative;
overflow: hidden;
}
.banner-preview__overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.25) 100%);
}
.banner-preview__text {
position: absolute;
left: 28px;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
flex-direction: column;
}
.banner-preview__title {
font-size: 28px;
font-weight: 800;
color: #fff;
margin-bottom: 4px;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.banner-preview__subtitle {
font-size: 32px;
font-weight: 800;
color: #e0f0fa;
margin-bottom: 12px;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.banner-preview__desc {
font-size: 15px;
color: rgba(255,255,255,0.85);
text-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
.banner-preview__info {
padding: 0 4px;
}
</style>
+41 -11
View File
@@ -6,12 +6,14 @@ import {
getBookingStatistics,
getSignInStatistics,
} from '@/api/modules/datacount'
import { getRevenueStatistics } from '@/api/modules/payment'
import dayjs from 'dayjs'
interface StatCard {
label: string
value: number | string
color: string
suffix?: string
}
const loading = ref(false)
@@ -20,6 +22,7 @@ const statCards = ref<StatCard[]>([
{ label: '今日新增', value: 0, color: '#52c41a' },
{ label: '今日签到', value: 0, color: '#722ed1' },
{ label: '今日预约', value: 0, color: '#faad14' },
{ label: '今日营业额', value: 0, color: '#e6a23c', suffix: '元' },
])
const memberChartRef = ref()
@@ -92,7 +95,7 @@ async function fetchData() {
}).catch(() => {}),
getBookingStatistics(range).then((res: any) => {
bookings[i] = res?.newBookings ?? 0
bookingData[i] = res?.newBookings ?? 0
cancelData[i] = res?.cancelBookings ?? 0
if (i === todayIdx) {
statCards.value[3].value = res?.newBookings ?? 0
@@ -107,6 +110,17 @@ async function fetchData() {
}).catch(() => {}),
)
}
// 营业额统计
promises.push(
getRevenueStatistics().then((res: any) => {
const data = res?.data
if (data) {
statCards.value[4].value = (data.todayIncome ?? 0).toFixed(2)
}
}).catch(() => {}),
)
await Promise.allSettled(promises)
} catch {
// fallback: empty data
@@ -192,19 +206,24 @@ onMounted(() => {
<div v-loading="loading">
<h2 style="margin-bottom: 16px">仪表盘</h2>
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :span="6" v-for="card in statCards" :key="card.label">
<el-card shadow="hover">
<div style="text-align: center">
<div style="font-size: 28px; font-weight: bold" :style="{ color: card.color }">
<!-- 核心指标卡片 -->
<div class="stat-cards" style="margin-bottom: 16px">
<el-card v-for="card in statCards" :key="card.label" shadow="hover" class="stat-card">
<div style="text-align: center">
<div style="font-size: 24px; font-weight: bold" :style="{ color: card.color }">
<template v-if="card.suffix">
¥{{ card.value }}{{ card.suffix }}
</template>
<template v-else>
{{ typeof card.value === 'number' ? card.value.toLocaleString() : card.value }}
</div>
<div style="color: var(--text-secondary); margin-top: 8px">{{ card.label }}</div>
</template>
</div>
</el-card>
</el-col>
</el-row>
<div style="color: var(--text-secondary); margin-top: 8px; font-size: 13px">{{ card.label }}</div>
</div>
</el-card>
</div>
<!-- 图表 -->
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :span="12">
<el-card shadow="hover">
@@ -227,3 +246,14 @@ onMounted(() => {
</el-row>
</div>
</template>
<style scoped>
.stat-cards {
display: flex;
gap: 16px;
}
.stat-card {
flex: 1;
min-width: 0;
}
</style>
+173 -1
View File
@@ -7,6 +7,7 @@ import {
getSignInStatistics,
getHistoricalStatistics,
} from '@/api/modules/datacount'
import { getPaymentRecords } from '@/api/modules/payment'
import { download } from '@/api/request'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
@@ -27,6 +28,88 @@ const filters = reactive({
periodType: 'DAY',
})
// ===== 支付记录 =====
const paymentLoading = ref(false)
const paymentList = ref<any[]>([])
const paymentTotal = ref(0)
const paymentPage = ref(1)
const paymentPageSize = ref(10)
const paymentFilters = reactive({
memberId: '',
payStatus: '',
tradeType: '',
})
function tradeTypeLabel(type: string) {
const map: Record<string, string> = { ALIPAY: '支付宝', WECHAT: '微信', QRCODE: '扫码支付' }
return map[type] || type || '-'
}
function payStatusLabel(status: string) {
const map: Record<string, string> = { PENDING: '待支付', SUCCESS: '成功', FAIL: '失败', CLOSED: '已关闭' }
return map[status] || status || '-'
}
function payStatusTagType(status: string) {
const map: Record<string, string> = { PENDING: 'warning', SUCCESS: 'success', FAIL: 'danger', CLOSED: 'info' }
return map[status] || 'info'
}
function orderTypeLabel(type: string) {
const map: Record<string, string> = {
MEMBER_CARD: '会员卡',
GROUP_COURSE: '团课',
GOODS: '商品',
STORED_CARD_RECHARGE: '储值卡充值',
}
return map[type] || type || '-'
}
async function fetchPaymentData() {
paymentLoading.value = true
try {
const params: any = { page: paymentPage.value, pageSize: paymentPageSize.value }
if (paymentFilters.memberId) params.memberId = paymentFilters.memberId
if (paymentFilters.payStatus) params.payStatus = paymentFilters.payStatus
if (paymentFilters.tradeType) params.tradeType = paymentFilters.tradeType
const res: any = await getPaymentRecords(params)
if (res?.data) {
paymentList.value = res.data.records || []
paymentTotal.value = res.data.total || 0
}
} catch {
paymentList.value = []
paymentTotal.value = 0
} finally {
paymentLoading.value = false
}
}
function handlePaymentSearch() {
paymentPage.value = 1
fetchPaymentData()
}
function handlePaymentReset() {
paymentFilters.memberId = ''
paymentFilters.payStatus = ''
paymentFilters.tradeType = ''
paymentPage.value = 1
fetchPaymentData()
}
function handlePaymentPageChange(page: number) {
paymentPage.value = page
fetchPaymentData()
}
function handlePaymentSizeChange(size: number) {
paymentPageSize.value = size
paymentPage.value = 1
fetchPaymentData()
}
// ===== 数据报表 =====
async function fetchData() {
loading.value = true
try {
@@ -124,6 +207,7 @@ function dateLabel(d: string | null) {
onMounted(() => {
fetchData()
fetchPaymentData()
})
</script>
@@ -256,7 +340,7 @@ onMounted(() => {
</el-row>
<!-- Historical data table -->
<el-card shadow="never">
<el-card shadow="never" style="margin-bottom: 16px">
<template #header><strong>历史统计</strong></template>
<el-table :data="historyList" v-loading="loading" stripe>
<el-table-column label="统计日期" width="170" align="center">
@@ -281,6 +365,88 @@ onMounted(() => {
暂无历史统计数据
</div>
</el-card>
<!-- 支付记录 -->
<el-card shadow="never">
<template #header><strong>支付记录</strong></template>
<el-form :model="paymentFilters" inline style="margin-bottom: 12px">
<el-form-item label="用户编号">
<el-input v-model="paymentFilters.memberId" placeholder="会员ID" clearable style="width: 140px" size="small" />
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="paymentFilters.payStatus" placeholder="全部" clearable style="width: 110px" size="small">
<el-option label="成功" value="SUCCESS" />
<el-option label="待支付" value="PENDING" />
<el-option label="失败" value="FAIL" />
<el-option label="已关闭" value="CLOSED" />
</el-select>
</el-form-item>
<el-form-item label="支付方式">
<el-select v-model="paymentFilters.tradeType" placeholder="全部" clearable style="width: 110px" size="small">
<el-option label="支付宝" value="ALIPAY" />
<el-option label="微信" value="WECHAT" />
<el-option label="扫码支付" value="QRCODE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="handlePaymentSearch">查询</el-button>
<el-button size="small" @click="handlePaymentReset">重置</el-button>
</el-form-item>
</el-form>
<el-table :data="paymentList" v-loading="paymentLoading" stripe size="small">
<el-table-column prop="memberId" label="用户编号" width="90" align="center" />
<el-table-column label="支付方式" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ tradeTypeLabel(row.tradeType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
{{ orderTypeLabel(row.orderType) }}
</template>
</el-table-column>
<el-table-column prop="goodsDesc" label="购买内容" min-width="150" show-overflow-tooltip />
<el-table-column label="金额" width="100" align="center">
<template #default="{ row }">
<span style="color: #1890ff; font-weight: 600">
¥{{ row.transAmt != null ? row.transAmt.toFixed(2) : '0.00' }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="payStatusTagType(row.payStatus)" size="small">
{{ payStatusLabel(row.payStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="支付日期" width="160">
<template #default="{ row }">
{{ row.payTime || row.createdAt || '-' }}
</template>
</el-table-column>
<el-table-column prop="orderNo" label="订单号" width="180" show-overflow-tooltip />
</el-table>
<div v-if="paymentList.length === 0 && !paymentLoading" class="empty-tip">
暂无支付记录
</div>
<div v-if="paymentTotal > 0" style="margin-top: 12px; text-align: right">
<el-pagination
v-model:current-page="paymentPage"
v-model:page-size="paymentPageSize"
:total="paymentTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
size="small"
@current-change="handlePaymentPageChange"
@size-change="handlePaymentSizeChange"
/>
</div>
</el-card>
</div>
</template>
@@ -288,4 +454,10 @@ onMounted(() => {
.search-card {
margin-bottom: 16px;
}
.empty-tip {
color: #909399;
font-size: 13px;
text-align: center;
padding: 40px;
}
</style>
+98 -23
View File
@@ -9,6 +9,7 @@ import {
restoreGroupCourse,
uploadImage,
getPresignedUrl,
getGroupCourseQRCode,
} from '@/api/modules/groupCourse'
import { getAllGroupCourseTypes } from '@/api/modules/groupCourseType'
import { formatDate } from '@/utils/format'
@@ -27,6 +28,7 @@ interface CourseItem {
coverImage: string | null
description: string
storedValueAmount: number
isRecurring: boolean | null
createdAt: string | null
updatedAt: string | null
deletedAt: string | null
@@ -72,6 +74,12 @@ const saving = ref(false)
const adminPassword = ref('')
const pendingCoverFile = ref<File | null>(null)
const coverPreviewUrl = ref('')
const recurringEnabled = ref(false)
// ===== 二维码弹窗 =====
const qrVisible = ref(false)
const qrLoading = ref(false)
const qrData = ref<{ base64: string; courseId: number; courseName: string } | null>(null)
const form = reactive({
id: 0,
@@ -79,9 +87,8 @@ const form = reactive({
coachId: null as number | null,
courseType: null as number | null,
startTime: '',
endTime: '',
durationMinutes: 60,
maxMembers: 20,
currentMembers: 0,
status: 0,
location: '',
coverImage: '',
@@ -301,15 +308,15 @@ function openCreate() {
adminPassword.value = ''
pendingCoverFile.value = null
coverPreviewUrl.value = ''
recurringEnabled.value = false
Object.assign(form, {
id: 0,
courseName: '',
coachId: null,
courseType: null,
startTime: '',
endTime: '',
durationMinutes: 60,
maxMembers: 20,
currentMembers: 0,
status: 0,
location: '',
coverImage: '',
@@ -329,14 +336,20 @@ function openEdit(row: CourseItem) {
form.coachId = row.coachId ?? null
form.courseType = toNum(row.courseType)
form.maxMembers = row.maxMembers ?? 20
form.currentMembers = row.currentMembers ?? 0
form.status = toNum(row.status)
form.location = row.location || ''
form.coverImage = row.coverImage || ''
form.description = row.description || ''
form.storedValueAmount = row.storedValueAmount ?? 0
form.startTime = row.startTime ? formatDate(row.startTime, 'YYYY-MM-DD HH:mm:ss') : ''
form.endTime = row.endTime ? formatDate(row.endTime, 'YYYY-MM-DD HH:mm:ss') : ''
// 根据 startTime/endTime 反算课程时长
if (row.startTime && row.endTime) {
const diff = new Date(row.endTime).getTime() - new Date(row.startTime).getTime()
form.durationMinutes = Math.max(1, Math.round(diff / 60000))
} else {
form.durationMinutes = 60
}
recurringEnabled.value = row.isRecurring === true
dialogVisible.value = true
// 如果有已有封面,获取预签名URL用于预览
if (form.coverImage) {
@@ -365,7 +378,7 @@ function handleImgError(e: Event) {
}
function handleDialogClosed() {
Object.assign(form, { startTime: '', endTime: '' })
Object.assign(form, { startTime: '', durationMinutes: 60 })
adminPassword.value = ''
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value)
@@ -379,8 +392,15 @@ async function handleSave() {
try {
const body: any = { ...form }
delete body.id
if (body.startTime) body.startTime = new Date(body.startTime).toISOString()
if (body.endTime) body.endTime = new Date(body.endTime).toISOString()
delete body.durationMinutes
body.isRecurring = recurringEnabled.value
if (body.startTime) {
const start = new Date(body.startTime)
body.startTime = start.toISOString()
// 根据上课时间 + 课程时长计算下课时间
const end = new Date(start.getTime() + (form.durationMinutes || 60) * 60000)
body.endTime = end.toISOString()
}
if (isEdit.value) {
if (!adminPassword.value) {
@@ -461,6 +481,32 @@ async function handleRestore(row: CourseItem) {
}
}
// ===== 二维码 =====
async function showQRCode(row: CourseItem) {
qrVisible.value = true
qrLoading.value = true
qrData.value = null
try {
const res: any = await getGroupCourseQRCode(row.id)
if (res) {
qrData.value = res
}
} catch {
ElMessage.error('获取二维码失败')
qrVisible.value = false
} finally {
qrLoading.value = false
}
}
function downloadQRCode() {
if (!qrData.value?.base64) return
const a = document.createElement('a')
a.href = qrData.value.base64
a.download = `签到二维码_${qrData.value.courseName}_${qrData.value.courseId}.png`
a.click()
}
onMounted(() => {
fetchTypes()
loadData()
@@ -624,15 +670,23 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="常态化" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.isRecurring" type="success" size="small">是</el-tag>
<span v-else style="color: #c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">
{{ row.createdAt ? formatDate(row.createdAt, 'YYYY-MM-DD HH:mm') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openEdit(row)" v-permission="'business:groupCourse:edit'">编辑</el-button>
<el-button type="warning" link @click="showQRCode(row)">二维码</el-button>
<el-button
v-if="isDeleted(row)"
type="success"
@@ -691,14 +745,9 @@ onMounted(() => {
style="width: 100%"
/>
</el-form-item>
<el-form-item label="下课时间">
<el-date-picker
v-model="form.endTime"
type="datetime"
placeholder="选择下课时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
<el-form-item label="课程时长">
<el-input-number v-model="form.durationMinutes" :min="1" :max="480" style="width: 160px" />
<span style="margin-left: 8px; color: var(--text-secondary)">分钟</span>
</el-form-item>
<el-form-item label="上课地点">
<el-input v-model="form.location" placeholder="请输入上课地点" maxlength="100" />
@@ -709,11 +758,6 @@ onMounted(() => {
<el-input-number v-model="form.maxMembers" :min="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="已报名人数">
<el-input-number v-model="form.currentMembers" :min="0" :max="form.maxMembers" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
@@ -721,6 +765,9 @@ onMounted(() => {
<el-option label="已取消" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="常态化团课">
<el-switch v-model="recurringEnabled" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="课程描述">
<el-input
v-model="form.description"
@@ -769,5 +816,33 @@ onMounted(() => {
<el-button type="primary" :loading="saving" @click="handleSave">确定</el-button>
</template>
</el-dialog>
<!-- QR Code Dialog -->
<el-dialog
v-model="qrVisible"
title="签到二维码"
width="400px"
:close-on-click-modal="false"
>
<div v-loading="qrLoading" style="text-align: center; padding: 16px">
<template v-if="qrData">
<img
:src="qrData.base64"
alt="签到二维码"
style="width: 260px; height: 260px; border: 1px solid #dcdfe6; border-radius: 8px"
/>
<div style="margin-top: 12px; color: var(--text-secondary); font-size: 13px">
{{ qrData.courseName }}
</div>
</template>
<div v-else-if="!qrLoading" style="color: #909399; padding: 40px 0">
暂无数据
</div>
</div>
<template #footer>
<el-button @click="qrVisible = false">关闭</el-button>
<el-button type="primary" :disabled="!qrData" @click="downloadQRCode">下载保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -7,6 +7,7 @@ import {
updateMemberCard,
deleteMemberCard,
} from '@/api/modules/memberCard'
import { getConfigByKey, updateConfig } from '@/api/modules/config'
import { formatDate } from '@/utils/format'
interface CardItem {
@@ -24,6 +25,49 @@ interface CardItem {
updatedAt: string | null
}
// ---- 团课免费取消次数配置 ----
const cancelFreeLimit = ref<number>(3)
const cancelFreeLimitConfigId = ref<number | null>(null)
const cancelFreeLimitLoading = ref(false)
const cancelFreeLimitSaving = ref(false)
async function fetchCancelFreeLimit() {
cancelFreeLimitLoading.value = true
try {
const res: any = await getConfigByKey('group_course.cancel_free_limit')
if (res) {
cancelFreeLimitConfigId.value = res.id || res.configId || null
cancelFreeLimit.value = parseInt(res.configValue || res.value) || 3
}
} catch {
// ignore, use default
} finally {
cancelFreeLimitLoading.value = false
}
}
async function saveCancelFreeLimit() {
if (!cancelFreeLimitConfigId.value) {
ElMessage.error('配置记录不存在,无法保存')
return
}
cancelFreeLimitSaving.value = true
try {
await updateConfig(cancelFreeLimitConfigId.value, {
configName: '团课取消预约免手续费次数',
configKey: 'group_course.cancel_free_limit',
configValue: String(cancelFreeLimit.value),
configType: 'Y',
})
ElMessage.success(`免费取消次数已更新为 ${cancelFreeLimit.value} 次。所有会员将立即应用新规则,后台实时生效。`)
await fetchCancelFreeLimit()
} catch {
ElMessage.error('保存失败')
} finally {
cancelFreeLimitSaving.value = false
}
}
// ---- State ----
const loading = ref(false)
const list = ref<CardItem[]>([])
@@ -217,6 +261,7 @@ async function doDelete(row: CardItem) {
onMounted(() => {
fetchData()
fetchCancelFreeLimit()
})
</script>
@@ -352,6 +397,38 @@ onMounted(() => {
</div>
</el-card>
<!-- 团课免费取消次数配置 -->
<el-card v-loading="cancelFreeLimitLoading" style="margin-top: 16px">
<template #header>
<div style="display: flex; align-items: center; gap: 8px">
<el-icon :size="18"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg></el-icon>
<span style="font-weight: 600">团课取消预约免手续费次数</span>
<el-tag size="small" type="warning">全局配置</el-tag>
</div>
</template>
<div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap">
<span style="color: var(--text-secondary); font-size: 14px">会员前</span>
<el-input-number
v-model="cancelFreeLimit"
:min="0"
:max="99"
controls-position="right"
style="width: 120px"
/>
<span style="color: var(--text-secondary); font-size: 14px">次取消预约免收手续费,超出后扣除 10% 手续费</span>
<el-button
type="primary"
:loading="cancelFreeLimitSaving"
@click="saveCancelFreeLimit()"
>
保存
</el-button>
</div>
<div style="margin-top: 8px; color: var(--text-muted, #909399); font-size: 12px">
当前生效值:{{ cancelFreeLimit }} 次。修改后实时生效,对所有会员立即应用新规则。
</div>
</el-card>
<!-- Create Dialog -->
<el-dialog v-model="createVisible" title="新增会员卡" width="520px">
<el-form :model="createForm" label-width="110px">
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getRevenueStatistics } from '@/api/modules/payment'
interface RevenueData {
todayIncome: number
todayRefund: number
todayOrderCount: number
monthIncome: number
monthRefund: number
monthOrderCount: number
yearIncome: number
yearRefund: number
yearOrderCount: number
}
const loading = ref(false)
const revenue = ref<RevenueData>({
todayIncome: 0, todayRefund: 0, todayOrderCount: 0,
monthIncome: 0, monthRefund: 0, monthOrderCount: 0,
yearIncome: 0, yearRefund: 0, yearOrderCount: 0,
})
async function fetchData() {
loading.value = true
try {
const res: any = await getRevenueStatistics()
if (res?.data) {
revenue.value = res.data
}
} catch {
// fallback
} finally {
loading.value = false
}
}
function fmt(v: number) {
return v != null ? v.toFixed(2) : '0.00'
}
onMounted(() => { fetchData() })
</script>
<template>
<div v-loading="loading">
<h2 style="margin-bottom: 16px">营收详情</h2>
<!-- 今日 -->
<el-card class="section-card" shadow="never">
<template #header>
<div class="section-header">
<span class="section-title">今日</span>
<el-tag size="small" type="primary">本日</el-tag>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value income">¥{{ fmt(revenue.todayIncome) }}</div>
<div class="mini-label">收入</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value refund">¥{{ fmt(revenue.todayRefund) }}</div>
<div class="mini-label">退款</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #303133">{{ revenue.todayOrderCount }}</div>
<div class="mini-label">订单数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #52c41a">¥{{ fmt(revenue.todayIncome - revenue.todayRefund) }}</div>
<div class="mini-label">净收入</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 当月 -->
<el-card class="section-card" shadow="never">
<template #header>
<div class="section-header">
<span class="section-title">当月</span>
<el-tag size="small" type="success">本月</el-tag>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value income">¥{{ fmt(revenue.monthIncome) }}</div>
<div class="mini-label">收入</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value refund">¥{{ fmt(revenue.monthRefund) }}</div>
<div class="mini-label">退款</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #303133">{{ revenue.monthOrderCount }}</div>
<div class="mini-label">订单数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #52c41a">¥{{ fmt(revenue.monthIncome - revenue.monthRefund) }}</div>
<div class="mini-label">净收入</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 当年 -->
<el-card class="section-card" shadow="never">
<template #header>
<div class="section-header">
<span class="section-title">当年</span>
<el-tag size="small" type="warning">本年</el-tag>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value income">¥{{ fmt(revenue.yearIncome) }}</div>
<div class="mini-label">收入</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value refund">¥{{ fmt(revenue.yearRefund) }}</div>
<div class="mini-label">退款</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #303133">{{ revenue.yearOrderCount }}</div>
<div class="mini-label">订单数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #52c41a">¥{{ fmt(revenue.yearIncome - revenue.yearRefund) }}</div>
<div class="mini-label">净收入</div>
</div>
</el-col>
</el-row>
</el-card>
</div>
</template>
<style scoped>
.section-card {
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 16px;
font-weight: 600;
}
.mini-stat {
text-align: center;
padding: 16px 8px;
border-radius: 8px;
background: #fafafa;
}
.mini-value {
font-size: 22px;
font-weight: 700;
}
.mini-value.income {
color: #1890ff;
}
.mini-value.refund {
color: #ff4d4f;
}
.mini-label {
color: var(--text-secondary, #909399);
font-size: 13px;
margin-top: 4px;
}
</style>