feat: 支持画廊图片更新

This commit is contained in:
richarjiang
2026-04-15 13:58:51 +08:00
parent 7ce7cef77c
commit 6ab16f508a
20 changed files with 1671 additions and 247 deletions

View File

@@ -14,27 +14,36 @@
<!-- Circular logo -->
<view class="logo-circle">
<image
v-if="logoImage"
class="logo-img"
:src="logoImage"
mode="aspectFill"
/>
<view v-else class="logo-placeholder">
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
</view>
</view>
<!-- Studio name -->
<text class="studio-name">Focus Core</text>
<text class="studio-name">{{ studioName }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
defineProps<{
const props = defineProps<{
studioInfo: StudioConfig | null
}>()
const bannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
const logoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.jpg'
const fallbackBannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
const fallbackLogoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.jpg'
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
</script>
<style lang="scss" scoped>
@@ -94,10 +103,16 @@ const logoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/im
}
.logo-placeholder {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
font-size: 64rpx;
font-weight: 800;
color: #333;
letter-spacing: 4rpx;
display: flex;
align-items: center;
justify-content: center;
}
.studio-name {

View File

@@ -37,22 +37,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
import {
DEFAULT_STUDIO_GALLERY_PHOTOS,
type StudioConfig,
} from '@mp-pilates/shared'
const props = defineProps<{
studioInfo: StudioConfig | null
}>()
const defaultGalleryPhotos = [
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_1.jpg',
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_2.jpg',
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_3.jpg',
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_4.jpg',
]
const galleryPhotos = computed(() => {
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
return photos.length ? photos : defaultGalleryPhotos
return photos.length ? photos : [...DEFAULT_STUDIO_GALLERY_PHOTOS]
})
function previewPhoto(index: number) {

View File

@@ -316,7 +316,7 @@ async function loadData() {
adminStore.fetchFlashSales(),
adminStore.fetchCardTypes(),
])
items.value = [...salesResult.data]
items.value = [...salesResult.items]
total.value = salesResult.total
cardTypes.value = [...cardTypesResult]
} catch {
@@ -329,7 +329,7 @@ async function loadData() {
async function reloadSales() {
try {
const result = await adminStore.fetchFlashSales()
items.value = [...result.data]
items.value = [...result.items]
total.value = result.total
} catch {
// silent

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,8 @@ import type {
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
@@ -141,6 +143,15 @@ export const useAdminStore = defineStore('admin', () => {
return data
}
async function createStudioUploadCredential(
dto: CreateStudioUploadCredentialDto,
): Promise<StudioUploadCredential> {
return post<StudioUploadCredential>(
'/admin/studio/upload-credentials',
dto as unknown as Record<string, unknown>,
)
}
// ── Orders ───────────────────────────────────────────────────────
async function fetchAdminOrders(params: {
page?: number
@@ -278,6 +289,7 @@ export const useAdminStore = defineStore('admin', () => {
// Studio
fetchStudioConfig,
saveStudioConfig,
createStudioUploadCredential,
// Orders
fetchAdminOrders,
// Bookings

View File

@@ -0,0 +1,72 @@
import type {
CreateStudioUploadCredentialDto,
StudioAssetType,
StudioUploadCredential,
} from '@mp-pilates/shared'
import type { useAdminStore } from '../stores/admin'
type AdminStore = ReturnType<typeof useAdminStore>
function inferContentType(fileName: string): string | undefined {
const extension = fileName.split('.').pop()?.toLowerCase()
if (extension === 'jpg' || extension === 'jpeg') {
return 'image/jpeg'
}
if (extension === 'png') {
return 'image/png'
}
if (extension === 'webp') {
return 'image/webp'
}
if (extension === 'heic') {
return 'image/heic'
}
if (extension === 'heif') {
return 'image/heif'
}
return undefined
}
function uploadToCos(filePath: string, credential: StudioUploadCredential): Promise<void> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: credential.uploadUrl,
filePath,
name: 'file',
formData: credential.formData as unknown as Record<string, string>,
success: (result) => {
if (result.statusCode >= 200 && result.statusCode < 300) {
resolve()
return
}
const body = typeof result.data === 'string' ? result.data : JSON.stringify(result.data)
const code = body.match(/<Code>([^<]+)<\/Code>/)?.[1]
const message = body.match(/<Message>([^<]+)<\/Message>/)?.[1]
const detail = code || message ? `${code ?? 'COS'}: ${message ?? body}` : body
reject(new Error(`COS 上传失败 (${result.statusCode}) ${detail}`))
},
fail: (error) => {
reject(new Error(error.errMsg || 'COS 上传失败'))
},
})
})
}
export async function uploadStudioAsset(params: {
adminStore: AdminStore
filePath: string
fileName: string
assetType: StudioAssetType
}): Promise<string> {
const payload: CreateStudioUploadCredentialDto = {
fileName: params.fileName,
contentType: inferContentType(params.fileName),
assetType: params.assetType,
}
const credential = await params.adminStore.createStudioUploadCredential(payload)
await uploadToCos(params.filePath, credential)
return credential.fileUrl
}

View File

@@ -20,4 +20,16 @@ API_BASE_URL=https://focus.richarjiang.com/
# Server
PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
# COS upload
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu
COS_SECRET_KEY=S1rrw0CY1fRQj7X7fCpjryAMwgel6drG
COS_BUCKET=plates-1251306435
COS_REGION=ap-guangzhou
COS_UPLOAD_ROLE_ARN=qcs::cam::uin/649581473:roleName/MpPilatesCosUploadRole
COS_APP_ID=1251306435
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800
COS_UPLOAD_ROLE_SESSION_NAME=mp-pilates-studio-upload

View File

@@ -0,0 +1,13 @@
DATABASE_URL=mysql://user:password@127.0.0.1:3306/mp_pilates
JWT_SECRET=change-me
WX_APPID=your-wechat-appid
WX_SECRET=your-wechat-secret
# COS upload
COS_SECRET_ID=your-cos-secret-id
COS_SECRET_KEY=your-cos-secret-key
COS_BUCKET=plates-1251306435
COS_REGION=ap-guangzhou
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800

View File

@@ -13,6 +13,7 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"studio:seed-gallery": "ts-node prisma/update-studio-gallery.ts",
"lint": "eslint \"{src,test}/**/*.ts\""
},
"dependencies": {

View File

@@ -0,0 +1,39 @@
import { PrismaClient } from '@prisma/client'
import { DEFAULT_STUDIO_GALLERY_PHOTOS } from '@mp-pilates/shared'
const prisma = new PrismaClient()
async function main() {
console.log('🖼️ Syncing studio gallery photos...')
const photos = [...DEFAULT_STUDIO_GALLERY_PHOTOS]
const existing = await prisma.studioConfig.findFirst({ select: { id: true } })
if (existing) {
await prisma.studioConfig.update({
where: { id: existing.id },
data: { photos },
})
console.log(` ✅ Updated existing studio config with ${photos.length} gallery images`)
} else {
await prisma.studioConfig.create({
data: {
name: '普拉提工作室',
address: '请在管理后台设置地址',
phone: '请在管理后台设置电话',
cancelHoursLimit: 2,
photos,
},
})
console.log(` ✅ Created studio config with ${photos.length} gallery images`)
}
}
main()
.catch((error) => {
console.error('❌ Studio gallery sync failed:', error)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -0,0 +1,15 @@
import { IsIn, IsOptional, IsString } from 'class-validator'
import type { StudioAssetType } from '@mp-pilates/shared'
export class CreateStudioUploadCredentialDto {
@IsString()
fileName!: string
@IsOptional()
@IsString()
contentType?: string
@IsOptional()
@IsIn(['gallery', 'logo', 'banner'])
assetType?: StudioAssetType
}

View File

@@ -0,0 +1,170 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import type {
StudioAssetType,
StudioUploadCredential,
} from '@mp-pilates/shared'
import { createHash, createHmac, randomBytes } from 'crypto'
import { CreateStudioUploadCredentialDto } from './dto/create-studio-upload-credential.dto'
const ALLOWED_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'])
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
heic: 'image/heic',
heif: 'image/heif',
}
const EXTENSION_BY_CONTENT_TYPE = new Map(
Object.entries(CONTENT_TYPE_BY_EXTENSION).map(([ext, type]) => [type, ext]),
)
@Injectable()
export class StudioUploadService {
constructor(private readonly configService: ConfigService) {}
async createUploadCredential(
dto: CreateStudioUploadCredentialDto,
): Promise<StudioUploadCredential> {
const bucket = this.getRequiredConfig('COS_BUCKET')
const region = this.getRequiredConfig('COS_REGION')
const assetType = dto.assetType ?? 'gallery'
const extension = this.resolveExtension(dto.fileName, dto.contentType)
const key = this.buildObjectKey(assetType, extension)
const uploadUrl = `https://${bucket}.cos.${region}.myqcloud.com`
const fileUrl = this.buildFileUrl(key, uploadUrl)
const expiresAt = Math.floor(Date.now() / 1000) + this.getDurationSeconds()
const formData = this.buildPostPolicy({ bucket, key, expiresAt })
return {
bucket,
region,
key,
uploadUrl,
fileUrl,
assetType,
expiresAt,
formData,
}
}
private buildPostPolicy(params: {
bucket: string
key: string
expiresAt: number
}): Record<string, string> {
const secretId = this.getRequiredConfig('COS_SECRET_ID')
const secretKey = this.getRequiredConfig('COS_SECRET_KEY')
const keyTime = this.buildKeyTime(params.expiresAt)
const policy = {
expiration: new Date(params.expiresAt * 1000).toISOString(),
conditions: [
{ bucket: params.bucket },
['eq', '$key', params.key],
{ success_action_status: '200' },
{ 'q-sign-algorithm': 'sha1' },
{ 'q-ak': secretId },
{ 'q-key-time': keyTime },
{ 'q-sign-time': keyTime },
['content-length-range', 0, 10 * 1024 * 1024],
],
}
const policyJson = JSON.stringify(policy)
const policyBase64 = Buffer.from(policyJson).toString('base64')
const signKey = createHmac('sha1', secretKey)
.update(keyTime)
.digest('hex')
const stringToSign = createHash('sha1').update(policyJson).digest('hex')
const signature = createHmac('sha1', signKey)
.update(stringToSign)
.digest('hex')
return {
key: params.key,
policy: policyBase64,
success_action_status: '200',
'q-sign-algorithm': 'sha1',
'q-ak': secretId,
'q-key-time': keyTime,
'q-sign-time': keyTime,
'q-signature': signature,
}
}
private buildObjectKey(assetType: StudioAssetType, extension: string): string {
const prefix = this.getUploadPrefix()
const now = new Date()
const datePath = [
now.getUTCFullYear(),
String(now.getUTCMonth() + 1).padStart(2, '0'),
String(now.getUTCDate()).padStart(2, '0'),
].join('/')
const randomSuffix = randomBytes(8).toString('hex')
return `${prefix}/${assetType}/${datePath}/${Date.now()}-${randomSuffix}.${extension}`
}
private buildFileUrl(key: string, uploadUrl: string): string {
const publicBaseUrl = this.configService.get<string>('COS_PUBLIC_BASE_URL')?.trim()
const baseUrl = publicBaseUrl || uploadUrl
return `${baseUrl.replace(/\/$/, '')}/${key}`
}
private buildKeyTime(expiresAt: number): string {
const startTime = Math.floor(Date.now() / 1000) - 5
return `${startTime};${expiresAt}`
}
private resolveExtension(fileName: string, contentType?: string): string {
const cleanedName = fileName.trim().toLowerCase()
const fileExtension = cleanedName.includes('.')
? cleanedName.split('.').pop() ?? ''
: ''
if (ALLOWED_IMAGE_EXTENSIONS.has(fileExtension)) {
return fileExtension === 'jpeg' ? 'jpg' : fileExtension
}
if (contentType) {
const normalizedType = contentType.trim().toLowerCase()
const matchedExtension = EXTENSION_BY_CONTENT_TYPE.get(normalizedType)
if (matchedExtension) {
return matchedExtension
}
}
throw new BadRequestException('仅支持 jpg、png、webp、heic、heif 图片上传')
}
private getDurationSeconds(): number {
const configured = Number(this.configService.get<string>('COS_UPLOAD_DURATION_SECONDS') ?? 1800)
if (!Number.isFinite(configured) || configured < 300 || configured > 7200) {
return 1800
}
return Math.floor(configured)
}
private getUploadPrefix(): string {
return (this.configService.get<string>('COS_UPLOAD_PREFIX')?.trim() || 'mp/studio')
.replace(/^\/+|\/+$/g, '')
}
private getRequiredConfig(key: string): string {
const value = this.configService.get<string>(key)?.trim()
if (!value) {
throw new InternalServerErrorException(`${key} 未配置`)
}
return value
}
}

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Get,
Post,
Put,
UseGuards,
} from '@nestjs/common'
@@ -9,12 +10,17 @@ import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
import { CreateStudioUploadCredentialDto } from './dto/create-studio-upload-credential.dto'
import { UpdateStudioDto } from './dto/update-studio.dto'
import { StudioService } from './studio.service'
import { StudioUploadService } from './studio-upload.service'
@Controller()
export class StudioController {
constructor(private readonly studioService: StudioService) {}
constructor(
private readonly studioService: StudioService,
private readonly studioUploadService: StudioUploadService,
) {}
@Get('studio/info')
getInfo() {
@@ -27,4 +33,11 @@ export class StudioController {
updateInfo(@Body() dto: UpdateStudioDto) {
return this.studioService.updateInfo(dto)
}
@Post('admin/studio/upload-credentials')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
createUploadCredential(@Body() dto: CreateStudioUploadCredentialDto) {
return this.studioUploadService.createUploadCredential(dto)
}
}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'
import { StudioController } from './studio.controller'
import { StudioService } from './studio.service'
import { StudioUploadService } from './studio-upload.service'
@Module({
controllers: [StudioController],
providers: [StudioService],
providers: [StudioService, StudioUploadService],
exports: [StudioService],
})
export class StudioModule {}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'
import { StudioConfig } from '@prisma/client'
import { StudioConfig as PrismaStudioConfig } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateStudioDto } from './dto/update-studio.dto'
@@ -7,28 +7,71 @@ import { UpdateStudioDto } from './dto/update-studio.dto'
export class StudioService {
constructor(private readonly prisma: PrismaService) {}
async getInfo(): Promise<StudioConfig> {
async getInfo() {
const existing = await this.prisma.studioConfig.findFirst()
if (existing) {
return existing
return this.normalizeStudioConfig(existing)
}
return this.prisma.studioConfig.create({
const created = await this.prisma.studioConfig.create({
data: {
name: '普拉提工作室',
},
})
return this.normalizeStudioConfig(created)
}
async updateInfo(dto: UpdateStudioDto): Promise<StudioConfig> {
const existing = await this.getInfo()
const updated = await this.prisma.studioConfig.update({
where: { id: existing.id },
data: { ...dto },
async updateInfo(dto: UpdateStudioDto) {
const existing = await this.prisma.studioConfig.findFirst({
select: { id: true },
})
return { ...updated }
const data = {
...dto,
photos: dto.photos ? this.normalizePhotos(dto.photos) : undefined,
}
const record = existing
? await this.prisma.studioConfig.update({
where: { id: existing.id },
data,
})
: await this.prisma.studioConfig.create({
data: { name: '普拉提工作室', ...data },
})
return this.normalizeStudioConfig(record)
}
private normalizeStudioConfig(config: PrismaStudioConfig) {
return {
...config,
latitude: config.latitude == null ? null : Number(config.latitude),
longitude: config.longitude == null ? null : Number(config.longitude),
photos: this.normalizePhotos(config.photos),
}
}
private normalizePhotos(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
const deduped = new Set<string>()
value.forEach((item) => {
if (typeof item !== 'string') {
return
}
const trimmed = item.trim()
if (trimmed) {
deduped.add(trimmed)
}
})
return [...deduped]
}
}

View File

@@ -1,6 +1,14 @@
/** 默认免费取消截止小时数 */
export const DEFAULT_CANCEL_HOURS_LIMIT = 2
/** 默认工作室画廊图片 */
export const DEFAULT_STUDIO_GALLERY_PHOTOS = [
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_1.jpg',
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_2.jpg',
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_3.jpg',
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_4.jpg',
] as const
/** 默认时段容量(私教 = 1 */
export const DEFAULT_SLOT_CAPACITY = 1

View File

@@ -15,6 +15,7 @@ export {
// Constants
export {
DEFAULT_CANCEL_HOURS_LIMIT,
DEFAULT_STUDIO_GALLERY_PHOTOS,
DEFAULT_SLOT_CAPACITY,
SLOT_GENERATION_DAYS,
TIME_PERIODS,
@@ -53,7 +54,10 @@ export type {
PaymentParams,
CreateOrderResponse,
StudioConfig,
StudioAssetType,
UpdateStudioConfigDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
ApiResponse,
PaginatedData,
PaginatedResponse,

View File

@@ -13,7 +13,13 @@ export type { WeekTemplate, WeekTemplateInput } from './week-template'
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory, CreateBookingDto } from './booking'
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
export type { StudioConfig, UpdateStudioConfigDto } from './studio'
export type {
StudioConfig,
StudioAssetType,
UpdateStudioConfigDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
} from './studio'
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
export type {
FlashSale,

View File

@@ -12,6 +12,8 @@ export interface StudioConfig {
readonly updatedAt: string
}
export type StudioAssetType = 'gallery' | 'logo' | 'banner'
export interface UpdateStudioConfigDto {
readonly name?: string
readonly logo?: string
@@ -23,3 +25,20 @@ export interface UpdateStudioConfigDto {
readonly cancelHoursLimit?: number
readonly photos?: string[]
}
export interface CreateStudioUploadCredentialDto {
readonly fileName: string
readonly contentType?: string
readonly assetType?: StudioAssetType
}
export interface StudioUploadCredential {
readonly bucket: string
readonly region: string
readonly key: string
readonly uploadUrl: string
readonly fileUrl: string
readonly assetType: StudioAssetType
readonly expiresAt: number
readonly formData: Readonly<Record<string, string>>
}