feat(card): add cover image support for card types

This commit is contained in:
richarjiang
2026-04-15 23:50:12 +08:00
parent 4dacd908a6
commit b02f38dcc7
9 changed files with 262 additions and 14 deletions

View File

@@ -30,10 +30,18 @@
class="card-row"
@tap="goToDetail(card.id)"
>
<!-- Card Cover clean minimal design -->
<view class="card-cover" :class="getCardCoverClass(card.type)">
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
<!-- Card Cover image if available, gradient fallback -->
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
<image
v-if="card.coverUrl"
class="card-cover-img"
:src="card.coverUrl"
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</view>
<!-- Card info aligns with card-cover height -->
@@ -178,6 +186,11 @@ function goToAllCards() {
position: relative;
}
.card-cover-img {
width: 100%;
height: 100%;
}
/* Decorative circles */
.cover-deco {
position: absolute;

View File

@@ -188,6 +188,32 @@
auto-height
/>
</view>
<!-- Cover image upload -->
<view class="modal-field modal-field--cover">
<text class="modal-label">封面图</text>
<view class="cover-upload-area">
<view v-if="form.coverUrl" class="cover-preview-wrap">
<image class="cover-preview-img" :src="form.coverUrl" mode="aspectFill" />
<view class="cover-remove-btn" @tap="clearCover">
<text class="cover-remove-icon"></text>
</view>
</view>
<view
v-else
class="cover-upload-btn"
:class="{ 'cover-upload-btn--loading': uploadingCover }"
@tap="uploadCover"
>
<text v-if="uploadingCover" class="cover-upload-hint">上传中...</text>
<template v-else>
<text class="cover-upload-plus"></text>
<text class="cover-upload-hint">上传封面</text>
</template>
</view>
<text class="cover-upload-tip">可选建议 3:2 比例</text>
</view>
</view>
</view>
<!-- Action buttons -->
@@ -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<CardType[]>([])
const loading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const uploadingCover = ref(false)
const editTarget = ref<CardType | null>(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<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 || '选择图片失败'))
},
})
})
}
// ─── 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;
}
</style>

View File

@@ -37,10 +37,18 @@
class="card-row"
@tap="goToDetail(c.id)"
>
<!-- Card Cover clean minimal -->
<view class="card-cover" :class="getCardCoverClass(c.type)">
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
<!-- Card Cover image if available, gradient fallback -->
<view class="card-cover" :class="c.coverUrl ? '' : getCardCoverClass(c.type)">
<image
v-if="c.coverUrl"
class="card-cover-img"
:src="c.coverUrl"
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</view>
<!-- Card info aligns with card-cover height -->
@@ -77,10 +85,19 @@
<!-- Card content (single card mode) -->
<template v-else>
<!-- Hero section -->
<view class="card-hero" :class="heroClass">
<!-- Decorative circles -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
<!-- Cover image background -->
<image
v-if="cardData.coverUrl"
class="hero-cover-img"
:src="cardData.coverUrl"
mode="aspectFill"
/>
<!-- Decorative circles (only when no cover image) -->
<template v-else>
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
</template>
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
@@ -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%;

View File

@@ -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")

View File

@@ -37,6 +37,10 @@ export class CreateCardTypeDto {
@IsString()
description?: string
@IsOptional()
@IsString()
coverUrl?: string
@IsOptional()
@IsInt()
@Min(0)

View File

@@ -42,6 +42,10 @@ export class UpdateCardTypeDto {
@IsString()
description?: string
@IsOptional()
@IsString()
coverUrl?: string
@IsOptional()
@IsBoolean()
isActive?: boolean

View File

@@ -10,6 +10,6 @@ export class CreateStudioUploadCredentialDto {
contentType?: string
@IsOptional()
@IsIn(['gallery', 'logo', 'banner'])
@IsIn(['gallery', 'logo', 'banner', 'card-cover'])
assetType?: StudioAssetType
}

View File

@@ -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
}

View File

@@ -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