feat(card): add cover image support for card types
This commit is contained in:
@@ -30,10 +30,18 @@
|
|||||||
class="card-row"
|
class="card-row"
|
||||||
@tap="goToDetail(card.id)"
|
@tap="goToDetail(card.id)"
|
||||||
>
|
>
|
||||||
<!-- Card Cover — clean minimal design -->
|
<!-- Card Cover — image if available, gradient fallback -->
|
||||||
<view class="card-cover" :class="getCardCoverClass(card.type)">
|
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
|
||||||
<view class="cover-deco cover-deco--1" />
|
<image
|
||||||
<view class="cover-deco cover-deco--2" />
|
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>
|
</view>
|
||||||
|
|
||||||
<!-- Card info — aligns with card-cover height -->
|
<!-- Card info — aligns with card-cover height -->
|
||||||
@@ -178,6 +186,11 @@ function goToAllCards() {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-cover-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* Decorative circles */
|
/* Decorative circles */
|
||||||
.cover-deco {
|
.cover-deco {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -188,6 +188,32 @@
|
|||||||
auto-height
|
auto-height
|
||||||
/>
|
/>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
@@ -215,6 +241,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
|
|||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice } from '../../utils/format'
|
||||||
|
import { uploadStudioAsset } from '../../utils/studio-upload'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
|
|
||||||
@@ -229,6 +256,7 @@ const cardTypes = ref<CardType[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const uploadingCover = ref(false)
|
||||||
const editTarget = ref<CardType | null>(null)
|
const editTarget = ref<CardType | null>(null)
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
@@ -246,6 +274,7 @@ const defaultForm = () => ({
|
|||||||
durationDaysStr: '90',
|
durationDaysStr: '90',
|
||||||
sortOrderStr: '0',
|
sortOrderStr: '0',
|
||||||
description: '',
|
description: '',
|
||||||
|
coverUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = ref(defaultForm())
|
const form = ref(defaultForm())
|
||||||
@@ -282,6 +311,7 @@ function openEdit(ct: CardType) {
|
|||||||
durationDaysStr: String(ct.durationDays),
|
durationDaysStr: String(ct.durationDays),
|
||||||
sortOrderStr: String(ct.sortOrder),
|
sortOrderStr: String(ct.sortOrder),
|
||||||
description: ct.description ?? '',
|
description: ct.description ?? '',
|
||||||
|
coverUrl: ct.coverUrl ?? '',
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
@@ -349,6 +379,9 @@ async function submitForm() {
|
|||||||
if (form.value.description.trim()) {
|
if (form.value.description.trim()) {
|
||||||
payload.description = form.value.description.trim()
|
payload.description = form.value.description.trim()
|
||||||
}
|
}
|
||||||
|
if (form.value.coverUrl) {
|
||||||
|
payload.coverUrl = form.value.coverUrl
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
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 ─────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
function typeLabel(ct: CardType): string {
|
function typeLabel(ct: CardType): string {
|
||||||
@@ -721,4 +833,82 @@ onMounted(fetchCardTypes)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -37,10 +37,18 @@
|
|||||||
class="card-row"
|
class="card-row"
|
||||||
@tap="goToDetail(c.id)"
|
@tap="goToDetail(c.id)"
|
||||||
>
|
>
|
||||||
<!-- Card Cover — clean minimal -->
|
<!-- Card Cover — image if available, gradient fallback -->
|
||||||
<view class="card-cover" :class="getCardCoverClass(c.type)">
|
<view class="card-cover" :class="c.coverUrl ? '' : getCardCoverClass(c.type)">
|
||||||
<view class="cover-deco cover-deco--1" />
|
<image
|
||||||
<view class="cover-deco cover-deco--2" />
|
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>
|
</view>
|
||||||
|
|
||||||
<!-- Card info — aligns with card-cover height -->
|
<!-- Card info — aligns with card-cover height -->
|
||||||
@@ -77,10 +85,19 @@
|
|||||||
<!-- Card content (single card mode) -->
|
<!-- Card content (single card mode) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<view class="card-hero" :class="heroClass">
|
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
|
||||||
<!-- Decorative circles -->
|
<!-- Cover image background -->
|
||||||
<view class="hero-deco hero-deco--1" />
|
<image
|
||||||
<view class="hero-deco hero-deco--2" />
|
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">
|
<view class="hero-badge">
|
||||||
<text class="hero-badge-text">{{ typeLabel }}</text>
|
<text class="hero-badge-text">{{ typeLabel }}</text>
|
||||||
@@ -456,6 +473,18 @@ onMounted(() => {
|
|||||||
&.hero--trial {
|
&.hero--trial {
|
||||||
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
|
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 */
|
/* Decorative background circles */
|
||||||
@@ -737,6 +766,11 @@ onMounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-cover-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-deco {
|
.cover-deco {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ model CardType {
|
|||||||
price Decimal @db.Decimal(10, 0)
|
price Decimal @db.Decimal(10, 0)
|
||||||
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
|
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
|
||||||
description String?
|
description String?
|
||||||
|
coverUrl String? @map("cover_url")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export class CreateCardTypeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
description?: string
|
description?: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
coverUrl?: string
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export class UpdateCardTypeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
description?: string
|
description?: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
coverUrl?: string
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ export class CreateStudioUploadCredentialDto {
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['gallery', 'logo', 'banner'])
|
@IsIn(['gallery', 'logo', 'banner', 'card-cover'])
|
||||||
assetType?: StudioAssetType
|
assetType?: StudioAssetType
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface CardType {
|
|||||||
readonly price: number
|
readonly price: number
|
||||||
readonly originalPrice: number | null
|
readonly originalPrice: number | null
|
||||||
readonly description: string | null
|
readonly description: string | null
|
||||||
|
readonly coverUrl: string | null
|
||||||
readonly isActive: boolean
|
readonly isActive: boolean
|
||||||
readonly sortOrder: number
|
readonly sortOrder: number
|
||||||
readonly createdAt: string
|
readonly createdAt: string
|
||||||
@@ -23,6 +24,7 @@ export interface CreateCardTypeDto {
|
|||||||
readonly price: number
|
readonly price: number
|
||||||
readonly originalPrice?: number
|
readonly originalPrice?: number
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
readonly coverUrl?: string
|
||||||
readonly sortOrder?: number
|
readonly sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface StudioConfig {
|
|||||||
readonly updatedAt: string
|
readonly updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StudioAssetType = 'gallery' | 'logo' | 'banner'
|
export type StudioAssetType = 'gallery' | 'logo' | 'banner' | 'card-cover'
|
||||||
|
|
||||||
export interface UpdateStudioConfigDto {
|
export interface UpdateStudioConfigDto {
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user