perf: 优化页面
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="booking-page">
|
||||
<view class="booking-page" :style="pageStyle">
|
||||
<!-- ──────────── Custom nav bar ──────────── -->
|
||||
<CustomNavBar title="预约课程" />
|
||||
|
||||
<!-- ──────────── Sticky header area ──────────── -->
|
||||
<view class="sticky-header">
|
||||
<!-- Date selector -->
|
||||
@@ -13,29 +16,45 @@
|
||||
<scroll-view
|
||||
class="slot-scroll"
|
||||
scroll-y
|
||||
:style="{ height: scrollHeight }"
|
||||
:style="{ height: scrollHeight, paddingTop: stickyHeaderHeight }"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
|
||||
<view v-for="i in 4" :key="i" class="skeleton-card" />
|
||||
<view v-for="i in 3" :key="i" class="skeleton-card">
|
||||
<view class="skeleton-time" />
|
||||
<view class="skeleton-body">
|
||||
<view class="skeleton-title" />
|
||||
<view class="skeleton-sub" />
|
||||
</view>
|
||||
<view class="skeleton-btn" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Empty state -->
|
||||
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
||||
<image class="empty-img" src="/static/images/empty-calendar.png" mode="aspectFit" />
|
||||
<view class="empty-icon-circle">
|
||||
<text class="empty-icon-text">📅</text>
|
||||
</view>
|
||||
<text class="empty-text">当日暂无可约时段</text>
|
||||
<text class="empty-sub">请选择其他日期或时段</text>
|
||||
<text class="empty-sub">请选择其他日期或时段查看</text>
|
||||
</view>
|
||||
|
||||
<!-- Slot cards -->
|
||||
<view v-else class="slot-list">
|
||||
<!-- Date summary -->
|
||||
<view class="date-summary">
|
||||
<text class="date-summary-text">
|
||||
共 {{ filteredSlots.length }} 个可选时段
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<SlotCard
|
||||
v-for="slot in filteredSlots"
|
||||
:key="slot.id"
|
||||
:slot="slot"
|
||||
v-for="item in filteredSlots"
|
||||
:key="item.id"
|
||||
:time-slot="item"
|
||||
@book="onBookTap"
|
||||
@cancel="onCancelTap"
|
||||
/>
|
||||
@@ -48,7 +67,7 @@
|
||||
<!-- ──────────── Confirm popup ──────────── -->
|
||||
<BookingConfirmPopup
|
||||
:visible="showConfirmPopup"
|
||||
:slot="pendingSlot"
|
||||
:time-slot="pendingSlot"
|
||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||
@confirm="onConfirmBooking"
|
||||
@cancel="showConfirmPopup = false"
|
||||
@@ -62,11 +81,12 @@ import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pila
|
||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getDateRange } from '../../utils/format'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||
import SlotCard from '../../components/SlotCard.vue'
|
||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||
|
||||
@@ -82,13 +102,34 @@ const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────
|
||||
// Approximate scroll area height (vh minus sticky header ~220rpx + tabbar ~100rpx)
|
||||
const scrollHeight = computed(() => {
|
||||
// Default: statusBar ~20px + 88rpx ≈ 64px; avoid empty string on first render
|
||||
const navBarHeight = ref('64px')
|
||||
const scrollHeight = ref('500px')
|
||||
const stickyHeaderHeight = ref('240rpx')
|
||||
|
||||
function updateLayout() {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const headerPx = 220 * (sysInfo.windowWidth / 750)
|
||||
const tabbarPx = 100 * (sysInfo.windowWidth / 750)
|
||||
return `${sysInfo.windowHeight - headerPx - tabbarPx}px`
|
||||
})
|
||||
const ratio = sysInfo.windowWidth / 750
|
||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||
const navTitlePx = 88 * ratio
|
||||
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
||||
navBarHeight.value = `${navBarPx}px`
|
||||
|
||||
// Measure sticky header: DateSelector (~160rpx) + TimePeriodFilter (~76rpx) + borders
|
||||
const stickyPx = Math.round(240 * ratio)
|
||||
stickyHeaderHeight.value = `${stickyPx}px`
|
||||
|
||||
// scrollHeight: from below nav bar to above tabbar
|
||||
const tabbarPx = Math.round(100 * ratio)
|
||||
scrollHeight.value = `${sysInfo.windowHeight - navBarPx - tabbarPx}px`
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
|
||||
// CSS variable for sticky header offset
|
||||
const pageStyle = computed(() => ({
|
||||
'--nav-bar-height': navBarHeight.value,
|
||||
}))
|
||||
|
||||
// ─── Filtered slots ───────────────────────────────────────
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
@@ -226,24 +267,29 @@ onMounted(async () => {
|
||||
<style lang="scss" scoped>
|
||||
.booking-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
background: #f7f4f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--nav-bar-height: v-bind(navBarHeight);
|
||||
padding-top: var(--nav-bar-height);
|
||||
}
|
||||
|
||||
/* ── Sticky header ─────────────────────────────────── */
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
top: var(--nav-bar-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ── Scroll container ──────────────────────────────── */
|
||||
.slot-scroll {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Slot list ─────────────────────────────────────── */
|
||||
@@ -251,7 +297,18 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 24rpx 0;
|
||||
padding: 24rpx 24rpx 0;
|
||||
}
|
||||
|
||||
/* ── Date summary ──────────────────────────────────── */
|
||||
.date-summary {
|
||||
padding: 0 8rpx 4rpx;
|
||||
}
|
||||
|
||||
.date-summary-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ──────────────────────────────── */
|
||||
@@ -264,10 +321,59 @@ onMounted(async () => {
|
||||
|
||||
.skeleton-card {
|
||||
height: 140rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
||||
gap: 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.skeleton-time {
|
||||
width: 80rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 12rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 60%;
|
||||
height: 28rpx;
|
||||
border-radius: 8rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-sub {
|
||||
width: 40%;
|
||||
height: 20rpx;
|
||||
border-radius: 6rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
width: 140rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
@@ -281,15 +387,23 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
padding: 140rpx 40rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8rpx;
|
||||
.empty-icon-circle {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
background: #f0ece8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.empty-icon-text {
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="card-detail-page">
|
||||
<view class="card-detail-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="购买会员卡" show-back />
|
||||
<!-- Loading state -->
|
||||
<view v-if="loading" class="loading-wrap">
|
||||
<view class="skeleton-header" />
|
||||
@@ -130,9 +131,13 @@ import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { get, post } from '../../utils/request'
|
||||
import { formatPrice } from '../../utils/format'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
// ─── Route params ──────────────────────────────────────────
|
||||
const cardId = ref<string>('')
|
||||
const isTrial = ref(false)
|
||||
@@ -273,6 +278,9 @@ async function doPurchase() {
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<view class="home-page" :style="pageStyle">
|
||||
<!-- ──────────── Custom nav bar ──────────── -->
|
||||
<CustomNavBar title="场馆首页" />
|
||||
|
||||
<!-- Pull-to-refresh wrapper -->
|
||||
<scroll-view
|
||||
class="page-scroll"
|
||||
@@ -36,9 +39,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import BrandBanner from '../../components/BrandBanner.vue'
|
||||
import StudioInfo from '../../components/StudioInfo.vue'
|
||||
import QuickEntry from '../../components/QuickEntry.vue'
|
||||
@@ -53,6 +57,24 @@ const userStore = useUserStore()
|
||||
const studioStore = useStudioStore()
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
function updateLayout() {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const ratio = sysInfo.windowWidth / 750
|
||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||
const navTitlePx = 88 * ratio
|
||||
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
||||
navBarHeight.value = `${navBarPx}px`
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
|
||||
const pageStyle = computed(() => ({
|
||||
'--nav-bar-height': navBarHeight.value,
|
||||
}))
|
||||
|
||||
const refreshing = ref(false)
|
||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||
const cardShopAnchorId = 'card-shop-anchor'
|
||||
@@ -99,10 +121,11 @@ function scrollToCardShop() {
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-top: var(--nav-bar-height);
|
||||
}
|
||||
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
height: calc(100vh - var(--nav-bar-height));
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="bookings-page">
|
||||
<view class="bookings-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的预约" show-back />
|
||||
<!-- Tab bar -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
@@ -134,9 +135,13 @@ import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { formatDate, getWeekdayLabel } from '../../utils/format'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
// ─── Tab state ────────────────────────────────────────────
|
||||
type TabKey = 'upcoming' | 'history'
|
||||
|
||||
@@ -273,7 +278,11 @@ async function handleCancel(booking: BookingWithDetails) {
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(() => bookingStore.fetchMyBookings())
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
bookingStore.fetchMyBookings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="profile-page">
|
||||
<!-- Custom nav bar (transparent, blends with UserCard gradient) -->
|
||||
<CustomNavBar title="我的" transparent />
|
||||
|
||||
<!-- User card -->
|
||||
<UserCard
|
||||
:logged-in="loggedIn"
|
||||
@@ -8,6 +11,7 @@
|
||||
:stats="stats"
|
||||
:memberships="memberships"
|
||||
:loading="loginLoading"
|
||||
:nav-bar-height="navBarHeight"
|
||||
@login="handleLogin"
|
||||
/>
|
||||
|
||||
@@ -28,17 +32,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||
|
||||
const loginLoading = ref(false)
|
||||
const navBarHeight = ref(64)
|
||||
|
||||
onMounted(() => {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||
const navTitlePx = 88 * (sysInfo.windowWidth / 750)
|
||||
navBarHeight.value = Math.round(statusBarPx + navTitlePx)
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
if (loggedIn.value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="info-page">
|
||||
<view class="info-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="个人信息" show-back />
|
||||
<!-- Avatar section -->
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
||||
@@ -84,9 +85,13 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { wxBindPhone } from '../../utils/auth'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
// ─── Form state ───────────────────────────────────────────
|
||||
const form = ref({
|
||||
nickname: '',
|
||||
@@ -211,6 +216,8 @@ async function handleSave() {
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
await userStore.fetchProfile()
|
||||
if (userStore.user) {
|
||||
form.value = { nickname: userStore.user.nickname }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="membership-page">
|
||||
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的会员卡" show-back />
|
||||
<!-- Pull-to-refresh scroll view -->
|
||||
<scroll-view
|
||||
class="scroll"
|
||||
@@ -146,9 +147,12 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
// ─── State ────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
@@ -235,7 +239,11 @@ function goStore() {
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(loadMemberships)
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
loadMemberships()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
Reference in New Issue
Block a user