Files
mp-pilates/packages/app/src/pages/admin/studio.vue
2026-04-15 13:58:51 +08:00

1047 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="工作室设置" show-back />
<view v-if="loading" class="skeleton-page">
<view class="skeleton-section" />
<view class="skeleton-section" />
<view class="skeleton-section" />
<view class="skeleton-section" />
</view>
<template v-else>
<view class="hero-card" :style="bannerStyle">
<view class="hero-overlay">
<view class="hero-topbar">
<view class="hero-pill">Studio Console</view>
<view class="hero-meta">{{ photoCountText }}</view>
</view>
<view class="hero-body">
<view class="hero-logo-wrap">
<image v-if="form.logo" class="hero-logo" :src="form.logo" mode="aspectFill" />
<view v-else class="hero-logo-placeholder">
<text class="hero-logo-text">{{ form.name.slice(0, 1) || 'P' }}</text>
</view>
</view>
<view class="hero-copy">
<text class="hero-title">{{ form.name || '工作室名称' }}</text>
<text class="hero-subtitle">管理品牌视觉地址与工作室画廊</text>
</view>
</view>
</view>
</view>
<view class="form-card">
<view class="section-head">
<text class="section-title">基本信息</text>
<text class="section-desc">Logo 和横幅通过 COS 上传保存时一并写入工作室配置</text>
</view>
<view class="upload-grid upload-grid--duo">
<view class="upload-block">
<view class="upload-preview" @tap="previewSingleImage(form.logo)">
<image v-if="form.logo" class="upload-image" :src="form.logo" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-placeholder-title">Logo</text>
<text class="upload-placeholder-desc">建议正方形</text>
</view>
<view v-if="uploadingLogo" class="upload-mask">
<text class="upload-mask-text">上传中</text>
</view>
</view>
<view class="upload-actions">
<view class="mini-btn" :class="{ 'mini-btn--disabled': saving || uploadingLogo }" @tap="uploadSingleAsset('logo')">
<text class="mini-btn-text">上传 Logo</text>
</view>
<view
v-if="form.logo"
class="mini-btn mini-btn--ghost"
:class="{ 'mini-btn--disabled': saving || uploadingLogo }"
@tap="clearImage('logo')"
>
<text class="mini-btn-text mini-btn-text--ghost">删除</text>
</view>
</view>
</view>
<view class="upload-block">
<view class="upload-preview" @tap="previewSingleImage(form.bannerUrl)">
<image v-if="form.bannerUrl" class="upload-image" :src="form.bannerUrl" mode="aspectFill" />
<view v-else class="upload-placeholder upload-placeholder--banner">
<text class="upload-placeholder-title">横幅</text>
<text class="upload-placeholder-desc">建议 3:1</text>
</view>
<view v-if="uploadingBanner" class="upload-mask">
<text class="upload-mask-text">上传中</text>
</view>
</view>
<view class="upload-actions">
<view class="mini-btn" :class="{ 'mini-btn--disabled': saving || uploadingBanner }" @tap="uploadSingleAsset('banner')">
<text class="mini-btn-text">上传横幅</text>
</view>
<view
v-if="form.bannerUrl"
class="mini-btn mini-btn--ghost"
:class="{ 'mini-btn--disabled': saving || uploadingBanner }"
@tap="clearImage('banner')"
>
<text class="mini-btn-text mini-btn-text--ghost">删除</text>
</view>
</view>
</view>
</view>
<view class="form-row">
<text class="form-label">工作室名称</text>
<input class="form-input" v-model="form.name" placeholder="请输入名称" placeholder-style="color:#b7afa7" :disabled="saving" />
</view>
<view class="form-row">
<text class="form-label">地址</text>
<input class="form-input" v-model="form.address" placeholder="请输入地址" placeholder-style="color:#b7afa7" :disabled="saving" />
</view>
<view class="form-row form-row--last">
<text class="form-label">联系电话</text>
<input class="form-input" v-model="form.phone" type="tel" placeholder="请输入电话" placeholder-style="color:#b7afa7" :disabled="saving" />
</view>
</view>
<view class="form-card">
<view class="section-head">
<text class="section-title">工作室画廊</text>
<text class="section-desc">支持上传删除和排序当前顺序就是首页展示顺序</text>
</view>
<view class="gallery-toolbar">
<text class="gallery-tip-text">最多 {{ MAX_GALLERY_IMAGES }} 建议横图</text>
<view
class="mini-btn gallery-add-btn"
:class="{ 'mini-btn--disabled': saving || uploadingGallery || form.photos.length >= MAX_GALLERY_IMAGES }"
@tap="handlePickGalleryImages"
>
<text class="mini-btn-text">新增图片</text>
</view>
</view>
<view v-if="form.photos.length" class="gallery-grid">
<view v-for="(photo, index) in form.photos" :key="photo + '-' + index" class="gallery-card">
<view class="gallery-image-wrap" @tap="previewGallery(index)">
<image class="gallery-image" :src="photo" mode="aspectFill" />
<view class="gallery-index">
<text class="gallery-index-text">{{ index + 1 }}</text>
</view>
</view>
<view class="gallery-card-actions">
<view class="tiny-btn" :class="{ 'tiny-btn--disabled': index === 0 || saving || uploadingGallery }" @tap="movePhoto(index, -1)">
<text class="tiny-btn-text">前移</text>
</view>
<view class="tiny-btn" :class="{ 'tiny-btn--disabled': index === form.photos.length - 1 || saving || uploadingGallery }" @tap="movePhoto(index, 1)">
<text class="tiny-btn-text">后移</text>
</view>
<view class="tiny-btn tiny-btn--danger" :class="{ 'tiny-btn--disabled': saving || uploadingGallery }" @tap="removePhoto(index)">
<text class="tiny-btn-text tiny-btn-text--danger">删除</text>
</view>
</view>
</view>
</view>
<view v-else class="gallery-empty">
<text class="gallery-empty-title">暂无画廊图片</text>
<text class="gallery-empty-desc">上传后首页工作室模块会直接使用这里的列表</text>
</view>
</view>
<view class="form-card">
<view class="section-head">
<text class="section-title">预约与位置</text>
<text class="section-desc">取消限制控制课前多少小时内不可取消</text>
</view>
<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:#b7afa7" :disabled="saving" />
</view>
<view class="form-row">
<text class="form-label">纬度</text>
<input class="form-input" type="digit" v-model="form.latitudeStr" placeholder="如 22.567048" placeholder-style="color:#b7afa7" :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="如 113.867227" placeholder-style="color:#b7afa7" :disabled="saving" />
</view>
</view>
<view class="save-wrap">
<view class="save-panel">
<view class="save-copy">
<text class="save-title">保存工作室配置</text>
<text class="save-desc">画廊Logo横幅和基础信息会一起写入服务端配置</text>
</view>
<view class="save-btn" :class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty || uploadingAny }" @tap="handleSave">
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
</view>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { getErrorMessage } from '../../utils/auth'
import { uploadStudioAsset } from '../../utils/studio-upload'
import { getSystemLayout } from '../../utils/system'
type FormState = {
name: string
address: string
phone: string
logo: string
bannerUrl: string
cancelHoursLimitStr: string
latitudeStr: string
longitudeStr: string
photos: string[]
}
type SingleAssetType = 'logo' | 'banner'
type PickedImage = {
path: string
name: string
}
const MAX_GALLERY_IMAGES = 12
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const saving = ref(false)
const uploadingLogo = ref(false)
const uploadingBanner = ref(false)
const uploadingGallery = ref(false)
const form = ref<FormState>(createEmptyForm())
const original = ref<FormState>(createEmptyForm())
const uploadingAny = computed(() => uploadingLogo.value || uploadingBanner.value || uploadingGallery.value)
const isDirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(original.value))
const photoCountText = computed(() => '画廊 ' + form.value.photos.length + ' 张')
const bannerStyle = computed(() => {
const bannerUrl = form.value.bannerUrl.trim()
if (bannerUrl) {
return 'background-image: url(' + bannerUrl + '); background-size: cover; background-position: center;'
}
return 'background: linear-gradient(140deg, #2e384d 0%, #50627f 45%, #c48b63 100%);'
})
onMounted(() => {
navBarHeight.value = String(getSystemLayout().navBarHeight) + 'px'
fetchStudioInfo()
})
function createEmptyForm(): FormState {
return {
name: '',
address: '',
phone: '',
logo: '',
bannerUrl: '',
cancelHoursLimitStr: '2',
latitudeStr: '',
longitudeStr: '',
photos: [],
}
}
function cloneFormState(state: FormState): FormState {
return {
...state,
photos: [...state.photos],
}
}
async function fetchStudioInfo() {
loading.value = true
try {
const data = await adminStore.fetchStudioConfig()
const initial: FormState = {
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) : '',
photos: [...(data.photos ?? [])],
}
form.value = cloneFormState(initial)
original.value = cloneFormState(initial)
} catch (error) {
uni.showToast({ title: getErrorMessage(error, '加载失败'), icon: 'none' })
} finally {
loading.value = false
}
}
async function uploadSingleAsset(assetType: SingleAssetType) {
const loadingRef = assetType === 'logo' ? uploadingLogo : uploadingBanner
if (saving.value || loadingRef.value) {
return
}
try {
const file = await chooseSingleImage()
if (!file) {
return
}
loadingRef.value = true
const url = await uploadStudioAsset({
adminStore,
filePath: file.path,
fileName: file.name,
assetType,
})
if (assetType === 'logo') {
form.value.logo = url
} else {
form.value.bannerUrl = url
}
uni.showToast({ title: '上传成功', icon: 'success' })
} catch (error) {
uni.showToast({ title: getErrorMessage(error, '上传失败'), icon: 'none' })
} finally {
loadingRef.value = false
}
}
async function handlePickGalleryImages() {
if (saving.value || uploadingGallery.value) {
return
}
const remainCount = MAX_GALLERY_IMAGES - form.value.photos.length
if (remainCount <= 0) {
uni.showToast({ title: '最多上传 ' + String(MAX_GALLERY_IMAGES) + ' 张', icon: 'none' })
return
}
try {
const files = await chooseMultipleImages(remainCount)
if (files.length === 0) {
return
}
uploadingGallery.value = true
const uploadedUrls = await Promise.all(
files.map((file) =>
uploadStudioAsset({
adminStore,
filePath: file.path,
fileName: file.name,
assetType: 'gallery',
}),
),
)
form.value.photos = [...form.value.photos, ...uploadedUrls]
uni.showToast({ title: '图片已加入画廊', icon: 'success' })
} catch (error) {
uni.showToast({ title: getErrorMessage(error, '上传失败'), icon: 'none' })
} finally {
uploadingGallery.value = false
}
}
function clearImage(type: SingleAssetType) {
if (saving.value || uploadingAny.value) {
return
}
if (type === 'logo') {
form.value.logo = ''
return
}
form.value.bannerUrl = ''
}
function previewSingleImage(url: string) {
if (!url) {
return
}
uni.previewImage({ current: url, urls: [url] })
}
function previewGallery(index: number) {
if (form.value.photos.length === 0) {
return
}
uni.previewImage({ current: index, urls: form.value.photos })
}
function removePhoto(index: number) {
if (saving.value || uploadingGallery.value) {
return
}
form.value.photos = form.value.photos.filter((_, currentIndex) => currentIndex !== index)
}
function movePhoto(index: number, direction: -1 | 1) {
if (saving.value || uploadingGallery.value) {
return
}
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= form.value.photos.length) {
return
}
const nextPhotos = [...form.value.photos]
const moved = nextPhotos.splice(index, 1)[0]
nextPhotos.splice(targetIndex, 0, moved)
form.value.photos = nextPhotos
}
async function handleSave() {
if (saving.value || uploadingAny.value || !isDirty.value) {
return
}
const cancelHoursLimit = parseInt(form.value.cancelHoursLimitStr, 10)
if (Number.isNaN(cancelHoursLimit) || cancelHoursLimit < 0) {
uni.showToast({ title: '取消限制小时数无效', icon: 'none' })
return
}
const latitude = parseCoordinate(form.value.latitudeStr)
const longitude = parseCoordinate(form.value.longitudeStr)
if (form.value.latitudeStr.trim() && latitude == null) {
uni.showToast({ title: '纬度格式无效', icon: 'none' })
return
}
if (form.value.longitudeStr.trim() && longitude == null) {
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,
latitude,
longitude,
photos: form.value.photos.map((item) => item.trim()).filter(Boolean),
}
const saved = await adminStore.saveStudioConfig(payload as any)
const normalized: FormState = {
name: saved.name ?? '',
address: saved.address ?? '',
phone: saved.phone ?? '',
logo: saved.logo ?? '',
bannerUrl: saved.bannerUrl ?? '',
cancelHoursLimitStr: String(saved.cancelHoursLimit ?? cancelHoursLimit),
latitudeStr: saved.latitude != null ? String(saved.latitude) : '',
longitudeStr: saved.longitude != null ? String(saved.longitude) : '',
photos: [...(saved.photos ?? [])],
}
form.value = cloneFormState(normalized)
original.value = cloneFormState(normalized)
uni.showToast({ title: '保存成功', icon: 'success' })
} catch (error) {
uni.showToast({ title: getErrorMessage(error, '保存失败'), icon: 'none' })
} finally {
saving.value = false
}
}
function parseCoordinate(raw: string): number | undefined {
const trimmed = raw.trim()
if (!trimmed) {
return undefined
}
const value = parseFloat(trimmed)
if (Number.isNaN(value)) {
return undefined
}
return value
}
function chooseSingleImage(): Promise<PickedImage | null> {
return new Promise((resolve, reject) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (result) => {
const tempFilePaths = Array.isArray(result.tempFilePaths)
? result.tempFilePaths
: typeof result.tempFilePaths === 'string'
? [result.tempFilePaths]
: []
const path = tempFilePaths[0]
if (!path) {
resolve(null)
return
}
const tempFiles = Array.isArray(result.tempFiles)
? result.tempFiles
: result.tempFiles
? [result.tempFiles]
: []
const file = tempFiles[0] as { path?: string; tempFilePath?: string; name?: string } | undefined
resolve({
path,
name: file?.name || extractFileName(file?.path || file?.tempFilePath || path),
})
},
fail: (error) => {
if ((error.errMsg || '').includes('cancel')) {
resolve(null)
return
}
reject(new Error(error.errMsg || '选择图片失败'))
},
})
})
}
function chooseMultipleImages(maxCount: number): Promise<PickedImage[]> {
return new Promise((resolve, reject) => {
uni.chooseImage({
count: maxCount,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (result) => {
const paths = Array.isArray(result.tempFilePaths)
? result.tempFilePaths
: typeof result.tempFilePaths === 'string'
? [result.tempFilePaths]
: []
const rawTempFiles = Array.isArray(result.tempFiles)
? result.tempFiles
: result.tempFiles
? [result.tempFiles]
: []
const tempFiles = rawTempFiles as Array<{ path?: string; tempFilePath?: string; name?: string }>
resolve(
paths.map((path: string, index: number) => ({
path,
name: tempFiles[index]?.name || extractFileName(tempFiles[index]?.path || tempFiles[index]?.tempFilePath || path),
})),
)
},
fail: (error) => {
if ((error.errMsg || '').includes('cancel')) {
resolve([])
return
}
reject(new Error(error.errMsg || '选择图片失败'))
},
})
})
}
function extractFileName(path: string): string {
const normalizedPath = path.split('?')[0]
const fileName = normalizedPath.split('/').pop()?.trim()
return fileName || 'studio-' + String(Date.now()) + '.jpg'
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(205, 176, 142, 0.22), transparent 28%),
linear-gradient(180deg, #f6f0e9 0%, #f9f6f1 22%, #f4efe8 100%);
padding-bottom: 72rpx;
}
.skeleton-page {
padding: 280rpx 24rpx 0;
}
.skeleton-section {
height: 220rpx;
border-radius: 28rpx;
margin-bottom: 24rpx;
background: linear-gradient(90deg, #f1ece5 25%, #e5ddd4 50%, #f1ece5 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.hero-card {
margin: 24rpx;
min-height: 320rpx;
border-radius: 32rpx;
overflow: hidden;
box-shadow: 0 18rpx 44rpx rgba(70, 55, 40, 0.12);
}
.hero-overlay {
min-height: 320rpx;
padding: 28rpx;
background:
linear-gradient(180deg, rgba(17, 21, 28, 0.14) 0%, rgba(17, 21, 28, 0.56) 100%),
linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0));
display: flex;
flex-direction: column;
justify-content: space-between;
}
.hero-topbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.hero-pill,
.hero-meta {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.14);
color: #fff7ef;
font-size: 22rpx;
letter-spacing: 1rpx;
}
.hero-body {
display: flex;
align-items: flex-end;
gap: 24rpx;
}
.hero-logo-wrap {
width: 132rpx;
height: 132rpx;
border-radius: 32rpx;
overflow: hidden;
background: rgba(255, 248, 240, 0.2);
border: 2rpx solid rgba(255, 255, 255, 0.22);
flex-shrink: 0;
}
.hero-logo,
.hero-logo-placeholder {
width: 100%;
height: 100%;
}
.hero-logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(255, 232, 214, 0.74), rgba(227, 194, 166, 0.88));
}
.hero-logo-text {
font-size: 56rpx;
color: #4b2d1b;
font-weight: 700;
}
.hero-copy {
display: flex;
flex-direction: column;
gap: 10rpx;
min-width: 0;
}
.hero-title {
color: #fff9f2;
font-size: 40rpx;
font-weight: 700;
line-height: 1.2;
}
.hero-subtitle {
color: rgba(255, 248, 241, 0.84);
font-size: 24rpx;
line-height: 1.5;
}
.form-card {
margin: 24rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10rpx 30rpx rgba(94, 73, 51, 0.06);
border: 1rpx solid rgba(188, 168, 145, 0.12);
overflow: hidden;
}
.section-head {
padding: 28rpx 28rpx 16rpx;
}
.section-title {
display: block;
font-size: 30rpx;
color: #433529;
font-weight: 700;
}
.section-desc {
display: block;
margin-top: 10rpx;
color: #8d7a69;
font-size: 22rpx;
line-height: 1.6;
}
.upload-grid {
display: grid;
gap: 20rpx;
padding: 0 28rpx 28rpx;
}
.upload-grid--duo {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.upload-block {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.upload-preview {
position: relative;
height: 240rpx;
overflow: hidden;
border-radius: 28rpx;
background: linear-gradient(135deg, #ede2d7, #d9cab9);
}
.upload-image {
width: 100%;
height: 100%;
}
.upload-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
color: #6e5846;
}
.upload-placeholder--banner {
background: linear-gradient(135deg, rgba(58, 78, 102, 0.88), rgba(197, 141, 95, 0.7));
color: #fff5ec;
}
.upload-placeholder-title {
font-size: 30rpx;
font-weight: 700;
}
.upload-placeholder-desc {
font-size: 22rpx;
opacity: 0.9;
}
.upload-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(32, 24, 19, 0.44);
}
.upload-mask-text {
color: #fff;
font-size: 24rpx;
}
.upload-actions {
display: flex;
gap: 12rpx;
}
.mini-btn {
min-width: 0;
flex: 1;
height: 72rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #4c5f7e 0%, #7087ab 100%);
display: flex;
align-items: center;
justify-content: center;
}
.gallery-add-btn {
flex: none;
width: 200rpx;
}
.mini-btn--ghost {
background: rgba(114, 91, 68, 0.1);
}
.mini-btn--disabled {
opacity: 0.45;
}
.mini-btn-text {
color: #fdf8f3;
font-size: 24rpx;
font-weight: 600;
}
.mini-btn-text--ghost {
color: #7a614d;
}
.form-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 26rpx 28rpx;
border-top: 1rpx solid rgba(188, 168, 145, 0.12);
}
.form-row--last {
border-bottom: none;
}
.form-label {
width: 172rpx;
flex-shrink: 0;
font-size: 28rpx;
color: #544132;
font-weight: 500;
}
.form-label-sub {
display: block;
margin-top: 6rpx;
font-size: 20rpx;
color: #9f8d7b;
}
.label-group {
width: 190rpx;
flex-shrink: 0;
}
.form-input {
flex: 1;
min-width: 0;
text-align: right;
font-size: 28rpx;
color: #2f241d;
}
.form-input--short {
flex: none;
width: 120rpx;
}
.gallery-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 0 28rpx 24rpx;
}
.gallery-tip-text {
color: #8d7a69;
font-size: 22rpx;
flex: 1;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
padding: 0 28rpx 28rpx;
}
.gallery-card {
padding: 14rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, #fffaf5 0%, #f7efe5 100%);
border: 1rpx solid rgba(200, 180, 160, 0.18);
}
.gallery-image-wrap {
position: relative;
height: 180rpx;
overflow: hidden;
border-radius: 18rpx;
background: #eadfd4;
}
.gallery-image {
width: 100%;
height: 100%;
}
.gallery-index {
position: absolute;
top: 12rpx;
left: 12rpx;
min-width: 40rpx;
height: 40rpx;
padding: 0 10rpx;
border-radius: 999rpx;
background: rgba(29, 24, 20, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.gallery-index-text {
color: #fff;
font-size: 22rpx;
font-weight: 700;
}
.gallery-card-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10rpx;
margin-top: 14rpx;
}
.tiny-btn {
height: 58rpx;
border-radius: 16rpx;
background: rgba(83, 99, 127, 0.12);
display: flex;
align-items: center;
justify-content: center;
}
.tiny-btn--danger {
background: rgba(187, 86, 78, 0.12);
}
.tiny-btn--disabled {
opacity: 0.38;
}
.tiny-btn-text {
color: #4e5f7a;
font-size: 22rpx;
font-weight: 600;
}
.tiny-btn-text--danger {
color: #b34a42;
}
.gallery-empty {
margin: 0 28rpx 28rpx;
padding: 48rpx 32rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #faf3ea, #f1e8dc);
}
.gallery-empty-title {
display: block;
color: #5c4636;
font-size: 28rpx;
font-weight: 700;
}
.gallery-empty-desc {
display: block;
margin-top: 10rpx;
color: #927d6a;
font-size: 22rpx;
line-height: 1.6;
}
.save-wrap {
padding: 8rpx 24rpx 0;
}
.save-panel {
border-radius: 28rpx;
background: linear-gradient(135deg, #2f3442 0%, #434c61 100%);
box-shadow: 0 16rpx 36rpx rgba(48, 53, 66, 0.18);
padding: 28rpx;
}
.save-copy {
margin-bottom: 24rpx;
}
.save-title {
display: block;
color: #fff4ea;
font-size: 30rpx;
font-weight: 700;
}
.save-desc {
display: block;
margin-top: 10rpx;
color: rgba(255, 244, 234, 0.76);
font-size: 22rpx;
line-height: 1.6;
}
.save-btn {
height: 96rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #d59b72 0%, #f0c09d 100%);
display: flex;
align-items: center;
justify-content: center;
}
.save-btn--loading,
.save-btn--disabled {
opacity: 0.46;
}
.save-btn-text {
color: #452f20;
font-size: 30rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
</style>