1047 lines
27 KiB
Vue
1047 lines
27 KiB
Vue
<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>
|