This commit is contained in:
zpc 2026-02-20 17:51:39 +08:00
parent 9f270c3741
commit c8b7cff7e9
6 changed files with 643 additions and 57 deletions

View File

@ -0,0 +1,102 @@
/**
* BusinessPage API - API
* @module api/business/businessPage
* @description
*/
import { request, type ApiResponse } from '@/utils/request'
import type { PagedRequest, PagedResult, UpdateStatusRequest } from '@/types/common'
// ==================== 类型定义 ====================
/** 业务介绍页项 */
export interface BusinessPageItem {
id: number
title: string
imageUrl: string
hasActionButton: boolean
actionButtonText?: string
actionButtonLink?: string
sort: number
status: number
statusName: string
createTime: string
}
/** 查询参数 */
export interface BusinessPageQuery extends PagedRequest {
title?: string
status?: number
}
/** 创建请求 */
export interface CreateBusinessPageRequest {
title: string
imageUrl: string
hasActionButton: boolean
actionButtonText?: string
actionButtonLink?: string
sort?: number
status?: number
}
/** 更新请求 */
export interface UpdateBusinessPageRequest extends CreateBusinessPageRequest {
id: number
}
// ==================== API ====================
/** 获取业务介绍页列表 */
export function getBusinessPageList(params: BusinessPageQuery): Promise<ApiResponse<PagedResult<BusinessPageItem>>> {
return request<PagedResult<BusinessPageItem>>({
url: '/admin/businessPage/getList',
method: 'get',
params
})
}
/** 创建业务介绍页 */
export function createBusinessPage(data: CreateBusinessPageRequest): Promise<ApiResponse<number>> {
return request<number>({
url: '/admin/businessPage/create',
method: 'post',
data
})
}
/** 更新业务介绍页 */
export function updateBusinessPage(data: UpdateBusinessPageRequest): Promise<ApiResponse<boolean>> {
return request<boolean>({
url: '/admin/businessPage/update',
method: 'post',
data
})
}
/** 删除业务介绍页 */
export function deleteBusinessPage(id: number): Promise<ApiResponse<boolean>> {
return request<boolean>({
url: '/admin/businessPage/delete',
method: 'post',
data: { id }
})
}
/** 更新业务介绍页状态 */
export function updateBusinessPageStatus(data: UpdateStatusRequest): Promise<ApiResponse<boolean>> {
return request<boolean>({
url: '/admin/businessPage/updateStatus',
method: 'post',
data
})
}
/** 更新业务介绍页排序 */
export function updateBusinessPageSort(data: { id: number; sort: number }): Promise<ApiResponse<boolean>> {
return request<boolean>({
url: '/admin/businessPage/updateSort',
method: 'post',
data
})
}

View File

@ -67,6 +67,12 @@ export const businessRoutes: RouteRecordRaw[] = [
name: 'Promotion',
component: () => import('@/views/business/content/promotion/index.vue'),
meta: { title: '宣传图管理', permission: 'promotion:view', keepAlive: true }
},
{
path: 'business-page',
name: 'BusinessPage',
component: () => import('@/views/business/content/business-page/index.vue'),
meta: { title: '业务介绍页', permission: 'businessPage:view', keepAlive: true }
}
]
},

View File

