feat: 支持画廊图片更新
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
72
packages/app/src/utils/studio-upload.ts
Normal file
72
packages/app/src/utils/studio-upload.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
13
packages/server/.env.example
Normal file
13
packages/server/.env.example
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
39
packages/server/prisma/update-studio-gallery.ts
Normal file
39
packages/server/prisma/update-studio-gallery.ts
Normal 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()
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
170
packages/server/src/studio/studio-upload.service.ts
Normal file
170
packages/server/src/studio/studio-upload.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user