perf: 优化页面

This commit is contained in:
richarjiang
2026-04-05 13:25:54 +08:00
parent a85270efd4
commit 9811c9a13b
31 changed files with 3135 additions and 375 deletions

View File

@@ -1,6 +1,7 @@
<template>
<view class="page">
<!-- Add button -->
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="卡种管理" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ cardTypes.length }} 个卡种</text>
<view class="add-btn" @tap="openAdd">
@@ -70,7 +71,7 @@
<view
class="ct-action-btn toggle-btn"
:class="ct.isActive ? 'toggle-off' : 'toggle-on'"
@tap.stop="toggleActive(ct)"
@tap.stop="confirmToggle(ct)"
>
<text class="ct-action-text">{{ ct.isActive ? '下架' : '上架' }}</text>
</view>
@@ -81,123 +82,136 @@
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<scroll-view scroll-y class="modal">
<text class="modal-title">{{ editTarget ? '编辑卡种' : '新增卡种' }}</text>
<view class="modal-field">
<text class="modal-label">卡种名称</text>
<input
class="modal-input"
v-model="form.name"
placeholder="如10次课套餐"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">类型</text>
<picker
mode="selector"
:range="typeOptions"
range-key="label"
:value="form.typeIdx"
@change="(e: any) => form.typeIdx = Number(e.detail.value)"
>
<view class="picker-display">
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
<text class="picker-arrow"></text>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.stop="closeModal">
<view class="modal-container" @tap.stop>
<scroll-view scroll-y class="modal-scroll">
<!-- Header -->
<view class="modal-header">
<text class="modal-title">{{ editTarget ? '编辑卡种' : '新增卡种' }}</text>
<view class="modal-close" @tap="closeModal">
<text class="modal-close-icon"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">现价</text>
<input
class="modal-input"
type="digit"
v-model="form.priceStr"
placeholder="如980"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">原价</text>
<input
class="modal-input"
type="digit"
v-model="form.originalPriceStr"
placeholder="可选,用于展示划线价"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">次数</text>
<input
class="modal-input"
type="number"
v-model="form.totalTimesStr"
placeholder="次卡必填,月卡留空"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">有效天数</text>
<input
class="modal-input"
type="number"
v-model="form.durationDaysStr"
placeholder="如90"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">排序值</text>
<input
class="modal-input"
type="number"
v-model="form.sortOrderStr"
placeholder="数字越小越靠前"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">描述</text>
<textarea
class="modal-textarea"
v-model="form.description"
placeholder="可选"
placeholder-style="color:#bbb"
:maxlength="200"
auto-height
/>
</view>
<view class="modal-actions">
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view
class="modal-confirm"
:class="{ 'modal-confirm--loading': submitting }"
@tap="submitForm"
>
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
<!-- Form fields -->
<view class="modal-body">
<view class="modal-field">
<text class="modal-label">卡种名称</text>
<input
class="modal-input"
v-model="form.name"
placeholder="如10次课套餐"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">类型</text>
<picker
mode="selector"
:range="typeOptions"
range-key="label"
:value="form.typeIdx"
@change="onTypeChange"
>
<view class="picker-display">
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">现价</text>
<input
class="modal-input"
type="digit"
v-model="form.priceStr"
placeholder="如980"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">原价</text>
<input
class="modal-input"
type="digit"
v-model="form.originalPriceStr"
placeholder="可选,用于展示划线价"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">次数</text>
<input
class="modal-input"
type="number"
v-model="form.totalTimesStr"
placeholder="次卡必填,月卡留空"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">有效天数</text>
<input
class="modal-input"
type="number"
v-model="form.durationDaysStr"
placeholder="如90"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">排序值</text>
<input
class="modal-input"
type="number"
v-model="form.sortOrderStr"
placeholder="数字越小越靠前"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">描述</text>
<textarea
class="modal-textarea"
v-model="form.description"
placeholder="可选"
placeholder-style="color:#bbb"
:maxlength="200"
auto-height
/>
</view>
</view>
</view>
</scroll-view>
<!-- Action buttons -->
<view class="modal-actions">
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view
class="modal-confirm"
:class="{ 'modal-confirm--loading': submitting }"
@tap="submitForm"
>
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认保存' }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { formatPrice } from '../../utils/format'
import { CardTypeCategory } from '@mp-pilates/shared'
@@ -205,6 +219,12 @@ import type { CardType } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
})
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const showModal = ref(false)
@@ -217,7 +237,7 @@ const typeOptions = [
{ label: '体验卡', value: CardTypeCategory.TRIAL },
]
const form = ref({
const defaultForm = () => ({
name: '',
typeIdx: 0,
priceStr: '',
@@ -228,6 +248,10 @@ const form = ref({
description: '',
})
const form = ref(defaultForm())
// ─── Data loading ────────────────────────────────────
async function fetchCardTypes() {
loading.value = true
try {
@@ -239,18 +263,11 @@ async function fetchCardTypes() {
}
}
// ─── Modal open / close ──────────────────────────────
function openAdd() {
editTarget.value = null
form.value = {
name: '',
typeIdx: 0,
priceStr: '',
originalPriceStr: '',
totalTimesStr: '',
durationDaysStr: '90',
sortOrderStr: '0',
description: '',
}
form.value = defaultForm()
showModal.value = true
}
@@ -259,8 +276,8 @@ function openEdit(ct: CardType) {
form.value = {
name: ct.name,
typeIdx: typeOptions.findIndex((t) => t.value === ct.type),
priceStr: String(ct.price),
originalPriceStr: ct.originalPrice ? String(ct.originalPrice) : '',
priceStr: String(Number(ct.price) / 100),
originalPriceStr: ct.originalPrice ? String(Number(ct.originalPrice) / 100) : '',
totalTimesStr: ct.totalTimes ? String(ct.totalTimes) : '',
durationDaysStr: String(ct.durationDays),
sortOrderStr: String(ct.sortOrder),
@@ -274,8 +291,16 @@ function closeModal() {
editTarget.value = null
}
function onTypeChange(e: { detail: { value: number } }) {
form.value.typeIdx = Number(e.detail.value)
}
// ─── Form submit ─────────────────────────────────────
async function submitForm() {
if (submitting.value) return
// Validation
if (!form.value.name.trim()) {
uni.showToast({ title: '请填写卡种名称', icon: 'none' })
return
@@ -291,19 +316,35 @@ async function submitForm() {
return
}
const selectedType = typeOptions[form.value.typeIdx].value
const totalTimes = form.value.totalTimesStr ? parseInt(form.value.totalTimesStr, 10) : null
// Times-based card must have totalTimes
if (
(selectedType === CardTypeCategory.TIMES || selectedType === CardTypeCategory.TRIAL) &&
(!totalTimes || totalTimes < 1)
) {
uni.showToast({ title: '次卡/体验卡请填写次数', icon: 'none' })
return
}
// Convert yuan → cents for storage
const priceCents = Math.round(price * 100)
const payload: Record<string, unknown> = {
name: form.value.name.trim(),
type: typeOptions[form.value.typeIdx].value,
price,
type: selectedType,
price: priceCents,
durationDays,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
}
if (form.value.originalPriceStr) {
payload.originalPrice = parseFloat(form.value.originalPriceStr)
const originalPrice = parseFloat(form.value.originalPriceStr)
payload.originalPrice = Math.round(originalPrice * 100)
}
if (form.value.totalTimesStr) {
payload.totalTimes = parseInt(form.value.totalTimesStr, 10)
if (totalTimes) {
payload.totalTimes = totalTimes
}
if (form.value.description.trim()) {
payload.description = form.value.description.trim()
@@ -319,33 +360,70 @@ async function submitForm() {
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
await fetchCardTypes()
} catch (e: any) {
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
} catch (e: unknown) {
const message = e instanceof Error ? e.message : '保存失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
submitting.value = false
}
}
async function toggleActive(ct: CardType) {
try {
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
await fetchCardTypes()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
// ─── Toggle active (上架 / 下架) ─────────────────────
function confirmToggle(ct: CardType) {
const action = ct.isActive ? '下架' : '上架'
const content = ct.isActive
? `下架后用户将无法购买「${ct.name}」,已持有的会员卡不受影响。`
: `上架后「${ct.name}」将重新对用户可见并可购买。`
uni.showModal({
title: `确认${action}`,
content,
confirmText: action,
confirmColor: ct.isActive ? '#e67e22' : '#27ae60',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: `${action}中...` })
try {
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive } as any)
uni.hideLoading()
uni.showToast({ title: `${action}`, icon: 'success' })
await fetchCardTypes()
} catch {
uni.hideLoading()
uni.showToast({ title: `${action}失败`, icon: 'none' })
}
}
},
})
}
// ─── Delete ──────────────────────────────────────────
function confirmDelete(ct: CardType) {
uni.showModal({
title: '确认删除',
content: `删除卡种「${ct.name}」?此操作不可恢复`,
content: `删除卡种「${ct.name}」?\n若有用户已购买此卡种将自动下架而非删除`,
confirmText: '删除',
confirmColor: '#c0392b',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中...' })
try {
await adminStore.deleteCardType(ct.id)
uni.showToast({ title: '已删除', icon: 'success' })
const result = await adminStore.deleteCardType(ct.id)
uni.hideLoading()
// result may contain { deleted, deactivated } from server
const resultData = result as unknown as { deleted?: boolean; deactivated?: boolean }
if (resultData?.deactivated) {
uni.showToast({ title: '存在关联数据,已自动下架', icon: 'none', duration: 2500 })
} else {
uni.showToast({ title: '已删除', icon: 'success' })
}
await fetchCardTypes()
} catch {
uni.hideLoading()
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
@@ -353,6 +431,8 @@ function confirmDelete(ct: CardType) {
})
}
// ─── Helpers ─────────────────────────────────────────
function typeLabel(ct: CardType): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
@@ -368,6 +448,8 @@ function headerClass(ct: CardType): string {
return 'header--times'
}
// ─── Lifecycle ───────────────────────────────────────
onMounted(fetchCardTypes)
</script>
@@ -403,7 +485,7 @@ onMounted(fetchCardTypes)
height: 260rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@@ -435,7 +517,7 @@ onMounted(fetchCardTypes)
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
&--inactive { opacity: 0.6; }
&--inactive { opacity: 0.55; }
}
.ct-header {
@@ -510,6 +592,8 @@ onMounted(fetchCardTypes)
border-right: 1rpx solid #f5f5f5;
&:last-child { border-right: none; }
&:active { background: #f9f9f9; }
}
.ct-action-text { font-size: 26rpx; font-weight: 600; }
@@ -526,23 +610,58 @@ onMounted(fetchCardTypes)
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
z-index: 100;
z-index: 1000;
}
.modal {
.modal-container {
width: 100%;
max-height: 85vh;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx 32rpx 60rpx;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-scroll {
flex: 1;
max-height: 85vh;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 16rpx;
position: sticky;
top: 0;
background: #ffffff;
z-index: 10;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 24rpx;
}
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 50%;
}
.modal-close-icon {
font-size: 24rpx;
color: #999;
}
.modal-body {
padding: 0 32rpx;
}
.modal-field {
@@ -575,7 +694,8 @@ onMounted(fetchCardTypes)
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: #ffffff;
}
.modal-cancel {
@@ -586,6 +706,8 @@ onMounted(fetchCardTypes)
display: flex;
align-items: center;
justify-content: center;
&:active { background: #e8e8e8; }
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
@@ -599,7 +721,8 @@ onMounted(fetchCardTypes)
align-items: center;
justify-content: center;
&--loading { opacity: 0.6; }
&:active { opacity: 0.85; }
&--loading { opacity: 0.6; pointer-events: none; }
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back />
<!-- Stats row -->
<view class="stats-row">
<view v-if="statsLoading" class="stats-shimmer-wrap">
@@ -40,9 +41,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import type { AdminStats } from '../../stores/admin'
const navBarHeight = ref('64px')
const adminStore = useAdminStore()
const statsLoading = ref(false)
@@ -72,7 +76,11 @@ async function loadStats() {
}
}
onMounted(loadStats)
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
loadStats()
})
</script>
<style lang="scss" scoped>

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="会员管理" show-back />
<!-- Search bar -->
<view class="filter-bar">
<input
@@ -104,11 +105,18 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import type { MemberSummary } from '../../stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
})
const members = ref<MemberSummary[]>([])
const loading = ref(false)
const searchQuery = ref('')

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="订单管理" show-back />
<!-- Status filter tabs -->
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-row">
@@ -77,6 +78,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDate } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared'
@@ -84,6 +86,12 @@ import type { OrderWithDetails } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
})
const filters = [
{ label: '全部', value: '' },
{ label: '已支付', value: OrderStatus.PAID },

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="排课管理" show-back />
<!-- Date selector -->
<view class="sticky-header">
<DateSelector v-model="selectedDate" @select="onDateSelect" />
@@ -156,6 +157,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue'
@@ -174,6 +176,7 @@ interface EditableSlot {
}
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const selectedDate = ref(formatDate(new Date()))
const loading = ref(false)
const publishing = ref(false)
@@ -405,7 +408,11 @@ function slotBadgeText(slot: EditableSlot): string {
// ── Lifecycle ─────────────────────────────────────────────
onMounted(() => loadPreview(selectedDate.value))
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
loadPreview(selectedDate.value)
})
</script>
<style lang="scss" scoped>

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="时段调整" show-back />
<!-- Tabs -->
<view class="tabs">
<view
@@ -138,13 +139,15 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const tabs = ['新增时段', '关闭时段', '批量生成']
const activeTab = ref(0)
const submitting = ref(false)
@@ -242,6 +245,11 @@ async function submitGenerate() {
submitting.value = false
}
}
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
})
</script>
<style lang="scss" scoped>

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="工作室设置" show-back />
<!-- Loading state -->
<view v-if="loading" class="skeleton-page">
<view class="skeleton-section" />
@@ -150,10 +151,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
})
const form = ref({
name: '',
address: '',

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="排课模板" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ templates.length }} 条模板</text>
@@ -137,6 +138,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
import type { WeekTemplate } from '@mp-pilates/shared'
@@ -151,6 +153,7 @@ type LocalTemplate = Partial<WeekTemplate> & {
}
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const saving = ref(false)
const isDirty = ref(false)
@@ -318,7 +321,11 @@ async function handleSave() {
}
}
onMounted(fetchTemplates)
onMounted(() => {
const sys = uni.getSystemInfoSync()
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
fetchTemplates()
})
</script>
<style lang="scss" scoped>