feat(app): implement home, booking, and profile pages

Home: brand banner, studio info swiper, smart quick entries based on
membership status, upcoming bookings, card shop horizontal scroll
Booking: 7-day date selector, time period filter, slot cards with
status, booking confirm popup with membership picker
Profile: user card with login, training stats, menu with admin entry
8 reusable components: BrandBanner, StudioInfo, QuickEntry,
UpcomingBooking, CardShop, DateSelector, SlotCard, BookingConfirmPopup,
TimePeriodFilter, UserCard, TrainingStats, ProfileMenu
This commit is contained in:
richarjiang
2026-04-02 14:35:17 +08:00
parent 554fc30954
commit 3a29aca0db
26 changed files with 7766 additions and 74 deletions

View File

@@ -1,15 +1,635 @@
<template>
<view class="page">
<view class="placeholder">
<text>卡种管理 - 待实现</text>
<!-- Add button -->
<view class="toolbar">
<text class="toolbar-hint"> {{ cardTypes.length }} 个卡种</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新增卡种</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!cardTypes.length" class="empty-state">
<text class="empty-icon">💳</text>
<text class="empty-text">暂无卡种点击右上角新增</text>
</view>
<!-- Card type list -->
<view v-else class="ct-list">
<view
v-for="ct in cardTypes"
:key="ct.id"
class="ct-card"
:class="{ 'ct-card--inactive': !ct.isActive }"
>
<!-- Header band -->
<view class="ct-header" :class="headerClass(ct)">
<text class="ct-type-label">{{ typeLabel(ct) }}</text>
<view class="ct-status-tag" :class="ct.isActive ? 'tag--on' : 'tag--off'">
<text class="ct-status-text">{{ ct.isActive ? '销售中' : '已下架' }}</text>
</view>
</view>
<!-- Body -->
<view class="ct-body">
<text class="ct-name">{{ ct.name }}</text>
<view class="ct-price-row">
<text class="ct-price">¥{{ formatPrice(ct.price) }}</text>
<text v-if="ct.originalPrice && ct.originalPrice > ct.price" class="ct-original">
¥{{ formatPrice(ct.originalPrice) }}
</text>
</view>
<text v-if="ct.description" class="ct-desc">{{ ct.description }}</text>
<view class="ct-meta">
<view v-if="ct.totalTimes" class="meta-item">
<text class="meta-value">{{ ct.totalTimes }}</text>
<text class="meta-label"></text>
</view>
<view class="meta-item">
<text class="meta-value">{{ ct.durationDays }}</text>
<text class="meta-label">天有效</text>
</view>
<view class="meta-item">
<text class="meta-label">排序</text>
<text class="meta-value">{{ ct.sortOrder }}</text>
</view>
</view>
</view>
<!-- Actions -->
<view class="ct-actions">
<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
<text class="ct-action-text">编辑</text>
</view>
<view
class="ct-action-btn toggle-btn"
:class="ct.isActive ? 'toggle-off' : 'toggle-on'"
@tap="toggleActive(ct)"
>
<text class="ct-action-text">{{ ct.isActive ? '下架' : '上架' }}</text>
</view>
<view class="ct-action-btn delete-btn" @tap="confirmDelete(ct)">
<text class="ct-action-text">删除</text>
</view>
</view>
</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>
</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>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put, del } from '../../utils/request'
import { formatPrice } from '../../utils/format'
import { CardTypeCategory } from '@mp-pilates/shared'
import type { CardType } from '@mp-pilates/shared'
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<CardType | null>(null)
const typeOptions = [
{ label: '次卡', value: CardTypeCategory.TIMES },
{ label: '月卡', value: CardTypeCategory.DURATION },
{ label: '体验卡', value: CardTypeCategory.TRIAL },
]
const form = ref({
name: '',
typeIdx: 0,
priceStr: '',
originalPriceStr: '',
totalTimesStr: '',
durationDaysStr: '90',
sortOrderStr: '0',
description: '',
})
async function fetchCardTypes() {
loading.value = true
try {
const data = await get<CardType[]>('/admin/card-types')
cardTypes.value = data.sort((a, b) => a.sortOrder - b.sortOrder)
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function openAdd() {
editTarget.value = null
form.value = {
name: '',
typeIdx: 0,
priceStr: '',
originalPriceStr: '',
totalTimesStr: '',
durationDaysStr: '90',
sortOrderStr: '0',
description: '',
}
showModal.value = true
}
function openEdit(ct: CardType) {
editTarget.value = ct
form.value = {
name: ct.name,
typeIdx: typeOptions.findIndex((t) => t.value === ct.type),
priceStr: String(ct.price),
originalPriceStr: ct.originalPrice ? String(ct.originalPrice) : '',
totalTimesStr: ct.totalTimes ? String(ct.totalTimes) : '',
durationDaysStr: String(ct.durationDays),
sortOrderStr: String(ct.sortOrder),
description: ct.description ?? '',
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
async function submitForm() {
if (submitting.value) return
if (!form.value.name.trim()) {
uni.showToast({ title: '请填写卡种名称', icon: 'none' })
return
}
const price = parseFloat(form.value.priceStr)
if (isNaN(price) || price <= 0) {
uni.showToast({ title: '请填写有效价格', icon: 'none' })
return
}
const durationDays = parseInt(form.value.durationDaysStr, 10)
if (isNaN(durationDays) || durationDays < 1) {
uni.showToast({ title: '请填写有效天数', icon: 'none' })
return
}
const payload: Record<string, unknown> = {
name: form.value.name.trim(),
type: typeOptions[form.value.typeIdx].value,
price,
durationDays,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
}
if (form.value.originalPriceStr) {
payload.originalPrice = parseFloat(form.value.originalPriceStr)
}
if (form.value.totalTimesStr) {
payload.totalTimes = parseInt(form.value.totalTimesStr, 10)
}
if (form.value.description.trim()) {
payload.description = form.value.description.trim()
}
submitting.value = true
try {
if (editTarget.value) {
await put(`/admin/card-types/${editTarget.value.id}`, payload)
} else {
await post('/admin/card-types', payload)
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
await fetchCardTypes()
} catch (e: any) {
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
} finally {
submitting.value = false
}
}
async function toggleActive(ct: CardType) {
try {
await put(`/admin/card-types/${ct.id}`, { isActive: !ct.isActive })
await fetchCardTypes()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
function confirmDelete(ct: CardType) {
uni.showModal({
title: '确认删除',
content: `删除卡种「${ct.name}」?此操作不可恢复。`,
success: async (res) => {
if (res.confirm) {
try {
await del(`/admin/card-types/${ct.id}`)
uni.showToast({ title: '已删除', icon: 'success' })
await fetchCardTypes()
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
},
})
}
function typeLabel(ct: CardType): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验卡',
}
return map[ct.type] ?? '会员卡'
}
function headerClass(ct: CardType): string {
if (ct.type === CardTypeCategory.TRIAL) return 'header--trial'
if (ct.type === CardTypeCategory.DURATION) return 'header--duration'
return 'header--times'
}
onMounted(fetchCardTypes)
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Toolbar ─────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint {
font-size: 24rpx;
color: #999;
}
.add-btn {
background: #1a1a2e;
border-radius: 32rpx;
padding: 12rpx 28rpx;
}
.add-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #c9a87c;
}
/* ── Skeleton ────────────────────────────── */
.skeleton-list {
padding: 0 24rpx;
}
.skeleton-item {
height: 260rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Card type list ──────────────────────── */
.ct-list {
padding: 0 24rpx;
}
.ct-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
&--inactive {
opacity: 0.6;
}
}
.ct-header {
padding: 16rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
.ct-type-label {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
letter-spacing: 2rpx;
}
.ct-status-tag {
border-radius: 20rpx;
padding: 4rpx 16rpx;
}
.tag--on { background: rgba(255,255,255,0.2); }
.tag--off { background: rgba(0,0,0,0.2); }
.ct-status-text {
font-size: 20rpx;
color: #ffffff;
}
.ct-body {
padding: 24rpx;
}
.ct-name {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 12rpx;
}
.ct-price-row {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-bottom: 12rpx;
}
.ct-price {
font-size: 40rpx;
font-weight: 800;
color: #c9a87c;
}
.ct-original {
font-size: 24rpx;
color: #bbb;
text-decoration: line-through;
}
.ct-desc {
font-size: 22rpx;
color: #888;
line-height: 1.5;
display: block;
margin-bottom: 16rpx;
}
.ct-meta {
display: flex;
gap: 24rpx;
}
.meta-item {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.meta-value {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
}
.meta-label {
font-size: 22rpx;
color: #999;
}
/* ── Actions ─────────────────────────────── */
.ct-actions {
display: flex;
border-top: 1rpx solid #f5f5f5;
}
.ct-action-btn {
flex: 1;
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
border-right: 1rpx solid #f5f5f5;
&:last-child {
border-right: none;
}
}
.ct-action-text {
font-size: 26rpx;
font-weight: 600;
}
.edit-btn .ct-action-text { color: #1a1a2e; }
.toggle-on .ct-action-text { color: #27ae60; }
.toggle-off .ct-action-text { color: #e67e22; }
.delete-btn .ct-action-text { color: #c0392b; }
/* ── Modal ───────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 100;
}
.modal {
width: 100%;
max-height: 85vh;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx 32rpx 60rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 24rpx;
}
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
gap: 16rpx;
&--last {
border-bottom: none;
align-items: flex-start;
}
}
.modal-label {
font-size: 26rpx;
color: #555;
width: 140rpx;
flex-shrink: 0;
}
.modal-input {
flex: 1;
text-align: right;
font-size: 26rpx;
color: #222;
}
.picker-display {
display: flex;
align-items: center;
gap: 8rpx;
}
.picker-text { font-size: 26rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.modal-textarea {
flex: 1;
font-size: 26rpx;
color: #222;
min-height: 80rpx;
text-align: right;
}
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
}
.modal-cancel {
flex: 1;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
.modal-confirm {
flex: 2;
height: 88rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&--loading { opacity: 0.6; }
}
.modal-confirm-text {
font-size: 28rpx;
font-weight: 700;
color: #c9a87c;
}
</style>

View File

@@ -1,15 +1,237 @@
<template>
<view class="page">
<view class="placeholder">
<text>管理中心 - 待实现</text>
<view class="admin-page">
<!-- Header -->
<view class="admin-header">
<view class="header-top">
<text class="header-title">管理中心</text>
<view class="admin-badge">
<text class="admin-badge-text">管理员</text>
</view>
</view>
<text class="header-sub">欢迎回来{{ userStore.user?.nickname }}</text>
</view>
<!-- Stats row -->
<view class="stats-row">
<view v-for="stat in stats" :key="stat.label" class="stat-cell">
<view v-if="loadingStats" class="stat-skeleton" />
<template v-else>
<text class="stat-value">{{ stat.value }}</text>
<text class="stat-label">{{ stat.label }}</text>
</template>
</view>
</view>
<!-- Nav grid -->
<view class="grid">
<view
v-for="item in navItems"
:key="item.path"
class="grid-item"
@tap="navigate(item.path)"
>
<text class="grid-icon">{{ item.icon }}</text>
<text class="grid-label">{{ item.label }}</text>
<text class="grid-desc">{{ item.desc }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useUserStore } from '../../stores/user'
import { get } from '../../utils/request'
import type { PaginatedData, OrderWithDetails, BookingWithDetails } from '@mp-pilates/shared'
const userStore = useUserStore()
const loadingStats = ref(true)
interface Stat {
label: string
value: string | number
}
const stats = ref<Stat[]>([
{ label: '今日预约', value: '-' },
{ label: '总订单', value: '-' },
{ label: '总预约', value: '-' },
])
const navItems = [
{ path: '/pages/admin/week-template', icon: '📅', label: '排课设置', desc: '管理周课模板' },
{ path: '/pages/admin/slot-adjust', icon: '🗓️', label: '时段调整', desc: '手动添加/关闭时段' },
{ path: '/pages/admin/members', icon: '👥', label: '会员管理', desc: '查看会员活跃度' },
{ path: '/pages/admin/orders', icon: '📋', label: '订单管理', desc: '查看购卡订单' },
{ path: '/pages/admin/card-types', icon: '💳', label: '卡种管理', desc: '配置会员卡套餐' },
{ path: '/pages/admin/studio', icon: '🏢', label: '工作室设置', desc: '基本信息配置' },
]
async function loadStats() {
loadingStats.value = true
try {
const today = new Date().toISOString().slice(0, 10)
const [bookingsRes, ordersRes] = await Promise.all([
get<PaginatedData<BookingWithDetails>>('/admin/bookings?page=1&limit=1'),
get<PaginatedData<OrderWithDetails>>('/admin/orders?page=1&limit=1'),
])
// Today's bookings — fetch with date filter
const todayRes = await get<PaginatedData<BookingWithDetails>>(
`/admin/bookings?page=1&limit=1&date=${today}`,
)
stats.value = [
{ label: '今日预约', value: todayRes.total ?? 0 },
{ label: '总订单', value: ordersRes.total ?? 0 },
{ label: '总预约', value: bookingsRes.total ?? 0 },
]
} catch {
stats.value = [
{ label: '今日预约', value: '--' },
{ label: '总订单', value: '--' },
{ label: '总预约', value: '--' },
]
} finally {
loadingStats.value = false
}
}
function navigate(path: string) {
uni.navigateTo({ url: path })
}
onMounted(loadStats)
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
.admin-page {
min-height: 100vh;
background: #f5f3f0;
}
/* ── Header ─────────────────────────────────────── */
.admin-header {
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
padding: 80rpx 32rpx 48rpx;
}
.header-top {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 12rpx;
}
.header-title {
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
}
.admin-badge {
background: #c9a87c;
border-radius: 20rpx;
padding: 4rpx 16rpx;
}
.admin-badge-text {
font-size: 20rpx;
font-weight: 600;
color: #1a1a2e;
}
.header-sub {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.65);
}
/* ── Stats row ───────────────────────────────────── */
.stats-row {
display: flex;
background: #ffffff;
border-radius: 20rpx;
margin: -24rpx 24rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.stat-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 36rpx 0;
border-right: 1rpx solid #f0f0f0;
&:last-child {
border-right: none;
}
}
.stat-value {
font-size: 44rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 22rpx;
color: #999;
}
.stat-skeleton {
width: 80rpx;
height: 60rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Nav grid ────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
margin: 32rpx 24rpx 40rpx;
}
.grid-item {
background: #ffffff;
border-radius: 16rpx;
padding: 36rpx 28rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
&:active {
opacity: 0.8;
}
}
.grid-icon {
font-size: 52rpx;
margin-bottom: 4rpx;
}
.grid-label {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
}
.grid-desc {
font-size: 22rpx;
color: #999;
line-height: 1.4;
}
</style>

View File

@@ -1,15 +1,354 @@
<template>
<view class="page">
<view class="placeholder">
<text>会员管理 - 待实现</text>
<!-- Search / filter bar -->
<view class="filter-bar">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索昵称或手机号"
placeholder-style="color:#bbb"
@input="onSearch"
/>
</view>
<!-- Stats summary -->
<view class="stats-row">
<view class="stat-cell">
<text class="stat-value">{{ totalMembers }}</text>
<text class="stat-label">活跃会员</text>
</view>
<view class="stat-cell">
<text class="stat-value">{{ totalBookings }}</text>
<text class="stat-label">总预约次数</text>
</view>
<view class="stat-cell">
<text class="stat-value">{{ confirmedBookings }}</text>
<text class="stat-label">待上课</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 6" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!filteredMembers.length" class="empty-state">
<text class="empty-icon">👥</text>
<text class="empty-text">{{ searchQuery ? '未找到匹配会员' : '暂无预约记录' }}</text>
</view>
<!-- Member list -->
<view v-else class="member-list">
<view
v-for="member in filteredMembers"
:key="member.userId"
class="member-card"
>
<view class="member-avatar">
<text class="member-avatar-text">{{ member.nickname.slice(0, 1).toUpperCase() }}</text>
</view>
<view class="member-info">
<text class="member-name">{{ member.nickname }}</text>
<text v-if="member.phone" class="member-phone">{{ maskPhone(member.phone) }}</text>
</view>
<view class="member-stats">
<view class="member-stat">
<text class="member-stat-value">{{ member.totalBookings }}</text>
<text class="member-stat-label">次预约</text>
</view>
<view class="member-stat">
<text class="member-stat-value confirmed-count">{{ member.confirmedBookings }}</text>
<text class="member-stat-label">待上课</text>
</view>
</view>
</view>
</view>
<!-- Load more -->
<view v-if="hasMore && !loading" class="load-more" @tap="loadMore">
<text class="load-more-text">加载更多</text>
</view>
<view v-if="loadingMore" class="load-more">
<text class="load-more-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { get } from '../../utils/request'
import { BookingStatus } from '@mp-pilates/shared'
import type { BookingWithDetails, PaginatedData } from '@mp-pilates/shared'
interface MemberSummary {
userId: string
nickname: string
phone?: string
totalBookings: number
confirmedBookings: number
}
const allBookings = ref<BookingWithDetails[]>([])
const page = ref(1)
const limit = 50
const hasMore = ref(true)
const loading = ref(false)
const loadingMore = ref(false)
const searchQuery = ref('')
const members = computed<MemberSummary[]>(() => {
const map = new Map<string, MemberSummary>()
for (const b of allBookings.value) {
const userId = b.userId
if (!userId) continue
if (!map.has(userId)) {
map.set(userId, {
userId,
nickname: userId.slice(0, 8),
totalBookings: 0,
confirmedBookings: 0,
})
}
const m = map.get(userId)!
m.totalBookings++
if (b.status === BookingStatus.CONFIRMED) {
m.confirmedBookings++
}
}
return Array.from(map.values()).sort((a, b) => b.totalBookings - a.totalBookings)
})
const filteredMembers = computed(() => {
if (!searchQuery.value.trim()) return members.value
const q = searchQuery.value.toLowerCase()
return members.value.filter(
(m) =>
m.nickname.toLowerCase().includes(q) ||
(m.phone && m.phone.includes(q)),
)
})
const totalMembers = computed(() => members.value.length)
const totalBookings = computed(() => members.value.reduce((s, m) => s + m.totalBookings, 0))
const confirmedBookings = computed(() => members.value.reduce((s, m) => s + m.confirmedBookings, 0))
async function fetchBookings(isLoadMore = false) {
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
allBookings.value = []
page.value = 1
hasMore.value = true
}
try {
const data = await get<PaginatedData<BookingWithDetails>>(
`/admin/bookings?page=${page.value}&limit=${limit}`,
)
allBookings.value = [...allBookings.value, ...(data.items ?? [])]
hasMore.value = allBookings.value.length < data.total
page.value++
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadMore() {
if (loadingMore.value || !hasMore.value) return
await fetchBookings(true)
}
function onSearch() {
// Reactive filtering via computed — no action needed
}
function maskPhone(phone: string): string {
return phone.slice(0, 3) + '****' + phone.slice(-4)
}
onMounted(() => fetchBookings())
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Filter bar ──────────────────────────── */
.filter-bar {
padding: 20rpx 24rpx;
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
}
.search-input {
background: #f5f3f0;
border-radius: 32rpx;
padding: 16rpx 28rpx;
font-size: 26rpx;
color: #222;
width: 100%;
}
/* ── Stats row ───────────────────────────── */
.stats-row {
display: flex;
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
}
.stat-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 28rpx 0;
border-right: 1rpx solid #f0f0f0;
&:last-child {
border-right: none;
}
}
.stat-value {
font-size: 40rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
margin-bottom: 6rpx;
}
.stat-label {
font-size: 20rpx;
color: #999;
}
/* ── Skeleton ────────────────────────────── */
.skeleton-list {
padding: 16rpx 24rpx 0;
}
.skeleton-item {
height: 120rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Member list ─────────────────────────── */
.member-list {
padding: 16rpx 24rpx 0;
}
.member-card {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 12rpx;
display: flex;
align-items: center;
gap: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
}
.member-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.member-avatar-text {
font-size: 32rpx;
font-weight: 700;
color: #c9a87c;
}
.member-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.member-name {
font-size: 28rpx;
font-weight: 600;
color: #1a1a2e;
}
.member-phone {
font-size: 22rpx;
color: #999;
}
.member-stats {
display: flex;
gap: 20rpx;
}
.member-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.member-stat-value {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
}
.confirmed-count {
color: #27ae60;
}
.member-stat-label {
font-size: 20rpx;
color: #999;
}
/* ── Load more ───────────────────────────── */
.load-more {
display: flex;
align-items: center;
justify-content: center;
padding: 28rpx 0;
}
.load-more-text {
font-size: 26rpx;
color: #c9a87c;
}
</style>

View File

@@ -1,15 +1,349 @@
<template>
<view class="page">
<view class="placeholder">
<text>订单管理 - 待实现</text>
<!-- Status filter tabs -->
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-row">
<view
v-for="f in filters"
:key="f.key"
class="filter-chip"
:class="{ 'filter-chip--active': statusFilter === f.key }"
@tap="selectFilter(f.key)"
>
<text class="filter-chip-text">{{ f.label }}</text>
</view>
</view>
</scroll-view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!orders.length" class="empty-state">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无订单</text>
</view>
<!-- Order list -->
<view v-else class="order-list">
<view
v-for="order in orders"
:key="order.id"
class="order-card"
>
<!-- Header: card name + status badge -->
<view class="order-header">
<text class="order-card-name">{{ order.cardType?.name ?? '未知卡种' }}</text>
<view class="status-badge" :class="statusBadgeClass(order.status)">
<text class="status-badge-text">{{ statusLabel(order.status) }}</text>
</view>
</view>
<!-- User info -->
<view v-if="order.user" class="order-user">
<text class="order-user-icon">👤</text>
<text class="order-user-text">
{{ order.user.nickname }}
<text v-if="order.user.phone"> · {{ maskPhone(order.user.phone) }}</text>
</text>
</view>
<!-- Amount + date row -->
<view class="order-footer">
<text class="order-amount">¥{{ formatPrice(order.amount) }}</text>
<text class="order-date">{{ formatOrderDate(order.createdAt) }}</text>
</view>
<!-- Order id -->
<text class="order-id">订单号{{ order.id.slice(0, 16) }}...</text>
</view>
</view>
<!-- Pagination -->
<view v-if="totalPages > 1" class="pagination">
<view
class="page-btn"
:class="{ 'page-btn--disabled': currentPage === 1 }"
@tap="goPage(currentPage - 1)"
>
<text class="page-btn-text"> 上一页</text>
</view>
<text class="page-info">{{ currentPage }} / {{ totalPages }}</text>
<view
class="page-btn"
:class="{ 'page-btn--disabled': currentPage === totalPages }"
@tap="goPage(currentPage + 1)"
>
<text class="page-btn-text">下一页 </text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { get } from '../../utils/request'
import { formatPrice } from '../../utils/format'
import type { OrderWithDetails, PaginatedData } from '@mp-pilates/shared'
const filters = [
{ key: '', label: '全部' },
{ key: 'PAID', label: '已支付' },
{ key: 'PENDING', label: '待支付' },
{ key: 'REFUNDED', label: '已退款' },
{ key: 'CANCELLED', label: '已取消' },
]
const statusFilter = ref('')
const orders = ref<OrderWithDetails[]>([])
const loading = ref(false)
const currentPage = ref(1)
const total = ref(0)
const limit = 10
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
async function fetchOrders() {
loading.value = true
try {
const statusParam = statusFilter.value ? `&status=${statusFilter.value}` : ''
const data = await get<PaginatedData<OrderWithDetails>>(
`/admin/orders?page=${currentPage.value}&limit=${limit}${statusParam}`,
)
orders.value = [...(data.items ?? [])]
total.value = data.total ?? 0
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
orders.value = []
} finally {
loading.value = false
}
}
function selectFilter(key: string) {
statusFilter.value = key
currentPage.value = 1
fetchOrders()
}
function goPage(p: number) {
if (p < 1 || p > totalPages.value) return
currentPage.value = p
fetchOrders()
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
PAID: '已支付',
PENDING: '待支付',
REFUNDED: '已退款',
CANCELLED: '已取消',
}
return map[status] ?? status
}
function statusBadgeClass(status: string): string {
if (status === 'PAID') return 'badge--paid'
if (status === 'PENDING') return 'badge--pending'
if (status === 'REFUNDED') return 'badge--refunded'
if (status === 'CANCELLED') return 'badge--cancelled'
return ''
}
function maskPhone(phone: string): string {
return phone.slice(0, 3) + '****' + phone.slice(-4)
}
function formatOrderDate(iso: string): string {
const d = new Date(iso)
return `${d.getMonth() + 1}${d.getDate()}${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
onMounted(fetchOrders)
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Filter scroll ───────────────────────── */
.filter-scroll {
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
}
.filter-row {
display: flex;
flex-direction: row;
gap: 12rpx;
padding: 16rpx 24rpx;
width: max-content;
}
.filter-chip {
padding: 12rpx 28rpx;
border-radius: 32rpx;
background: #f0f0f0;
&--active {
background: #1a1a2e;
}
}
.filter-chip-text {
font-size: 26rpx;
color: #555;
.filter-chip--active & {
color: #c9a87c;
font-weight: 600;
}
}
/* ── Skeleton ────────────────────────────── */
.skeleton-list {
padding: 16rpx 24rpx 0;
}
.skeleton-item {
height: 180rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Order list ──────────────────────────── */
.order-list {
padding: 16rpx 24rpx 0;
}
.order-card {
background: #ffffff;
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.order-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.order-card-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
flex: 1;
}
.status-badge {
border-radius: 20rpx;
padding: 6rpx 16rpx;
}
.status-badge-text {
font-size: 22rpx;
font-weight: 600;
}
.badge--paid { background: #d4edda; .status-badge-text { color: #155724; } }
.badge--pending { background: #fff3cd; .status-badge-text { color: #856404; } }
.badge--refunded { background: #cce5ff; .status-badge-text { color: #004085; } }
.badge--cancelled { background: #f8d7da; .status-badge-text { color: #721c24; } }
.order-user {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 16rpx;
}
.order-user-icon { font-size: 24rpx; }
.order-user-text {
font-size: 24rpx;
color: #555;
}
.order-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.order-amount {
font-size: 36rpx;
font-weight: 800;
color: #c9a87c;
}
.order-date {
font-size: 22rpx;
color: #999;
}
.order-id {
font-size: 20rpx;
color: #bbb;
display: block;
}
/* ── Pagination ──────────────────────────── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 32rpx;
padding: 32rpx 0;
}
.page-btn {
padding: 12rpx 32rpx;
background: #ffffff;
border-radius: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
&--disabled {
opacity: 0.4;
}
}
.page-btn-text {
font-size: 26rpx;
color: #1a1a2e;
font-weight: 600;
}
.page-info {
font-size: 26rpx;
color: #555;
}
</style>

View File

@@ -1,15 +1,512 @@
<template>
<view class="page">
<view class="placeholder">
<text>时段调整 - 待实现</text>
<!-- Tabs -->
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab"
:class="{ 'tab--active': activeTab === tab.key }"
@tap="activeTab = tab.key"
>
<text class="tab-text">{{ tab.label }}</text>
</view>
</view>
<!-- Tab: Manual add -->
<view v-if="activeTab === 'add'" class="section">
<text class="section-title">手动新增时段</text>
<view class="form-card">
<view class="form-row">
<text class="form-label">日期</text>
<picker mode="date" :value="addForm.date" @change="(e: any) => addForm.date = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ addForm.date }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">开始时间</text>
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ addForm.startTime }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">结束时间</text>
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ addForm.endTime }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row form-row--last">
<text class="form-label">容量</text>
<input
class="form-input"
type="number"
v-model="addForm.capacityStr"
placeholder="默认10"
placeholder-style="color:#bbb"
/>
</view>
</view>
<view
class="action-btn primary-btn"
:class="{ 'primary-btn--loading': addingSlot }"
@tap="handleAddSlot"
>
<text class="primary-btn-text">{{ addingSlot ? '添加中...' : '添加时段' }}</text>
</view>
</view>
<!-- Tab: Close slots -->
<view v-else-if="activeTab === 'close'" class="section">
<view class="search-row">
<picker mode="date" :value="closeDateFilter" @change="(e: any) => { closeDateFilter = e.detail.value; fetchSlotsForClose() }">
<view class="date-filter">
<text class="date-filter-text">{{ closeDateFilter }}</text>
<text class="date-filter-arrow"></text>
</view>
</picker>
</view>
<view v-if="loadingClose" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item" />
</view>
<view v-else-if="!closeSlots.length" class="empty-state">
<text class="empty-icon">🗓</text>
<text class="empty-text">该日暂无时段</text>
</view>
<view v-else class="slot-list">
<view
v-for="slot in closeSlots"
:key="slot.id"
class="slot-card"
:class="{ 'slot-card--closed': slot.status === 'CLOSED' }"
>
<view class="slot-info">
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}{{ slot.endTime.slice(0, 5) }}</text>
<text class="slot-cap">容量 {{ slot.capacity }} · 已预约 {{ slot.bookedCount }}</text>
</view>
<view
v-if="slot.status !== 'CLOSED'"
class="close-btn"
@tap="confirmClose(slot)"
>
<text class="close-btn-text">关闭</text>
</view>
<view v-else class="closed-tag">
<text class="closed-tag-text">已关闭</text>
</view>
</view>
</view>
</view>
<!-- Tab: Generate -->
<view v-else-if="activeTab === 'generate'" class="section">
<text class="section-title">按模板生成时段</text>
<text class="section-sub">将依据当前排课模板生成未来指定天数的课程时段已存在的时段不会重复生成</text>
<view class="form-card">
<view class="form-row form-row--last">
<text class="form-label">生成天数</text>
<input
class="form-input"
type="number"
v-model="generateDaysStr"
placeholder="如14"
placeholder-style="color:#bbb"
/>
</view>
</view>
<view
class="action-btn primary-btn"
:class="{ 'primary-btn--loading': generating }"
@tap="handleGenerate"
>
<text class="primary-btn-text">{{ generating ? '生成中...' : '生成时段' }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put } from '../../utils/request'
import type { TimeSlot } from '@mp-pilates/shared'
const tabs = [
{ key: 'add', label: '新增时段' },
{ key: 'close', label: '关闭时段' },
{ key: 'generate', label: '批量生成' },
]
const activeTab = ref<string>('add')
// ── Add slot form ─────────────────────────────────
const todayStr = new Date().toISOString().slice(0, 10)
const addForm = ref({
date: todayStr,
startTime: '09:00',
endTime: '10:00',
capacityStr: '10',
})
const addingSlot = ref(false)
async function handleAddSlot() {
if (addingSlot.value) return
const capacity = parseInt(addForm.value.capacityStr, 10)
if (!addForm.value.date || !addForm.value.startTime || !addForm.value.endTime) {
uni.showToast({ title: '请完整填写信息', icon: 'none' })
return
}
addingSlot.value = true
try {
await post('/admin/time-slot/manual', {
date: addForm.value.date,
startTime: addForm.value.startTime,
endTime: addForm.value.endTime,
capacity: isNaN(capacity) ? undefined : capacity,
})
uni.showToast({ title: '时段已添加', icon: 'success' })
addForm.value = { date: todayStr, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
} catch (e: any) {
uni.showToast({ title: e?.message ?? '添加失败', icon: 'none' })
} finally {
addingSlot.value = false
}
}
// ── Close slots ────────────────────────────────────
interface SlotRow extends TimeSlot {
bookedCount: number
}
const closeDateFilter = ref(todayStr)
const closeSlots = ref<SlotRow[]>([])
const loadingClose = ref(false)
async function fetchSlotsForClose() {
loadingClose.value = true
try {
const data = await get<SlotRow[]>(`/admin/time-slots?date=${closeDateFilter.value}`)
closeSlots.value = data
} catch {
closeSlots.value = []
} finally {
loadingClose.value = false
}
}
function confirmClose(slot: SlotRow) {
uni.showModal({
title: '关闭时段',
content: `确认关闭 ${slot.startTime.slice(0, 5)}${slot.endTime.slice(0, 5)} 的时段?`,
success: async (res) => {
if (res.confirm) {
try {
await put(`/admin/time-slot/${slot.id}/close`, {})
uni.showToast({ title: '已关闭', icon: 'success' })
await fetchSlotsForClose()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
},
})
}
// ── Generate slots ─────────────────────────────────
const generateDaysStr = ref('14')
const generating = ref(false)
async function handleGenerate() {
if (generating.value) return
const days = parseInt(generateDaysStr.value, 10)
if (isNaN(days) || days < 1 || days > 90) {
uni.showToast({ title: '请输入 190 天', icon: 'none' })
return
}
generating.value = true
try {
await post('/admin/generate-slots', { days })
uni.showToast({ title: '生成成功', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message ?? '生成失败', icon: 'none' })
} finally {
generating.value = false
}
}
onMounted(() => {
fetchSlotsForClose()
})
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
.page {
min-height: 100vh;
background: #f5f3f0;
}
/* ── Tabs ────────────────────────────────── */
.tabs {
display: flex;
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
}
.tab {
flex: 1;
padding: 28rpx 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&--active::after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
right: 20%;
height: 4rpx;
background: #1a1a2e;
border-radius: 2rpx;
}
}
.tab-text {
font-size: 28rpx;
color: #999;
.tab--active & {
color: #1a1a2e;
font-weight: 700;
}
}
/* ── Section ─────────────────────────────── */
.section {
padding: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 8rpx;
}
.section-sub {
font-size: 24rpx;
color: #999;
line-height: 1.6;
display: block;
margin-bottom: 24rpx;
}
/* ── Form card ───────────────────────────── */
.form-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
margin-bottom: 24rpx;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
&--last {
border-bottom: none;
}
}
.form-label {
font-size: 28rpx;
color: #555;
width: 160rpx;
flex-shrink: 0;
}
.picker-display {
display: flex;
align-items: center;
gap: 8rpx;
}
.picker-text {
font-size: 28rpx;
color: #222;
}
.picker-arrow {
font-size: 28rpx;
color: #bbb;
}
.form-input {
flex: 1;
text-align: right;
font-size: 28rpx;
color: #222;
}
/* ── Buttons ─────────────────────────────── */
.action-btn {
width: 100%;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.primary-btn {
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
&--loading {
opacity: 0.6;
}
}
.primary-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #c9a87c;
}
/* ── Close tab ───────────────────────────── */
.search-row {
margin-bottom: 20rpx;
}
.date-filter {
display: inline-flex;
align-items: center;
gap: 8rpx;
background: #ffffff;
border-radius: 32rpx;
padding: 12rpx 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
}
.date-filter-text {
font-size: 26rpx;
color: #1a1a2e;
font-weight: 600;
}
.date-filter-arrow {
font-size: 26rpx;
color: #bbb;
}
.skeleton-list {
margin-top: 16rpx;
}
.skeleton-item {
height: 100rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
.slot-list {
margin-top: 8rpx;
}
.slot-card {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx 28rpx;
margin-bottom: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
&--closed {
opacity: 0.5;
}
}
.slot-info {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.slot-time {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
}
.slot-cap {
font-size: 22rpx;
color: #999;
}
.close-btn {
background: #fde8e8;
border-radius: 8rpx;
padding: 12rpx 28rpx;
}
.close-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #c0392b;
}
.closed-tag {
background: #f0f0f0;
border-radius: 8rpx;
padding: 12rpx 28rpx;
}
.closed-tag-text {
font-size: 26rpx;
color: #999;
}
</style>

View File

@@ -1,15 +1,417 @@
<template>
<view class="page">
<view class="placeholder">
<text>工作室设置 - 待实现</text>
<!-- Loading state -->
<view v-if="loading" class="skeleton-page">
<view class="skeleton-section" />
<view class="skeleton-section" />
<view class="skeleton-section" />
</view>
<template v-else>
<!-- Banner preview -->
<view class="banner-preview" :style="bannerStyle">
<view class="banner-overlay">
<view class="banner-logo-wrap">
<image v-if="form.logo" class="banner-logo" :src="form.logo" mode="aspectFill" />
<view v-else class="banner-logo-placeholder">
<text class="banner-logo-text">{{ form.name.slice(0, 1) || '🏢' }}</text>
</view>
</view>
<text class="banner-name">{{ form.name || '工作室名称' }}</text>
</view>
</view>
<!-- Form card -->
<view class="form-card">
<text class="form-card-title">基本信息</text>
<view class="form-row">
<text class="form-label">工作室名称</text>
<input
class="form-input"
v-model="form.name"
placeholder="请输入名称"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
<view class="form-row">
<text class="form-label">地址</text>
<input
class="form-input"
v-model="form.address"
placeholder="请输入地址"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
<view class="form-row">
<text class="form-label">联系电话</text>
<input
class="form-input"
v-model="form.phone"
type="tel"
placeholder="请输入电话"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
<view class="form-row form-row--last">
<text class="form-label">Logo URL</text>
<input
class="form-input"
v-model="form.logo"
placeholder="图片链接(可选)"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
</view>
<!-- Settings card -->
<view class="form-card">
<text class="form-card-title">预约设置</text>
<view class="form-row">
<view class="label-group">
<text class="form-label">取消限制小时</text>
<text class="form-label-sub">课前多少小时内不允许取消</text>
</view>
<input
class="form-input form-input--short"
type="number"
v-model="form.cancelHoursLimitStr"
placeholder="如2"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
<view class="form-row form-row--last">
<view class="label-group">
<text class="form-label">宣传图 URL</text>
<text class="form-label-sub">首页横幅图片链接</text>
</view>
<input
class="form-input"
v-model="form.bannerUrl"
placeholder="图片链接(可选)"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
</view>
<!-- Location card -->
<view class="form-card">
<text class="form-card-title">位置坐标可选</text>
<view class="form-row">
<text class="form-label">纬度</text>
<input
class="form-input"
type="digit"
v-model="form.latitudeStr"
placeholder="如31.2304"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
<view class="form-row form-row--last">
<text class="form-label">经度</text>
<input
class="form-input"
type="digit"
v-model="form.longitudeStr"
placeholder="如121.4737"
placeholder-style="color:#bbb"
:disabled="saving"
/>
</view>
</view>
<!-- Save button -->
<view class="save-wrap">
<view
class="save-btn"
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty }"
@tap="handleSave"
>
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { get, put } from '../../utils/request'
import type { StudioConfig } from '@mp-pilates/shared'
// Form state
const form = ref({
name: '',
address: '',
phone: '',
logo: '',
bannerUrl: '',
cancelHoursLimitStr: '2',
latitudeStr: '',
longitudeStr: '',
})
const original = ref({ ...form.value })
const loading = ref(false)
const saving = ref(false)
const isDirty = computed(() =>
JSON.stringify(form.value) !== JSON.stringify(original.value),
)
const bannerStyle = computed(() => {
if (form.value.bannerUrl) {
return `background-image: url(${form.value.bannerUrl}); background-size: cover; background-position: center;`
}
return 'background: linear-gradient(135deg, #1a1a2e, #2d2d5e);'
})
async function fetchStudioInfo() {
loading.value = true
try {
const data = await get<StudioConfig>('/studio/info')
const initial = {
name: data.name ?? '',
address: data.address ?? '',
phone: data.phone ?? '',
logo: data.logo ?? '',
bannerUrl: data.bannerUrl ?? '',
cancelHoursLimitStr: String(data.cancelHoursLimit ?? 2),
latitudeStr: data.latitude != null ? String(data.latitude) : '',
longitudeStr: data.longitude != null ? String(data.longitude) : '',
}
form.value = { ...initial }
original.value = { ...initial }
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function handleSave() {
if (!isDirty.value || saving.value) return
const cancelHoursLimit = parseInt(form.value.cancelHoursLimitStr, 10)
if (isNaN(cancelHoursLimit) || cancelHoursLimit < 0) {
uni.showToast({ title: '取消限制小时数无效', icon: 'none' })
return
}
saving.value = true
try {
const payload: Record<string, unknown> = {
name: form.value.name.trim() || undefined,
address: form.value.address.trim() || undefined,
phone: form.value.phone.trim() || undefined,
logo: form.value.logo.trim() || undefined,
bannerUrl: form.value.bannerUrl.trim() || undefined,
cancelHoursLimit,
}
const lat = parseFloat(form.value.latitudeStr)
const lng = parseFloat(form.value.longitudeStr)
if (!isNaN(lat)) payload.latitude = lat
if (!isNaN(lng)) payload.longitude = lng
await put('/admin/studio/info', payload)
original.value = { ...form.value }
uni.showToast({ title: '保存成功', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
onMounted(fetchStudioInfo)
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 60rpx;
}
/* ── Skeleton ────────────────────────────── */
.skeleton-page {
padding: 0 24rpx;
padding-top: 280rpx;
}
.skeleton-section {
height: 200rpx;
border-radius: 20rpx;
margin-bottom: 24rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Banner preview ──────────────────────── */
.banner-preview {
height: 260rpx;
position: relative;
}
.banner-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.banner-logo-wrap {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
overflow: hidden;
border: 4rpx solid rgba(255, 255, 255, 0.4);
}
.banner-logo {
width: 96rpx;
height: 96rpx;
}
.banner-logo-placeholder {
width: 100%;
height: 100%;
background: #c9a87c;
display: flex;
align-items: center;
justify-content: center;
}
.banner-logo-text {
font-size: 40rpx;
font-weight: 700;
color: #1a1a2e;
}
.banner-name {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Form card ───────────────────────────── */
.form-card {
background: #ffffff;
border-radius: 20rpx;
margin: 24rpx 24rpx 0;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.form-card-title {
font-size: 26rpx;
font-weight: 700;
color: #999;
display: block;
padding: 24rpx 28rpx 0;
letter-spacing: 1rpx;
text-transform: uppercase;
}
.form-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx;
border-bottom: 1rpx solid #f5f5f5;
&--last {
border-bottom: none;
}
}
.form-label {
font-size: 28rpx;
color: #555;
width: 180rpx;
flex-shrink: 0;
font-weight: 500;
}
.form-label-sub {
font-size: 20rpx;
color: #bbb;
display: block;
margin-top: 4rpx;
}
.label-group {
width: 240rpx;
flex-shrink: 0;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: #222;
text-align: right;
background: transparent;
}
.form-input--short {
width: 100rpx;
flex: none;
text-align: right;
}
/* ── Save button ─────────────────────────── */
.save-wrap {
padding: 40rpx 24rpx;
}
.save-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
&:active { opacity: 0.85; }
&--loading,
&--disabled {
opacity: 0.5;
box-shadow: none;
}
}
.save-btn-text {
font-size: 32rpx;
font-weight: 700;
color: #c9a87c;
letter-spacing: 2rpx;
}
</style>

View File

@@ -1,15 +1,624 @@
<template>
<view class="page">
<view class="placeholder">
<text>排课设置 - 待实现</text>
<!-- Top toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ templates.length }} 条模板</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新增</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!templates.length" class="empty-state">
<text class="empty-icon">📅</text>
<text class="empty-text">暂无排课模板点击右上角新增</text>
</view>
<!-- Template list grouped by weekday -->
<template v-else>
<view v-for="day in weekDays" :key="day.value" class="day-group">
<view class="day-header">
<text class="day-label">{{ day.label }}</text>
<text class="day-count">{{ dayTemplates(day.value).length }} </text>
</view>
<view v-if="!dayTemplates(day.value).length" class="day-empty">
<text class="day-empty-text">该天无课</text>
</view>
<view
v-for="tpl in dayTemplates(day.value)"
:key="tpl.id"
class="tpl-card"
:class="{ 'tpl-card--inactive': !tpl.isActive }"
>
<view class="tpl-main">
<view class="tpl-time-block">
<text class="tpl-time">{{ tpl.startTime.slice(0, 5) }}{{ tpl.endTime.slice(0, 5) }}</text>
<view class="tpl-status-dot" :class="tpl.isActive ? 'dot--active' : 'dot--inactive'" />
</view>
<view class="tpl-meta">
<text class="tpl-capacity">容量 {{ tpl.capacity }} </text>
<text class="tpl-active-label">{{ tpl.isActive ? '启用中' : '已停用' }}</text>
</view>
</view>
<view class="tpl-actions">
<view class="action-btn edit-btn" @tap="openEdit(tpl)">
<text class="action-btn-text">编辑</text>
</view>
<view
class="action-btn toggle-btn"
:class="tpl.isActive ? 'toggle-btn--off' : 'toggle-btn--on'"
@tap="toggleActive(tpl)"
>
<text class="action-btn-text">{{ tpl.isActive ? '停用' : '启用' }}</text>
</view>
<view class="action-btn delete-btn" @tap="confirmDelete(tpl)">
<text class="action-btn-text">删除</text>
</view>
</view>
</view>
</view>
</template>
<!-- Save all button -->
<view v-if="dirty" class="save-bar">
<view class="save-bar-btn" :class="{ 'save-bar-btn--loading': saving }" @tap="saveAll">
<text class="save-bar-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<view class="modal">
<text class="modal-title">{{ editTarget ? '编辑模板' : '新增模板' }}</text>
<view class="modal-field">
<text class="modal-label">星期</text>
<picker mode="selector" :range="weekDays" range-key="label" :value="form.dayOfWeek" @change="onDayChange">
<view class="picker-display">
<text class="picker-text">{{ weekDays[form.dayOfWeek].label }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<picker mode="time" :value="form.startTime" @change="(e: any) => form.startTime = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ form.startTime }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<picker mode="time" :value="form.endTime" @change="(e: any) => form.endTime = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ form.endTime }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">容量</text>
<input
class="modal-input"
type="number"
v-model="form.capacityStr"
placeholder="如10"
placeholder-style="color:#bbb"
/>
</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>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { get, put } from '../../utils/request'
import type { WeekTemplate, WeekTemplateInput } from '@mp-pilates/shared'
const templates = ref<WeekTemplate[]>([])
const loading = ref(false)
const saving = ref(false)
const dirty = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<WeekTemplate | null>(null)
const weekDays = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 0 },
]
const form = ref({
dayOfWeek: 0,
startTime: '09:00',
endTime: '10:00',
capacityStr: '10',
})
function dayTemplates(dayVal: number) {
return templates.value.filter((t) => t.dayOfWeek === dayVal)
}
async function fetchTemplates() {
loading.value = true
try {
const data = await get<WeekTemplate[]>('/admin/week-template')
templates.value = data
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function openAdd() {
editTarget.value = null
form.value = { dayOfWeek: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
showModal.value = true
}
function openEdit(tpl: WeekTemplate) {
editTarget.value = tpl
form.value = {
dayOfWeek: weekDays.findIndex((d) => d.value === tpl.dayOfWeek),
startTime: tpl.startTime.slice(0, 5),
endTime: tpl.endTime.slice(0, 5),
capacityStr: String(tpl.capacity),
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
function onDayChange(e: any) {
form.value.dayOfWeek = Number(e.detail.value)
}
async function submitForm() {
const capacity = parseInt(form.value.capacityStr, 10)
if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请输入有效容量', icon: 'none' })
return
}
const dayVal = weekDays[form.value.dayOfWeek].value
if (editTarget.value) {
// Update in local list
const idx = templates.value.findIndex((t) => t.id === editTarget.value!.id)
if (idx !== -1) {
templates.value[idx] = {
...templates.value[idx],
dayOfWeek: dayVal,
startTime: form.value.startTime,
endTime: form.value.endTime,
capacity,
}
}
} else {
// Add locally with a temp id
templates.value.push({
id: `tmp_${Date.now()}`,
dayOfWeek: dayVal,
startTime: form.value.startTime,
endTime: form.value.endTime,
capacity,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as WeekTemplate)
}
dirty.value = true
closeModal()
}
function toggleActive(tpl: WeekTemplate) {
const idx = templates.value.findIndex((t) => t.id === tpl.id)
if (idx !== -1) {
templates.value[idx] = { ...templates.value[idx], isActive: !templates.value[idx].isActive }
dirty.value = true
}
}
function confirmDelete(tpl: WeekTemplate) {
uni.showModal({
title: '确认删除',
content: `删除 ${weekDays.find((d) => d.value === tpl.dayOfWeek)?.label} ${tpl.startTime.slice(0, 5)} 的模板?`,
success: (res) => {
if (res.confirm) {
templates.value = templates.value.filter((t) => t.id !== tpl.id)
dirty.value = true
}
},
})
}
async function saveAll() {
if (saving.value) return
saving.value = true
try {
const payload: WeekTemplateInput[] = templates.value.map((t) => ({
dayOfWeek: t.dayOfWeek,
startTime: t.startTime,
endTime: t.endTime,
capacity: t.capacity,
isActive: t.isActive,
}))
await put('/admin/week-template', { templates: payload })
dirty.value = false
uni.showToast({ title: '保存成功', icon: 'success' })
await fetchTemplates()
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
} finally {
saving.value = false
}
}
onMounted(fetchTemplates)
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 160rpx;
}
/* ── Toolbar ────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint {
font-size: 24rpx;
color: #999;
}
.add-btn {
background: #1a1a2e;
border-radius: 32rpx;
padding: 12rpx 32rpx;
}
.add-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #c9a87c;
}
/* ── Skeleton ───────────────────────────── */
.skeleton-list {
padding: 0 24rpx;
}
.skeleton-item {
height: 120rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ──────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 20rpx;
}
.empty-icon {
font-size: 80rpx;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
}
/* ── Day group ──────────────────────────── */
.day-group {
margin: 0 24rpx 24rpx;
}
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 12rpx;
}
.day-label {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
}
.day-count {
font-size: 22rpx;
color: #c9a87c;
}
.day-empty {
padding: 20rpx 0;
}
.day-empty-text {
font-size: 24rpx;
color: #ccc;
}
/* ── Template card ──────────────────────── */
.tpl-card {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
&--inactive {
opacity: 0.55;
}
}
.tpl-main {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.tpl-time-block {
display: flex;
align-items: center;
gap: 12rpx;
}
.tpl-time {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
}
.tpl-status-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
}
.dot--active { background: #27ae60; }
.dot--inactive { background: #ccc; }
.tpl-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4rpx;
}
.tpl-capacity {
font-size: 24rpx;
color: #555;
}
.tpl-active-label {
font-size: 22rpx;
color: #999;
}
.tpl-actions {
display: flex;
gap: 12rpx;
}
.action-btn {
flex: 1;
padding: 12rpx 0;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn-text {
font-size: 24rpx;
font-weight: 600;
}
.edit-btn {
background: #f0f0f0;
.action-btn-text { color: #1a1a2e; }
}
.toggle-btn--off {
background: #fff3cd;
.action-btn-text { color: #a07000; }
}
.toggle-btn--on {
background: #d4edda;
.action-btn-text { color: #155724; }
}
.delete-btn {
background: #fde8e8;
.action-btn-text { color: #c0392b; }
}
/* ── Save bar ───────────────────────────── */
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx;
background: #ffffff;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
}
.save-bar-btn {
width: 100%;
height: 88rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&--loading {
opacity: 0.6;
}
}
.save-bar-text {
font-size: 30rpx;
font-weight: 700;
color: #c9a87c;
}
/* ── Modal ──────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 100;
}
.modal {
width: 100%;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx 32rpx 60rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 32rpx;
}
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.modal-label {
font-size: 28rpx;
color: #555;
width: 160rpx;
flex-shrink: 0;
}
.picker-display {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8rpx;
}
.picker-text {
font-size: 28rpx;
color: #222;
}
.picker-arrow {
font-size: 28rpx;
color: #bbb;
}
.modal-input {
flex: 1;
text-align: right;
font-size: 28rpx;
color: #222;
}
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 40rpx;
}
.modal-cancel {
flex: 1;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-cancel-text {
font-size: 28rpx;
color: #555;
}
.modal-confirm {
flex: 2;
height: 88rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&--loading {
opacity: 0.6;
}
}
.modal-confirm-text {
font-size: 28rpx;
font-weight: 700;
color: #c9a87c;
}
</style>