diff --git a/packages/app/src/components/CardShop.vue b/packages/app/src/components/CardShop.vue index 6414a6a..94d3a6d 100644 --- a/packages/app/src/components/CardShop.vue +++ b/packages/app/src/components/CardShop.vue @@ -30,10 +30,18 @@ class="card-row" @tap="goToDetail(card.id)" > - - - - + + + + @@ -178,6 +186,11 @@ function goToAllCards() { position: relative; } +.card-cover-img { + width: 100%; + height: 100%; +} + /* Decorative circles */ .cover-deco { position: absolute; diff --git a/packages/app/src/pages/admin/card-types.vue b/packages/app/src/pages/admin/card-types.vue index ffddae5..b627f0a 100644 --- a/packages/app/src/pages/admin/card-types.vue +++ b/packages/app/src/pages/admin/card-types.vue @@ -188,6 +188,32 @@ auto-height /> + + + + 封面图 + + + + + + + + + 上传中... + + + 可选,建议 3:2 比例 + + @@ -215,6 +241,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue' import { getSystemLayout } from '../../utils/system' import { useAdminStore } from '../../stores/admin' import { formatPrice } from '../../utils/format' +import { uploadStudioAsset } from '../../utils/studio-upload' import { CardTypeCategory } from '@mp-pilates/shared' import type { CardType } from '@mp-pilates/shared' @@ -229,6 +256,7 @@ const cardTypes = ref([]) const loading = ref(false) const showModal = ref(false) const submitting = ref(false) +const uploadingCover = ref(false) const editTarget = ref(null) const typeOptions = [ @@ -246,6 +274,7 @@ const defaultForm = () => ({ durationDaysStr: '90', sortOrderStr: '0', description: '', + coverUrl: '', }) const form = ref(defaultForm()) @@ -282,6 +311,7 @@ function openEdit(ct: CardType) { durationDaysStr: String(ct.durationDays), sortOrderStr: String(ct.sortOrder), description: ct.description ?? '', + coverUrl: ct.coverUrl ?? '', } showModal.value = true } @@ -349,6 +379,9 @@ async function submitForm() { if (form.value.description.trim()) { payload.description = form.value.description.trim() } + if (form.value.coverUrl) { + payload.coverUrl = form.value.coverUrl + } submitting.value = true try { @@ -431,6 +464,85 @@ function confirmDelete(ct: CardType) { }) } +// ─── Cover image upload ───────────────────────────── + +async function uploadCover() { + if (uploadingCover.value) return + + try { + const file = await chooseSingleImage() + if (!file) return + + uploadingCover.value = true + const url = await uploadStudioAsset({ + adminStore, + filePath: file.path, + fileName: file.name, + assetType: 'card-cover', + }) + form.value.coverUrl = url + uni.showToast({ title: '上传成功', icon: 'success' }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : '上传失败' + uni.showToast({ title: message, icon: 'none' }) + } finally { + uploadingCover.value = false + } +} + +function clearCover() { + form.value.coverUrl = '' +} + +interface PickedImage { + readonly path: string + readonly name: string +} + +function extractFileName(filePath: string): string { + return filePath.split('/').pop() || `image_${Date.now()}.jpg` +} + +function chooseSingleImage(): Promise { + 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 || '选择图片失败')) + }, + }) + }) +} + // ─── Helpers ───────────────────────────────────────── function typeLabel(ct: CardType): string { @@ -721,4 +833,82 @@ onMounted(fetchCardTypes) } .modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; } + +/* ── Cover upload ───────────────────────── */ +.modal-field--cover { + flex-direction: column; + align-items: flex-start; + gap: 16rpx; + border-bottom: none; +} + +.cover-upload-area { + width: 100%; + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.cover-preview-wrap { + position: relative; + width: 300rpx; + height: 200rpx; + border-radius: 12rpx; + overflow: hidden; +} + +.cover-preview-img { + width: 100%; + height: 100%; +} + +.cover-remove-btn { + position: absolute; + top: 8rpx; + right: 8rpx; + width: 44rpx; + height: 44rpx; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.cover-remove-icon { + font-size: 20rpx; + color: #ffffff; +} + +.cover-upload-btn { + width: 300rpx; + height: 200rpx; + border: 2rpx dashed #ddd; + border-radius: 12rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8rpx; + background: #fafafa; + + &:active { background: #f0f0f0; } + &--loading { opacity: 0.6; pointer-events: none; } +} + +.cover-upload-plus { + font-size: 48rpx; + color: #bbb; + line-height: 1; +} + +.cover-upload-hint { + font-size: 22rpx; + color: #999; +} + +.cover-upload-tip { + font-size: 20rpx; + color: #bbb; +} diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue index f1d28e6..24a34a5 100644 --- a/packages/app/src/pages/card/detail.vue +++ b/packages/app/src/pages/card/detail.vue @@ -37,10 +37,18 @@ class="card-row" @tap="goToDetail(c.id)" > - - - - + + + + @@ -77,10 +85,19 @@