更新多个功能
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user