@ -0,0 +1,430 @@
<template>
<div class="business-page-container">
<!-- 页面标题和操作栏 -->
<el-card class="page-header">
<div class="header-content">
<div class="header-left">
<h2 class="page-title">业务介绍页管理</h2>
<span class="page-description">管理小程序业务详情页内容支持配置长图和操作按钮</span>
</div>
<div class="header-right">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增介绍页
</el-button>
</div>
</div>
</el-card>
<!-- 搜索表单 -->
<el-card class="search-card">
<el-form :model="queryParams" inline>
<el-form-item label="标题">
<el-input
v-model="queryParams.title"
placeholder="请输入标题"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<DictSelect
v-model="queryParams.status"
type="common_status"
placeholder="请选择状态"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card v-loading="state.loading" class="table-card">
<el-table :data="state.tableData" row-key="id" stripe>
<!-- 图片预览 -->
<el-table-column label="图片" width="120" align="center">
<template #default="{ row }">
<el-image
:src="row.imageUrl"
:preview-src-list="[row.imageUrl]"
fit="cover"
style="width: 80px; height: 60px; border-radius: 4px;"
preview-teleported
>
<template #error>
<div class="image-error"><el-icon><Picture /></el-icon></div>
</template>
</el-image>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<!-- 操作按钮 -->
<el-table-column label="操作按钮" width="200" align="center">
<template #default="{ row }">
<template v-if="row.hasActionButton">
<el-tag type="success" size="small">{{ row.actionButtonText || '按钮' }}</el-tag>
<div style="font-size: 12px; color: #999; margin-top: 4px;">{{ row.actionButtonLink }}</div>
</template>
<el-tag v-else type="info" size="small">无按钮</el-tag>
</template>
</el-table-column>
<!-- 状态 -->
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
:loading="row._statusLoading"
@change="(val: number) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="createTime" label="创建时间" width="180" align="center" />
<!-- 操作 -->
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
</el-button>
<el-popconfirm
title="确定要删除这条业务介绍页吗?"
confirm-button-text="确定"
cancel-button-text="取消"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button type="danger" link size="small">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="queryParams.pageIndex"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="state.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="state.dialogVisible"
:title="state.dialogTitle"
width="600px"
:close-on-click-modal="false"
@closed="handleDialogClosed"
>
<el-form
ref="formRef"
:model="state.formData"
:rules="formRules"
label-width="110px"
label-position="right"
>
<el-form-item label="标题" prop="title">
<el-input v-model="state.formData.title" placeholder="请输入标题" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="介绍图片" prop="imageUrl" required>
<ImageUpload
v-model="state.formData.imageUrl"
placeholder="点击上传介绍长图"
tip="建议宽度750px支持 jpg、png 格式"
:max-size="10"
/>
</el-form-item>
<el-form-item label="操作按钮" prop="hasActionButton">
<el-switch v-model="state.formData.hasActionButton" />
</el-form-item>
<template v-if="state.formData.hasActionButton">
<el-form-item label="按钮文字" prop="actionButtonText">
<el-input v-model="state.formData.actionButtonText" placeholder="如:立即参与" maxlength="50" />
</el-form-item>
<el-form-item label="按钮链接" prop="actionButtonLink">
<el-input v-model="state.formData.actionButtonLink" placeholder="如:/pages/assessment/info/index" maxlength="500" />
</el-form-item>
</template>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="state.formData.sort" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status" required>
<DictSelect v-model="state.formData.status" type="common_status" placeholder="请选择状态" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="state.dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="state.formLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
/**
* 业务介绍页管理
* @description 管理小程序业务详情页内容
*/
import { reactive, ref, onMounted } from 'vue'
import { Plus, Search, Refresh, Edit, Delete, Picture } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
getBusinessPageList,
createBusinessPage,
updateBusinessPage,
deleteBusinessPage,
updateBusinessPageStatus,
type BusinessPageItem,
type BusinessPageQuery,
type CreateBusinessPageRequest,
type UpdateBusinessPageRequest
} from '@/api/business/businessPage'
import { DictSelect, ImageUpload } from '@/components'
// ============ Types ============
interface FormData {
id?: number
title: string
imageUrl: string
hasActionButton: boolean
actionButtonText: string
actionButtonLink: string
sort: number
status: string
}
interface PageState {
loading: boolean
tableData: (BusinessPageItem & { _statusLoading?: boolean })[]
total: number
dialogVisible: boolean
dialogTitle: string
formData: FormData
formLoading: boolean
isEdit: boolean
}
// ============ Refs ============
const formRef = ref<FormInstance>()
// ============ State ============
const queryParams = reactive({
pageIndex: 1,
pageSize: 10,
title: '',
status: undefined as string | undefined
})
const state = reactive<PageState>({
loading: false,
tableData: [],
total: 0,
dialogVisible: false,
dialogTitle: '新增介绍页',
formData: getDefaultFormData(),
formLoading: false,
isEdit: false
})
const formRules: FormRules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
imageUrl: [{ required: true, message: '请上传介绍图片', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
// ============ Helpers ============
function getDefaultFormData(): FormData {
return {
title: '',
imageUrl: '',
hasActionButton: false,
actionButtonText: '',
actionButtonLink: '',
sort: 0,
status: '1'
}
}
// ============ API ============
async function loadList() {
state.loading = true
try {
const params: BusinessPageQuery = {
pageIndex: queryParams.pageIndex,
pageSize: queryParams.pageSize
}
if (queryParams.title) params.title = queryParams.title
if (queryParams.status !== undefined && queryParams.status !== '') {
params.status = Number(queryParams.status)
}
const res = await getBusinessPageList(params)
if (res.code === 0) {
state.tableData = res.data?.list || []
state.total = res.data?.total || 0
} else {
throw new Error(res.message || '获取列表失败')
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取列表失败')
} finally {
state.loading = false
}
}
// ============ Handlers ============
function handleSearch() { queryParams.pageIndex = 1; loadList() }
function handleReset() { queryParams.title = ''; queryParams.status = undefined; queryParams.pageIndex = 1; loadList() }
function handleSizeChange(size: number) { queryParams.pageSize = size; queryParams.pageIndex = 1; loadList() }
function handleCurrentChange(page: number) { queryParams.pageIndex = page; loadList() }
function handleAdd() {
state.isEdit = false
state.dialogTitle = '新增介绍页'
state.formData = getDefaultFormData()
state.dialogVisible = true
}
function handleEdit(row: BusinessPageItem) {
state.isEdit = true
state.dialogTitle = '编辑介绍页'
state.formData = {
id: row.id,
title: row.title || '',
imageUrl: row.imageUrl,
hasActionButton: row.hasActionButton,
actionButtonText: row.actionButtonText || '',
actionButtonLink: row.actionButtonLink || '',
sort: row.sort,
status: String(row.status)
}
state.dialogVisible = true
}
async function handleStatusChange(row: BusinessPageItem & { _statusLoading?: boolean }, status: number) {
row._statusLoading = true
try {
const res = await updateBusinessPageStatus({ id: row.id, status })
if (res.code === 0) {
ElMessage.success(status === 1 ? '已启用' : '已禁用')
} else {
row.status = status === 1 ? 0 : 1
throw new Error(res.message || '状态更新失败')
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '状态更新失败')
} finally {
row._statusLoading = false
}
}
async function handleDelete(row: BusinessPageItem) {
try {
const res = await deleteBusinessPage(row.id)
if (res.code === 0) {
ElMessage.success('删除成功')
if (state.tableData.length === 1 && queryParams.pageIndex > 1) queryParams.pageIndex--
await loadList()
} else {
throw new Error(res.message || '删除失败')
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function handleSubmit() {
if (!formRef.value) return
try { await formRef.value.validate() } catch { return }
state.formLoading = true
try {
const fd = state.formData
const data: CreateBusinessPageRequest | UpdateBusinessPageRequest = {
title: fd.title,
imageUrl: fd.imageUrl,
hasActionButton: fd.hasActionButton,
actionButtonText: fd.hasActionButton ? fd.actionButtonText : undefined,
actionButtonLink: fd.hasActionButton ? fd.actionButtonLink : undefined,
sort: fd.sort,
status: Number(fd.status)
}
let res
if (state.isEdit && fd.id) {
res = await updateBusinessPage({ ...data, id: fd.id } as UpdateBusinessPageRequest)
} else {
res = await createBusinessPage(data)
}
if (res.code === 0) {
ElMessage.success(state.isEdit ? '更新成功' : '创建成功')
state.dialogVisible = false
await loadList()
} else {
throw new Error(res.message || (state.isEdit ? '更新失败' : '创建失败'))
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally {
state.formLoading = false
}
}
function handleDialogClosed() {
formRef.value?.resetFields()
state.formData = getDefaultFormData()
}
// ============ Lifecycle ============
onMounted(() => { loadList() })
</script>
<style scoped>
.business-page-container { padding: 0; }
.page-header { margin-bottom: 16px; }
.header-content { display: flex; justify-content: space-between; align-items: center; }
.header-left { display: flex; align-items: baseline; gap: 16px; }
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary, #303133); }
.page-description { font-size: 14px; color: var(--text-secondary, #909399); }
.search-card { margin-bottom: 16px; }
.search-card :deep(.el-card__body) { padding-bottom: 2px; }
.table-card { min-height: 400px; }
.image-error { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; background: var(--el-fill-color-light); color: var(--el-text-color-placeholder); }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
:deep(.el-table th.el-table__cell) { background-color: var(--bg-light, #f5f7fa); font-weight: 500; }
:deep(.el-dialog__body) { padding-top: 20px; }
</style>

View File

@ -10,7 +10,7 @@ import { get } from './request'
* @returns {Promise<Object>}
*/
export function getBusinessDetail(businessId) {
return get('/business/getDetail', { businessId })
return get('/business/getDetail', { id: businessId })
}
export default {

View File

@ -5,7 +5,7 @@
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "首页",
"enablePullDownRefresh": true
"enablePullDownRefresh": false
}
},
{

View File

@ -1,7 +1,7 @@
<template>
<view class="home-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="navbarStyle">
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content" :style="{ height: navbarHeight + 'px' }">
<text class="navbar-title">首页</text>
</view>
@ -11,7 +11,13 @@
<view class="navbar-placeholder" :style="{ height: totalNavbarHeight + 'px' }"></view>
<!-- 页面内容 -->
<view class="page-content">
<scroll-view
class="page-content"
scroll-y
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- Banner 轮播图 -->
<view class="banner-section" v-if="bannerList.length > 0">
<swiper
@ -37,28 +43,39 @@
</swiper>
</view>
<!-- 测评入口列表 -->
<!-- 专业测评入口 -->
<view class="assessment-section" v-if="assessmentList.length > 0">
<view
class="assessment-item"
v-for="(item, index) in assessmentList"
:key="index"
@click="handleAssessmentClick(item)"
>
<image
:src="item.imageUrl"
mode="widthFix"
class="assessment-image"
/>
<!-- 即将上线标签 -->
<view v-if="item.status === 0" class="coming-soon-tag">
<text>即将上线</text>
<view class="section-header">
<view class="section-indicator"></view>
<text class="section-title">专业测评</text>
</view>
<view class="assessment-grid">
<view
class="assessment-card"
v-for="(item, index) in assessmentList"
:key="index"
@click="handleAssessmentClick(item)"
>
<!-- 即将上线标签 -->
<view v-if="item.status === 0" class="coming-soon-tag">
<text>即将上线</text>
</view>
<image
:src="item.imageUrl"
mode="aspectFit"
class="assessment-icon"
/>
<text class="assessment-name">{{ item.name }}</text>
</view>
</view>
</view>
<!-- 底部宣传长图 -->
<!-- 关于我们 - 宣传长图 -->
<view class="promotion-section" v-if="promotionList.length > 0">
<view class="section-header">
<view class="section-indicator"></view>
<text class="section-title">关于我们</text>
</view>
<image
v-for="(item, index) in promotionList"
:key="index"
@ -75,13 +92,12 @@
<!-- 底部安全区域 -->
<view class="safe-bottom"></view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useNavbar } from '@/composables/useNavbar.js'
import { getBannerList, getAssessmentList, getPromotionList } from '@/api/home.js'
@ -92,15 +108,12 @@ const { statusBarHeight, navbarHeight, totalNavbarHeight } = useNavbar()
//
const pageLoading = ref(true)
const isRefreshing = ref(false)
const bannerList = ref([])
const assessmentList = ref([])
const promotionList = ref([])
//
const navbarStyle = computed(() => ({
paddingTop: statusBarHeight.value + 'px',
height: totalNavbarHeight.value + 'px'
}))
//
/**
* 加载Banner数据
@ -211,12 +224,13 @@ function handleAssessmentClick(item) {
}
/**
* 下拉刷新
* scroll-view 下拉刷新
*/
onPullDownRefresh(async () => {
async function onRefresh() {
isRefreshing.value = true
await initPageData()
uni.stopPullDownRefresh()
})
isRefreshing.value = false
}
/**
* 页面加载
@ -260,8 +274,9 @@ onMounted(() => {
width: 100%;
}
//
// scroll-view
.page-content {
height: calc(100vh - var(--navbar-height, 0px));
padding-bottom: env(safe-area-inset-bottom);
}
@ -270,11 +285,12 @@ onMounted(() => {
width: 100%;
padding: 0 $spacing-lg;
box-sizing: border-box;
margin-top: $spacing-md;
margin-top: $spacing-sm;
.banner-swiper {
width: 100%;
height: 320rpx;
// 750:360 2:1 padding 686rpx 330rpx
height: 330rpx;
border-radius: $border-radius-lg;
overflow: hidden;
@ -286,44 +302,76 @@ onMounted(() => {
}
}
//
.assessment-section {
padding: $spacing-lg;
//
.section-header {
display: flex;
align-items: center;
margin-bottom: $spacing-lg;
.assessment-item {
.section-indicator {
width: 8rpx;
height: 36rpx;
background-color: $warning-color;
border-radius: $border-radius-xs;
margin-right: $spacing-sm;
}
.section-title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-color;
}
}
// -
.assessment-section {
padding: $spacing-xl $spacing-lg $spacing-lg;
.assessment-grid {
display: flex;
gap: $spacing-md;
}
.assessment-card {
position: relative;
margin-bottom: $spacing-md;
border-radius: $border-radius-lg;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
.assessment-image {
width: 100%;
display: block;
}
display: flex;
flex-direction: column;
align-items: center;
width: 200rpx;
.coming-soon-tag {
position: absolute;
top: 20rpx;
right: 20rpx;
background-color: rgba(0, 0, 0, 0.5);
padding: 8rpx 20rpx;
border-radius: $border-radius-round;
top: 0;
right: 0;
background-color: $warning-color;
padding: 4rpx 16rpx;
border-radius: 0 $border-radius-xl 0 $border-radius-lg;
z-index: 1;
text {
font-size: $font-size-xs;
color: $text-white;
}
}
.assessment-icon {
width: 200rpx;
height: 200rpx;
border-radius: $border-radius-xl;
}
.assessment-name {
margin-top: $spacing-sm;
font-size: $font-size-md;
font-weight: $font-weight-bold;
color: $text-color;
}
}
}
//
// -
.promotion-section {
padding: 0 $spacing-lg;
padding: $spacing-lg $spacing-lg 0;
.promotion-image {
width: 100%;