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 @@
-
-
-
-
+
+
+
+
+
+
+
+
{{ typeLabel }}
@@ -456,6 +473,18 @@ onMounted(() => {
&.hero--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
+
+ &.hero--custom {
+ background: #333;
+ }
+}
+
+.hero-cover-img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0.5;
}
/* Decorative background circles */
@@ -737,6 +766,11 @@ onMounted(() => {
position: relative;
}
+.card-cover-img {
+ width: 100%;
+ height: 100%;
+}
+
.cover-deco {
position: absolute;
border-radius: 50%;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index bd94e26..231627a 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -120,6 +120,7 @@ model CardType {
price Decimal @db.Decimal(10, 0)
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
description String?
+ coverUrl String? @map("cover_url")
isActive Boolean @default(true) @map("is_active")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
diff --git a/packages/server/src/membership/dto/create-card-type.dto.ts b/packages/server/src/membership/dto/create-card-type.dto.ts
index 5d74202..6d9ebb7 100644
--- a/packages/server/src/membership/dto/create-card-type.dto.ts
+++ b/packages/server/src/membership/dto/create-card-type.dto.ts
@@ -37,6 +37,10 @@ export class CreateCardTypeDto {
@IsString()
description?: string
+ @IsOptional()
+ @IsString()
+ coverUrl?: string
+
@IsOptional()
@IsInt()
@Min(0)
diff --git a/packages/server/src/membership/dto/update-card-type.dto.ts b/packages/server/src/membership/dto/update-card-type.dto.ts
index fce225b..93c95e2 100644
--- a/packages/server/src/membership/dto/update-card-type.dto.ts
+++ b/packages/server/src/membership/dto/update-card-type.dto.ts
@@ -42,6 +42,10 @@ export class UpdateCardTypeDto {
@IsString()
description?: string
+ @IsOptional()
+ @IsString()
+ coverUrl?: string
+
@IsOptional()
@IsBoolean()
isActive?: boolean
diff --git a/packages/server/src/studio/dto/create-studio-upload-credential.dto.ts b/packages/server/src/studio/dto/create-studio-upload-credential.dto.ts
index e61a7d3..54bf843 100644
--- a/packages/server/src/studio/dto/create-studio-upload-credential.dto.ts
+++ b/packages/server/src/studio/dto/create-studio-upload-credential.dto.ts
@@ -10,6 +10,6 @@ export class CreateStudioUploadCredentialDto {
contentType?: string
@IsOptional()
- @IsIn(['gallery', 'logo', 'banner'])
+ @IsIn(['gallery', 'logo', 'banner', 'card-cover'])
assetType?: StudioAssetType
}
diff --git a/packages/shared/src/types/card-type.ts b/packages/shared/src/types/card-type.ts
index d65d751..065680a 100644
--- a/packages/shared/src/types/card-type.ts
+++ b/packages/shared/src/types/card-type.ts
@@ -9,6 +9,7 @@ export interface CardType {
readonly price: number
readonly originalPrice: number | null
readonly description: string | null
+ readonly coverUrl: string | null
readonly isActive: boolean
readonly sortOrder: number
readonly createdAt: string
@@ -23,6 +24,7 @@ export interface CreateCardTypeDto {
readonly price: number
readonly originalPrice?: number
readonly description?: string
+ readonly coverUrl?: string
readonly sortOrder?: number
}
diff --git a/packages/shared/src/types/studio.ts b/packages/shared/src/types/studio.ts
index 66108f4..e3f3fae 100644
--- a/packages/shared/src/types/studio.ts
+++ b/packages/shared/src/types/studio.ts
@@ -12,7 +12,7 @@ export interface StudioConfig {
readonly updatedAt: string
}
-export type StudioAssetType = 'gallery' | 'logo' | 'banner'
+export type StudioAssetType = 'gallery' | 'logo' | 'banner' | 'card-cover'
export interface UpdateStudioConfigDto {
readonly name?: string