feat: 支持馆主个人信息编辑

This commit is contained in:
richarjiang
2026-10-25 15:21:27 +08:00
parent 819367178a
commit 4ca32c22f9
19 changed files with 556 additions and 390 deletions

View File

@@ -0,0 +1,25 @@
# 馆主介绍
- 入口:管理中心 → 系统设置 → 馆主介绍。
- 个人介绍支持多段文字,最多 5000 字。首页摘要与详情页使用同一份内容。
- 认证最多 30 项,每项最多 200 字;支持新增、删除、上移、下移。
- 展示预览使用与前台相同的内容组件,点击“保存并更新”后才生效。
- 保存前后端均保留段落和认证顺序,去除首尾空白、空认证及重复认证。
- 清空个人介绍或全部认证并保存后,对应详情模块隐藏。未编辑的旧工作室继续显示原有介绍与认证。
- 更新工作室其他设置不会覆盖馆主内容。写入沿用管理员鉴权,访客可读取展示内容。
## 发布顺序
本次增加 `studio_config.owner_introduction`(TEXT,可空)与 `owner_certifications`(JSON,可空),没有新环境变量。
1. 在服务端目录执行 `pnpm exec prisma migrate deploy`,应用 `20261025090000_owner_introduction` 迁移。
2. 重新生成 Prisma Client、构建 shared 和服务端并发布。
3. 构建、发布小程序。
## 验收
1. 后台编辑个人介绍,包含中文、换行及空行;添加两项认证并调整顺序。
2. 检查展示预览,保存后返回首页,打开“馆主介绍”,确认文字与顺序同步。
3. 重新进入后台,确认已保存内容回填;修改工作室名称后,馆主内容保持不变。
4. 清空一项模块并保存,确认前台隐藏该模块;随后可重新填写。
5. 修改后返回,确认出现未保存提醒;网络加载或保存失败时可重试,编辑内容不丢失。

View File

@@ -2,7 +2,7 @@
<view class="about-section"> <view class="about-section">
<view class="section-header"> <view class="section-header">
<view> <view>
<text class="section-title">老师介绍</text> <text class="section-title">馆主介绍</text>
</view> </view>
<text class="section-more" @tap="goToDetail">查看详情</text> <text class="section-more" @tap="goToDetail">查看详情</text>
</view> </view>
@@ -19,7 +19,7 @@
<view> <view>
<view class="name-row"> <view class="name-row">
<text class="teacher-name">{{ teacher.name }}</text> <text class="teacher-name">{{ teacher.name }}</text>
<text class="owner-badge">馆主</text>
</view> </view>
<text class="teacher-title">{{ teacher.title }}</text> <text class="teacher-title">{{ teacher.title }}</text>
</view> </view>
@@ -46,12 +46,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { irisProfile } from '../utils/teacher' import { useOwnerProfile } from '../composables/use-owner-profile'
const teacher = irisProfile const teacher = useOwnerProfile()
function goToDetail() { function goToDetail() {
uni.navigateTo({ url: `/pages/teacher/detail?id=${teacher.id}` }) uni.navigateTo({ url: '/pages/teacher/detail' })
} }
function goToBooking() { function goToBooking() {
@@ -64,12 +64,14 @@ function goToBooking() {
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; } .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; } .section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; } .section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.teacher-card { padding: 26rpx; background: #f2ebe5; border-radius: 28rpx; } .teacher-card { padding: 26rpx; background: #f2ebe5; border-radius: 28rpx; transition: transform 180ms ease; }
.teacher-card:active { transform: scale(.99); }
.teacher-main { display: flex; align-items: flex-start; gap: 24rpx; } .teacher-main { display: flex; align-items: flex-start; gap: 24rpx; }
.cover-wrap { width: 164rpx; height: 230rpx; border-radius: 18rpx; overflow: hidden; background: #e1d4c8; flex-shrink: 0; } .cover-wrap { width: 164rpx; height: 230rpx; border-radius: 18rpx; overflow: hidden; background: #e1d4c8; flex-shrink: 0; }
.teacher-cover { width: 100%; height: 100%; } .teacher-cover { width: 100%; height: 100%; }
.teacher-content { flex: 1; min-width: 0; } .teacher-content { flex: 1; min-width: 0; }
.name-row { margin-bottom: 8rpx; } .name-row { display: flex; align-items: center; gap: 14rpx; margin-bottom: 8rpx; }
.owner-badge { padding: 4rpx 12rpx; border: 1rpx solid #d3dace; border-radius: 8rpx; color: #617d73; font-size: 19rpx; }
.teacher-name { font-size: 34rpx; font-weight: 500; color: #514943; } .teacher-name { font-size: 34rpx; font-weight: 500; color: #514943; }
.teacher-title { font-size: 22rpx; color: #8b817b; line-height: 1.5; } .teacher-title { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
.specialty-row { margin: 14rpx 0 10rpx; } .specialty-row { margin: 14rpx 0 10rpx; }

View File

@@ -0,0 +1,44 @@
<template>
<view class="owner-content">
<view v-if="introduction" class="owner-panel owner-panel--intro">
<text class="eyebrow">ABOUT ME</text>
<text class="panel-title">个人介绍</text>
<view class="intro-rule" />
<text class="introduction">{{ introduction }}</text>
</view>
<view v-if="certifications.length" class="owner-panel owner-panel--credentials">
<view class="credentials-head">
<view>
<text class="eyebrow">CREDENTIALS</text>
<text class="panel-title">专业认证</text>
</view>
<text class="credential-count">{{ certifications.length }} 项</text>
</view>
<view v-for="(item, index) in certifications" :key="index" class="credential-row">
<text class="credential-number">{{ String(index + 1).padStart(2, '0') }}</text>
<text class="credential-text">{{ item }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{ introduction: string; certifications: readonly string[] }>()
</script>
<style lang="scss" scoped>
.owner-panel { margin-bottom: 20rpx; padding: 34rpx 30rpx; border: 1rpx solid #e7e2d8; border-radius: 24rpx; background: #fffdf9; animation: owner-reveal 480ms ease-out both; }
.owner-panel--credentials { animation-delay: 90ms; }
.eyebrow { display: block; margin-bottom: 12rpx; color: #92957f; font-size: 18rpx; letter-spacing: 4rpx; }
.panel-title { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; font-weight: 500; color: #434c3d; }
.intro-rule { width: 40rpx; height: 3rpx; margin: 24rpx 0; background: #9da88d; }
.introduction { display: block; color: #655f53; font-size: 27rpx; line-height: 1.95; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
.credentials-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14rpx; }
.credential-count { padding: 8rpx 16rpx; border-radius: 999rpx; color: #78856a; background: #f0f2e9; font-size: 21rpx; }
.credential-row { display: flex; align-items: flex-start; gap: 22rpx; padding: 24rpx 0; border-bottom: 1rpx solid #efebe3; }
.credential-row:last-child { padding-bottom: 0; border-bottom: 0; }
.credential-number { flex-shrink: 0; padding-top: 4rpx; font-family: Georgia, serif; font-size: 26rpx; color: #9aa58d; }
.credential-text { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.8; color: #625e51; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
@keyframes owner-reveal { from { opacity: 0; transform: translateY(14rpx); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .owner-panel { animation: none; } }
</style>

View File

@@ -0,0 +1,12 @@
import { computed } from 'vue'
import { useStudioStore } from '../stores/studio'
import { irisProfile } from '../utils/teacher'
export function useOwnerProfile() {
const studioStore = useStudioStore()
return computed(() => ({
...irisProfile,
intro: studioStore.studioInfo?.ownerIntroduction ?? irisProfile.intro,
certifications: studioStore.studioInfo?.ownerCertifications ?? irisProfile.certifications,
}))
}

View File

@@ -201,6 +201,12 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "owner",
"style": {
"navigationStyle": "custom"
}
},
{ {
"path": "member-progress", "path": "member-progress",
"style": { "style": {

View File

@@ -82,6 +82,10 @@
<text class="item-title">工作室设置</text> <text class="item-title">工作室设置</text>
<text class="arrow-text">›</text> <text class="arrow-text">›</text>
</view> </view>
<view class="list-item" @tap="navigate('/pages/admin/owner')">
<text class="item-title">馆主介绍</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="handleIncreaseSubscriptionCount"> <view class="list-item" @tap="handleIncreaseSubscriptionCount">
<text class="item-title">增加订阅次数</text> <text class="item-title">增加订阅次数</text>
<text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text> <text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text>

View File

@@ -0,0 +1,239 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="馆主介绍" show-back :before-back="beforeBack" />
<view v-if="loading" class="state">
<view class="skeleton skeleton--short" />
<view class="skeleton" />
<view class="skeleton" />
<text class="state-copy">正在读取馆主介绍…</text>
</view>
<view v-else-if="error" class="state">
<text class="state-title">暂时无法读取内容</text>
<text class="state-copy">{{ error }}</text>
<button class="retry-button" @tap="load">重新加载</button>
</view>
<view v-else class="content">
<view class="editor-header">
<text class="eyebrow">YOUR STORY, YOUR STUDIO</text>
<text class="headline">让学员,认识真实的你</text>
<text class="subline">一人一馆,从一份用心的介绍开始。</text>
</view>
<view class="mode-switch">
<button :class="{ selected: !previewing }" @tap="previewing = false">编辑内容</button>
<button :class="{ selected: previewing }" @tap="previewing = true">展示预览</button>
</view>
<view v-if="previewing" class="preview-wrap">
<text class="preview-note">以下为当前内容预览,保存后学员可见</text>
<OwnerIntroduction :introduction="introduction.trim()" :certifications="previewCertifications" />
<view v-if="!introduction.trim() && !previewCertifications.length" class="empty-preview">
<text class="state-title">把你的故事写在这里</text>
<text class="state-copy">填写个人介绍或添加认证后,即可预览展示效果。</text>
</view>
</view>
<view v-else class="editor-panels">
<view class="panel introduction-panel">
<view class="panel-heading">
<view class="heading-group"><text class="section-number">01</text><text class="section-title">个人介绍</text></view>
<text class="count">{{ introduction.length }} / {{ OWNER_INTRODUCTION_MAX_LENGTH }}</text>
</view>
<text class="hint">写下你的教学经历、练习理念,或想对学员说的话。</text>
<textarea v-model="introduction" class="introduction-input" :disabled="saving" :maxlength="OWNER_INTRODUCTION_MAX_LENGTH"
placeholder="你好,我是这间工作室的馆主…" placeholder-style="color:#aaa495" :show-confirm-bar="false" :adjust-position="true" />
<text class="field-note">支持换行与分段,首页会同步展示介绍摘要。</text>
</view>
<view class="panel certification-panel">
<view class="panel-heading">
<view class="heading-group"><text class="section-number">02</text><text class="section-title">专业认证</text></view>
<text class="count">{{ certifications.length }} / {{ OWNER_CERTIFICATIONS_MAX_COUNT }}</text>
</view>
<text class="hint">逐项填写证书或进修经历,按你希望的顺序展示。</text>
<view v-for="(item, index) in certifications" :key="item.id" class="certification-item">
<view class="certification-top">
<text class="certification-index">认证 {{ String(index + 1).padStart(2, '0') }}</text>
<view class="item-actions">
<button :disabled="saving || index === 0" @tap="moveCertification(index, -1)">上移</button>
<button :disabled="saving || index === certifications.length - 1" @tap="moveCertification(index, 1)">下移</button>
<button class="remove-button" :disabled="saving" @tap="removeCertification(index)">删除</button>
</view>
</view>
<textarea v-model="item.text" class="certification-input" :disabled="saving" :maxlength="OWNER_CERTIFICATION_MAX_LENGTH"
placeholder="例如:STOTT PILATES 全场馆认证" placeholder-style="color:#aaa495" auto-height :show-confirm-bar="false" />
<text class="item-count">{{ item.text.length }} / {{ OWNER_CERTIFICATION_MAX_LENGTH }}</text>
</view>
<view v-if="!certifications.length" class="certification-empty">
<text>每一次进修,都值得被看见</text>
<text class="field-note">点击下方按钮,添加第一项专业认证</text>
</view>
<button class="add-button" :disabled="saving || certifications.length >= OWNER_CERTIFICATIONS_MAX_COUNT" @tap="addCertification">+ 添加认证</button>
<text class="field-note">留空的模块保存后不展示,可随时回来补充。</text>
</view>
</view>
<view class="save-bar">
<view class="save-status"><view class="status-dot" :class="{ 'status-dot--dirty': isDirty }" /><text>{{ isDirty ? '有未保存的修改' : '内容已同步' }}</text></view>
<button class="save-button" :disabled="saving || !isDirty" :loading="saving" @tap="save">{{ saving ? '正在保存' : '保存并更新' }}</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { OWNER_INTRODUCTION_MAX_LENGTH, OWNER_CERTIFICATIONS_MAX_COUNT, OWNER_CERTIFICATION_MAX_LENGTH } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnerIntroduction from '../../components/OwnerIntroduction.vue'
import { useUserStore } from '../../stores/user'
import { useStudioStore } from '../../stores/studio'
import { useAdminStore } from './stores/admin'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
const navBarHeight = getSystemLayout().navBarHeight
const userStore = useUserStore()
const studioStore = useStudioStore()
const adminStore = useAdminStore()
const loading = ref(true)
const error = ref('')
const saving = ref(false)
const previewing = ref(false)
const introduction = ref('')
const certifications = ref<Array<{ id: number; text: string }>>([])
const original = ref('')
let nextId = 0
const snapshot = computed(() => JSON.stringify({ introduction: introduction.value, certifications: certifications.value.map(item => item.text) }))
const isDirty = computed(() => original.value !== '' && snapshot.value !== original.value)
const previewCertifications = computed(() => [...new Set(certifications.value.map(item => item.text.trim()).filter(Boolean))])
async function load() {
if (saving.value) return
loading.value = true
error.value = ''
try {
if (userStore.loggedIn && !userStore.hasProfile) await userStore.fetchProfile()
if (!userStore.isAdmin) throw new Error('请使用馆主管理员账号登录后编辑')
const data = await adminStore.fetchStudioConfig()
introduction.value = data.ownerIntroduction
certifications.value = data.ownerCertifications.map(text => ({ id: ++nextId, text }))
original.value = snapshot.value
} catch (err) {
error.value = getErrorMessage(err, '加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function addCertification() {
if (saving.value || certifications.value.length >= OWNER_CERTIFICATIONS_MAX_COUNT) return
certifications.value.push({ id: ++nextId, text: '' })
}
function moveCertification(index: number, delta: number) {
const target = index + delta
if (saving.value || target < 0 || target >= certifications.value.length) return
const items = [...certifications.value]
const moved = items.splice(index, 1)[0]
items.splice(target, 0, moved)
certifications.value = items
}
function removeCertification(index: number) {
if (saving.value) return
certifications.value.splice(index, 1)
}
async function save() {
if (saving.value || !isDirty.value || loading.value || error.value) return
saving.value = true
try {
const data = await adminStore.saveStudioConfig({
ownerIntroduction: introduction.value.trim(),
ownerCertifications: previewCertifications.value,
})
studioStore.studioInfo = data
introduction.value = data.ownerIntroduction
certifications.value = data.ownerCertifications.map(text => ({ id: ++nextId, text }))
original.value = snapshot.value
uni.showToast({ title: '馆主介绍已更新', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败,请重试'), icon: 'none' })
} finally {
saving.value = false
}
}
async function beforeBack() {
if (saving.value) return false
if (!isDirty.value) return true
const result = await uni.showModal({ title: '介绍尚未保存', content: '离开后,本次修改不会保存。', confirmText: '离开', cancelText: '继续编辑' })
if (result.confirm) uni.disableAlertBeforeUnload?.({})
return Boolean(result.confirm)
}
watch(isDirty, dirty => {
if (dirty) uni.enableAlertBeforeUnload?.({ message: '馆主介绍尚未保存,确定离开吗?' })
else uni.disableAlertBeforeUnload?.({})
})
onLoad(() => { void load() })
onUnload(() => { uni.disableAlertBeforeUnload?.({}) })
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; box-sizing: border-box; background: #f7f6ef; color: #515747; }
.content { padding: 36rpx 28rpx calc(180rpx + env(safe-area-inset-bottom)); }
.editor-header { padding: 16rpx 6rpx 36rpx; animation: enter 420ms ease-out both; }
.eyebrow { display: block; font-size: 18rpx; color: #929a80; letter-spacing: 3rpx; margin-bottom: 22rpx; }
.headline { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 40rpx; line-height: 1.5; color: #414c39; }
.subline { display: block; margin-top: 12rpx; font-size: 25rpx; color: #8c8c7a; line-height: 1.7; }
.mode-switch { display: flex; padding: 8rpx; margin-bottom: 28rpx; background: #eaece1; border-radius: 18rpx; }
button { margin: 0; padding: 0; border: 0; border-radius: 12rpx; font-size: 25rpx; line-height: 1.5; transition: background 180ms ease, transform 180ms ease; }
button::after { border: 0; }
button[disabled] { opacity: .42; }
button:active { transform: scale(.98); }
.mode-switch button { flex: 1; height: 76rpx; background: transparent; color: #858b76; }
.mode-switch .selected { background: #fffdf8; color: #4f6243; box-shadow: 0 4rpx 12rpx rgba(65, 76, 57, .05); }
.panel { background: #fffdf9; padding: 30rpx 26rpx; margin-bottom: 24rpx; border-radius: 24rpx; border: 1rpx solid #e6e5d9; animation: enter 420ms ease-out both; }
.certification-panel { animation-delay: 70ms; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 10rpx; margin-bottom: 18rpx; }
.heading-group { display: flex; align-items: center; gap: 18rpx; }
.section-number { color: #a2ad91; font-size: 25rpx; font-family: Georgia, serif; }
.section-title { font-size: 30rpx; font-weight: 500; color: #45513d; }
.count { color: #a3a18e; font-size: 21rpx; }
.hint { display: block; color: #92907d; font-size: 23rpx; line-height: 1.8; margin-bottom: 24rpx; }
textarea { box-sizing: border-box; width: 100%; border-radius: 14rpx; padding: 22rpx; background: #f5f4ec; color: #53594b; font-size: 27rpx; line-height: 1.9; }
.introduction-input { height: 400rpx; }
.field-note { display: block; color: #a09c88; font-size: 21rpx; line-height: 1.7; margin-top: 16rpx; }
.certification-item { margin: 22rpx 0; padding-bottom: 18rpx; border-bottom: 1rpx solid #eeece2; animation: enter 260ms ease-out both; }
.certification-top { display: flex; align-items: center; justify-content: space-between; gap: 10rpx; margin-bottom: 14rpx; }
.certification-index { font-size: 23rpx; color: #8e977f; }
.item-actions { display: flex; gap: 4rpx; }
.item-actions button { padding: 14rpx 12rpx; background: transparent; color: #728061; font-size: 22rpx; }
.item-actions .remove-button { color: #aa8271; }
.certification-input { min-height: 120rpx; }
.item-count { display: block; margin-top: 10rpx; text-align: right; color: #aaa590; font-size: 20rpx; }
.certification-empty { text-align: center; padding: 32rpx 8rpx; color: #838b72; font-size: 25rpx; }
.add-button { width: 100%; height: 84rpx; border: 1rpx dashed #bdc6ab; background: #f3f5eb; color: #74835f; }
.preview-note { display: block; text-align: center; margin-bottom: 24rpx; color: #9a9682; font-size: 22rpx; }
.empty-preview { padding: 80rpx 32rpx; text-align: center; border: 1rpx dashed #d4d7c8; border-radius: 24rpx; }
.save-bar { position: fixed; z-index: 10; bottom: 0; left: 0; right: 0; display: flex; align-items: center; gap: 24rpx; padding: 24rpx 30rpx calc(24rpx + env(safe-area-inset-bottom)); background: #fffdf9; border-top: 1rpx solid #e6e5d9; }
.save-status { flex: 1; display: flex; align-items: center; gap: 10rpx; color: #9c9e8b; font-size: 23rpx; }
.status-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #9aab83; }
.status-dot--dirty { background: #bd9b64; }
.save-button { min-width: 260rpx; padding: 24rpx 30rpx; border-radius: 999rpx; color: #fffdf5; background: #657857; font-size: 27rpx; }
.state { padding: 70rpx 34rpx; text-align: center; }
.state-title { display: block; font-size: 30rpx; line-height: 1.6; color: #6c775d; }
.state-copy { display: block; color: #96917f; font-size: 25rpx; line-height: 1.8; margin-top: 20rpx; }
.retry-button { padding: 22rpx; margin: 28rpx auto; width: 240rpx; color: #657857; background: #e9edde; }
.skeleton { height: 280rpx; border-radius: 24rpx; margin-bottom: 26rpx; background: #eaece2; animation: breathe 1.2s ease-in-out infinite alternate; }
.skeleton--short { height: 120rpx; width: 70%; }
@keyframes enter { from { opacity: 0; transform: translateY(12rpx); } to { opacity: 1; transform: translateY(0); } }
@keyframes breathe { from { opacity: .45; } to { opacity: .85; } }
@media (prefers-reduced-motion: reduce) { .editor-header, .panel, .certification-item, .skeleton { animation: none; } button { transition: none; } }
</style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<view class="teacher-detail-page"> <view class="teacher-detail-page">
<CustomNavBar title="教练详情" show-back /> <CustomNavBar title="馆主介绍" show-back />
<scroll-view class="detail-scroll" scroll-y :style="{ height: scrollHeight }"> <scroll-view class="detail-scroll" scroll-y :style="{ height: scrollHeight }">
<view class="nav-spacer" :style="{ height: navBarHeight }" /> <view class="nav-spacer" :style="{ height: navBarHeight }" />
@@ -11,7 +11,8 @@
<view class="hero-content"> <view class="hero-content">
<view class="hero-badges"> <view class="hero-badges">
<text v-for="item in teacher.badges" :key="item" class="hero-badge">{{ item }}</text> <text class="hero-badge">馆主 · 亲自授课</text>
<text class="hero-badge">一对一私教</text>
</view> </view>
<text class="hero-name">{{ teacher.name }}</text> <text class="hero-name">{{ teacher.name }}</text>
<text class="hero-title">{{ teacher.title }}</text> <text class="hero-title">{{ teacher.title }}</text>
@@ -22,7 +23,7 @@
<view class="summary-card"> <view class="summary-card">
<view class="summary-head"> <view class="summary-head">
<view> <view>
<text class="summary-eyebrow">Private Pilates Coach</text> <text class="summary-eyebrow">Meet Your Studio Owner</text>
<text class="summary-title">为你定制更细腻的训练节奏</text> <text class="summary-title">为你定制更细腻的训练节奏</text>
</view> </view>
</view> </view>
@@ -31,7 +32,7 @@
<text v-for="item in teacher.specialties" :key="item" class="specialty-pill">{{ item }}</text> <text v-for="item in teacher.specialties" :key="item" class="specialty-pill">{{ item }}</text>
</view> </view>
<text class="summary-intro">{{ teacher.intro }}</text> <text class="summary-intro">一人一馆,专注每一次与你的练习。</text>
<view class="stats-grid"> <view class="stats-grid">
<view v-for="item in teacher.stats" :key="item.label" class="stat-item"> <view v-for="item in teacher.stats" :key="item.label" class="stat-item">
@@ -41,23 +42,7 @@
</view> </view>
</view> </view>
<view class="detail-card"> <OwnerIntroduction :introduction="teacher.intro" :certifications="teacher.certifications" />
<text class="card-title">教练介绍</text>
<text class="card-text card-text--intro">
我是 Iris,一名注重体态、控制与身体感受的普拉提教练。
</text>
<text class="card-text">
我希望带你在稳定、安心的节奏里,找回核心力量、挺拔线条和更轻松的身体状态。
</text>
</view>
<view class="detail-card">
<text class="card-title">认证背景</text>
<view v-for="item in teacher.certifications" :key="item" class="list-row">
<view class="list-dot" />
<text class="list-text">{{ item }}</text>
</view>
</view>
<view class="detail-card"> <view class="detail-card">
<text class="card-title">授课重点</text> <text class="card-title">授课重点</text>
@@ -83,7 +68,7 @@
如果你想改善体态、缓解肩颈腰背不适,提升核心稳定、线条感与身体控制力,这里会是一个很好的开始。 如果你想改善体态、缓解肩颈腰背不适,提升核心稳定、线条感与身体控制力,这里会是一个很好的开始。
</text> </text>
<view class="cta-inline" @tap="goToBooking"> <view class="cta-inline" @tap="goToBooking">
<text class="cta-inline-text">去约 Iris 的课程</text> <text class="cta-inline-text">预约馆主的课程</text>
<text class="cta-inline-arrow">›</text> <text class="cta-inline-arrow">›</text>
</view> </view>
</view> </view>
@@ -106,28 +91,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { irisProfile } from '../../utils/teacher' import OwnerIntroduction from '../../components/OwnerIntroduction.vue'
import { useOwnerProfile } from '../../composables/use-owner-profile'
import { useStudioStore } from '../../stores/studio'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
const teacher = irisProfile const teacher = useOwnerProfile()
const studioStore = useStudioStore()
const navBarHeight = ref('64px') const navBarHeight = ref('64px')
const scrollHeight = ref('500px') const scrollHeight = ref('500px')
onShareAppMessage(() => { onShareAppMessage(() => {
return { return {
title: `${teacher.name}|${teacher.title}`, title: `馆主介绍|${teacher.value.name}`,
path: `/pages/teacher/detail?id=${teacher.id}`, path: '/pages/teacher/detail',
imageUrl: teacher.cover, imageUrl: teacher.value.cover,
} }
}) })
onShareTimeline(() => { onShareTimeline(() => {
return { return {
title: `${teacher.name}|${teacher.title}`, title: `馆主介绍|${teacher.value.name}`,
query: `id=${teacher.id}`, imageUrl: teacher.value.cover,
imageUrl: teacher.cover,
} }
}) })
@@ -135,6 +122,8 @@ onLoad(() => {
updateLayout() updateLayout()
}) })
onShow(() => { void studioStore.fetchStudioInfo() })
function updateLayout() { function updateLayout() {
const { navBarHeight: navBarPx } = getSystemLayout() const { navBarHeight: navBarPx } = getSystemLayout()
const { windowHeight } = uni.getWindowInfo() const { windowHeight } = uni.getWindowInfo()
@@ -148,344 +137,51 @@ function goToBooking() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.teacher-detail-page { .teacher-detail-page { height: 100vh; background: #f7f6ef; box-sizing: border-box; }
height: 100vh; .nav-spacer { width: 100%; flex-shrink: 0; }
background: #f7f2ee; .detail-scroll { width: 100%; }
box-sizing: border-box; .hero-section { position: relative; height: 580rpx; overflow: hidden; background: #b3b6a1; }
} .hero-image { width: 100%; display: block; }
.hero-overlay { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(35, 43, 31, .02) 25%, rgba(35, 43, 31, .7) 100%); }
.nav-spacer { .hero-content { position: absolute; left: 36rpx; right: 36rpx; bottom: 68rpx; z-index: 1; animation: reveal 550ms ease-out both; }
width: 100%; .hero-badges { display: flex; gap: 10rpx; margin-bottom: 20rpx; }
flex-shrink: 0; .hero-badge { padding: 8rpx 18rpx; border-radius: 999rpx; background: rgba(255, 255, 255, .14); border: 1rpx solid rgba(255, 255, 255, .3); color: #fffdf5; font-size: 20rpx; }
} .hero-name { display: block; font-family: Georgia, 'Songti SC', serif; font-size: 64rpx; font-weight: 400; color: #fffdf7; line-height: 1.1; margin-bottom: 14rpx; }
.hero-title { font-size: 25rpx; color: rgba(255, 253, 245, .85); }
.hero-section { .content-wrap { position: relative; margin-top: -28rpx; padding: 0 26rpx; }
position: relative; .summary-card, .detail-card { border: 1rpx solid #e7e2d8; background: #fffdf9; border-radius: 24rpx; }
height: 580rpx; .summary-card { padding: 32rpx 28rpx; margin-bottom: 20rpx; animation: reveal 500ms ease-out both; }
overflow: hidden; .summary-head { margin-bottom: 22rpx; }
} .summary-eyebrow { display: block; font-size: 18rpx; color: #92957f; letter-spacing: 3rpx; text-transform: uppercase; margin-bottom: 14rpx; }
.summary-title { font-family: 'Songti SC', 'STSong', serif; font-size: 35rpx; line-height: 1.5; font-weight: 500; color: #434c3d; }
.hero-image { .specialty-row { display: flex; flex-wrap: wrap; gap: 10rpx; margin-bottom: 22rpx; }
width: 100%; .specialty-pill { padding: 10rpx 18rpx; border-radius: 10rpx; background: #eef1e7; color: #748367; font-size: 22rpx; }
display: block; .summary-intro, .card-text { display: block; font-size: 25rpx; line-height: 1.9; color: #7d7d69; }
} .stats-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12rpx; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #eeeae1; }
.stat-item { text-align: center; padding: 8rpx 0; }
.hero-overlay { .stat-item + .stat-item { border-left: 1rpx solid #eeeae1; }
position: absolute; .stat-value { display: block; font-size: 24rpx; line-height: 1.5; color: #536048; margin-bottom: 8rpx; }
inset: 0; .stat-label { font-size: 20rpx; color: #9b9786; }
background: .detail-card { padding: 30rpx; margin-bottom: 20rpx; }
linear-gradient(180deg, rgba(33, 24, 22, 0.08) 0%, rgba(33, 24, 22, 0.08) 34%, rgba(33, 24, 22, 0.64) 100%), .card-title { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; font-weight: 500; color: #434c3d; margin-bottom: 24rpx; }
radial-gradient(circle at top right, rgba(255, 228, 208, 0.26), transparent 28%); .focus-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; }
} .focus-item { min-height: 164rpx; box-sizing: border-box; border-radius: 16rpx; padding: 22rpx 18rpx; display: flex; flex-direction: column; background: #f4f4ec; }
.focus-title { font-size: 25rpx; font-weight: 500; color: #68755a; }
.hero-content { .focus-desc { margin-top: 12rpx; font-size: 22rpx; line-height: 1.8; color: #99917e; }
position: absolute; .cta-card { background: #f0f2e8; }
left: 32rpx; .fit-grid { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 22rpx; }
right: 32rpx; .fit-pill { padding: 10rpx 18rpx; border-radius: 999rpx; border: 1rpx solid #d6dccb; color: #7a876a; font-size: 22rpx; }
bottom: 92rpx; .cta-inline { margin-top: 24rpx; padding: 24rpx 28rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: space-between; background: #657857; transition: transform 180ms ease; }
z-index: 1; .cta-inline-text { color: #fffdf5; font-size: 26rpx; }
} .cta-inline-arrow { color: #e2e8d6; font-size: 34rpx; }
.bottom-space { height: calc(190rpx + env(safe-area-inset-bottom)); }
.hero-badges { .bottom-bar { position: fixed; left: 24rpx; right: 24rpx; bottom: calc(20rpx + env(safe-area-inset-bottom)); z-index: 20; display: flex; align-items: center; gap: 18rpx; padding: 18rpx 22rpx; border-radius: 24rpx; border: 1rpx solid #e1e4d5; background: #fffdf9; box-shadow: 0 12rpx 40rpx rgba(65, 76, 57, .1); }
display: flex; .bottom-note { flex: 1; min-width: 0; }
gap: 10rpx; .bottom-note-title { display: block; font-size: 25rpx; color: #526044; margin-bottom: 8rpx; }
margin-bottom: 18rpx; .bottom-note-sub { font-size: 20rpx; color: #9b9786; }
} .booking-btn { width: 216rpx; height: 86rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #657857; transition: transform 180ms ease; }
.booking-btn-text { font-size: 27rpx; color: #fffdf5; }
.hero-badge { .booking-btn:active, .cta-inline:active { transform: scale(.97); }
height: 42rpx; @keyframes reveal { from { opacity: 0; transform: translateY(16rpx); } to { opacity: 1; transform: translateY(0); } }
padding: 0 16rpx; @media (prefers-reduced-motion: reduce) { .hero-content, .summary-card { animation: none; } .booking-btn, .cta-inline { transition: none; } }
border-radius: 999rpx;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.24);
color: #fff8f3;
font-size: 20rpx;
backdrop-filter: blur(10rpx);
}
.hero-name {
display: block;
font-size: 56rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.05;
margin-bottom: 10rpx;
}
.hero-title {
font-size: 26rpx;
color: rgba(255, 247, 241, 0.88);
}
.detail-scroll {
width: 100%;
}
.content-wrap {
position: relative;
margin-top: -8rpx;
padding: 0 24rpx;
}
.summary-card,
.detail-card {
background: rgba(255, 255, 255, 0.92);
border-radius: 28rpx;
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
backdrop-filter: blur(12rpx);
}
.summary-card {
padding: 28rpx;
margin-bottom: 18rpx;
}
.summary-head {
margin-bottom: 18rpx;
}
.summary-eyebrow {
display: block;
font-size: 20rpx;
color: #c09a89;
letter-spacing: 3rpx;
text-transform: uppercase;
margin-bottom: 8rpx;
}
.summary-title {
font-size: 34rpx;
line-height: 1.35;
font-weight: 700;
color: #2f2622;
}
.specialty-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-bottom: 18rpx;
}
.specialty-pill {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(92, 126, 151, 0.12);
color: #56778a;
font-size: 22rpx;
font-weight: 600;
}
.summary-intro,
.card-text,
.list-text {
font-size: 25rpx;
line-height: 1.78;
color: #5f534c;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 24rpx;
}
.stat-item {
padding: 20rpx 16rpx;
border-radius: 20rpx;
background: linear-gradient(180deg, #fff8f4 0%, #f7f1ec 100%);
text-align: center;
}
.stat-value {
display: block;
font-size: 24rpx;
line-height: 1.45;
font-weight: 700;
color: #3f3029;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 20rpx;
color: #9c8579;
}
.detail-card {
padding: 28rpx;
margin-bottom: 18rpx;
}
.card-title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #342925;
margin-bottom: 18rpx;
}
.card-text + .card-text {
margin-top: 16rpx;
}
.card-text--intro {
font-size: 28rpx;
line-height: 1.7;
font-weight: 600;
color: #43352f;
}
.list-row {
display: flex;
align-items: flex-start;
gap: 14rpx;
}
.list-row + .list-row {
margin-top: 18rpx;
}
.list-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
margin-top: 14rpx;
flex-shrink: 0;
background: linear-gradient(135deg, #ff8b69 0%, #ff593f 100%);
}
.focus-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.focus-item {
min-height: 164rpx;
border-radius: 22rpx;
padding: 22rpx 18rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
background: linear-gradient(135deg, #fff8f5 0%, #f6eee9 100%);
}
.focus-title {
font-size: 24rpx;
font-weight: 600;
color: #5c4a42;
}
.focus-desc {
margin-top: 10rpx;
font-size: 21rpx;
line-height: 1.65;
color: #8a7569;
}
.cta-card {
background: linear-gradient(135deg, #fff6f1 0%, #fffdfc 100%);
}
.fit-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 18rpx;
}
.fit-pill {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 126, 92, 0.12);
color: #b3573f;
font-size: 22rpx;
font-weight: 600;
}
.cta-inline {
margin-top: 22rpx;
height: 88rpx;
border-radius: 999rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #ff7f5e 0%, #ff543c 100%);
box-shadow: 0 16rpx 28rpx rgba(255, 95, 73, 0.22);
}
.cta-inline-text,
.cta-inline-arrow {
color: #ffffff;
font-weight: 700;
}
.cta-inline-text {
font-size: 28rpx;
}
.cta-inline-arrow {
font-size: 38rpx;
line-height: 1;
}
.bottom-space {
height: 160rpx;
}
.bottom-bar {
position: fixed;
left: 24rpx;
right: 24rpx;
bottom: 24rpx;
z-index: 20;
display: flex;
align-items: center;
gap: 18rpx;
padding: 18rpx;
border-radius: 28rpx;
background: rgba(35, 29, 27, 0.9);
backdrop-filter: blur(18rpx);
box-shadow: 0 20rpx 48rpx rgba(36, 28, 26, 0.18);
}
.bottom-note {
flex: 1;
min-width: 0;
}
.bottom-note-title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #fffaf6;
margin-bottom: 6rpx;
}
.bottom-note-sub {
font-size: 20rpx;
color: rgba(255, 244, 236, 0.72);
}
.booking-btn {
width: 224rpx;
height: 88rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ff815e 0%, #ff5136 100%);
}
.booking-btn-text {
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 1rpx;
}
</style> </style>

View File

@@ -1,3 +1,5 @@
import { DEFAULT_OWNER_INTRODUCTION, DEFAULT_OWNER_CERTIFICATIONS } from '@mp-pilates/shared'
export interface TeacherProfile { export interface TeacherProfile {
id: string id: string
name: string name: string
@@ -20,14 +22,8 @@ export const irisProfile: TeacherProfile = {
cover: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/teacher_avatar.jpg', cover: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/teacher_avatar.jpg',
badges: ['高级', 'STOTT PILATES'], badges: ['高级', 'STOTT PILATES'],
specialties: ['塑性训练', '体态调整', '产后恢复'], specialties: ['塑性训练', '体态调整', '产后恢复'],
intro: '我擅长把专业训练拆解成身体真正听得懂的节奏,让你在安全、稳定与被看见的陪伴里,一点点找回力量、线条与自信。', intro: DEFAULT_OWNER_INTRODUCTION,
certifications: [ certifications: [...DEFAULT_OWNER_CERTIFICATIONS],
'斯多特塑身机初&中级认证教练',
'斯多特全场馆认证教练',
'曼丽丘斯多特塑身机弹跳网芭杆工作坊',
'塑身机上的高尔夫与旋转运动调理工作坊',
'四维人体运动解剖认证',
],
teachingFocus: [ teachingFocus: [
{ {
title: '核心激活', title: '核心激活',

View File

@@ -0,0 +1,3 @@
ALTER TABLE `studio_config`
ADD COLUMN `owner_introduction` TEXT NULL,
ADD COLUMN `owner_certifications` JSON NULL;

View File

@@ -339,6 +339,8 @@ model StudioConfig {
longitude Decimal? @db.Decimal(10, 7) longitude Decimal? @db.Decimal(10, 7)
cancelHoursLimit Int @default(2) @map("cancel_hours_limit") cancelHoursLimit Int @default(2) @map("cancel_hours_limit")
photos Json @default("[]") photos Json @default("[]")
ownerIntroduction String? @map("owner_introduction") @db.Text
ownerCertifications Json? @map("owner_certifications")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@map("studio_config") @@map("studio_config")

View File

@@ -132,6 +132,8 @@ const mockStudioConfig = {
longitude: null, longitude: null,
cancelHoursLimit: 2, cancelHoursLimit: 2,
photos: [], photos: [],
ownerIntroduction: '',
ownerCertifications: [],
updatedAt: new Date(), updatedAt: new Date(),
} }

View File

@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'
import { StudioService } from '../studio.service' import { StudioService } from '../studio.service'
import { PrismaService } from '../../prisma/prisma.service' import { PrismaService } from '../../prisma/prisma.service'
import { UpdateStudioDto } from '../dto/update-studio.dto' import { UpdateStudioDto } from '../dto/update-studio.dto'
import { DEFAULT_OWNER_INTRODUCTION, DEFAULT_OWNER_CERTIFICATIONS } from '@mp-pilates/shared'
const mockStudioConfig = { const mockStudioConfig = {
id: 'test-id-001', id: 'test-id-001',
@@ -14,6 +15,8 @@ const mockStudioConfig = {
longitude: null, longitude: null,
cancelHoursLimit: 2, cancelHoursLimit: 2,
photos: [], photos: [],
ownerIntroduction: DEFAULT_OWNER_INTRODUCTION,
ownerCertifications: [...DEFAULT_OWNER_CERTIFICATIONS],
updatedAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'),
} }
@@ -41,6 +44,25 @@ describe('StudioService', () => {
}) })
describe('getInfo()', () => { describe('getInfo()', () => {
it('preserves the original introduction and credentials for an unconfigured studio', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue({
...mockStudioConfig,
ownerIntroduction: null,
ownerCertifications: null,
})
expect(await service.getInfo()).toEqual(mockStudioConfig)
})
it('does not restore defaults when the owner intentionally clears content', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue({
...mockStudioConfig,
ownerIntroduction: '',
ownerCertifications: [],
})
expect(await service.getInfo()).toMatchObject({ ownerIntroduction: '', ownerCertifications: [] })
})
it('should return existing config when one exists', async () => { it('should return existing config when one exists', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig) mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
@@ -67,6 +89,42 @@ describe('StudioService', () => {
}) })
describe('updateInfo()', () => { describe('updateInfo()', () => {
it('persists owner content with paragraphs and credential order, independently of studio settings', async () => {
const dto: UpdateStudioDto = {
ownerIntroduction: ' 我的教学经历\n\n我的练习理念 ',
ownerCertifications: [' 认证 B ', '', '认证 A', '认证 B'],
}
const normalized = { ownerIntroduction: '我的教学经历\n\n我的练习理念', ownerCertifications: ['认证 B', '认证 A'] }
mockPrismaService.studioConfig.findFirst.mockResolvedValue({ id: mockStudioConfig.id })
mockPrismaService.studioConfig.update.mockResolvedValue({ ...mockStudioConfig, ...normalized })
expect(await service.updateInfo(dto)).toMatchObject(normalized)
expect(mockPrismaService.studioConfig.update).toHaveBeenCalledWith({
where: { id: mockStudioConfig.id },
data: { ...normalized, photos: undefined },
})
})
it('saves empty content and does not overwrite owner content when updating other settings', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue({ id: mockStudioConfig.id })
mockPrismaService.studioConfig.update.mockResolvedValue({ ...mockStudioConfig, ownerIntroduction: '', ownerCertifications: [] })
await service.updateInfo({ ownerIntroduction: '', ownerCertifications: [] })
expect(mockPrismaService.studioConfig.update.mock.calls[0][0].data).toMatchObject({ ownerIntroduction: '', ownerCertifications: [] })
await service.updateInfo({ name: '新的馆名' })
const data = mockPrismaService.studioConfig.update.mock.calls[1][0].data
expect(data.ownerIntroduction).toBeUndefined()
expect(data.ownerCertifications).toBeUndefined()
})
it('creates a studio with editable owner content if no config exists yet', async () => {
const dto = { ownerIntroduction: '馆主的故事', ownerCertifications: ['认证 A'] }
mockPrismaService.studioConfig.findFirst.mockResolvedValue(null)
mockPrismaService.studioConfig.create.mockResolvedValue({ ...mockStudioConfig, ...dto })
expect(await service.updateInfo(dto)).toMatchObject(dto)
expect(mockPrismaService.studioConfig.create).toHaveBeenCalledWith({ data: { name: '普拉提工作室', ...dto, photos: undefined } })
})
it('should update and return a new config object with changes applied', async () => { it('should update and return a new config object with changes applied', async () => {
const dto: UpdateStudioDto = { const dto: UpdateStudioDto = {
name: 'New Studio Name', name: 'New Studio Name',

View File

@@ -0,0 +1,31 @@
import { plainToInstance } from 'class-transformer'
import { validate } from 'class-validator'
import { OWNER_INTRODUCTION_MAX_LENGTH, OWNER_CERTIFICATIONS_MAX_COUNT, OWNER_CERTIFICATION_MAX_LENGTH } from '@mp-pilates/shared'
import { UpdateStudioDto } from '../dto/update-studio.dto'
const errorsFor = (input: Record<string, unknown>) => validate(plainToInstance(UpdateStudioDto, input))
describe('UpdateStudioDto owner content', () => {
it.each([
{},
{ ownerIntroduction: '', ownerCertifications: [] },
{ ownerIntroduction: '我的介绍\n\n我的理念', ownerCertifications: ['认证 A', '认证 B'] },
{ ownerIntroduction: '字'.repeat(OWNER_INTRODUCTION_MAX_LENGTH), ownerCertifications: Array(OWNER_CERTIFICATIONS_MAX_COUNT).fill('字'.repeat(OWNER_CERTIFICATION_MAX_LENGTH)) },
])('accepts partial updates, intentional clearing and content within limits', async input => {
expect(await errorsFor(input)).toHaveLength(0)
})
it.each([
{ ownerIntroduction: null },
{ ownerIntroduction: 123 },
{ ownerIntroduction: '字'.repeat(OWNER_INTRODUCTION_MAX_LENGTH + 1) },
{ ownerCertifications: null },
{ ownerCertifications: '认证 A' },
{ ownerCertifications: [123] },
{ ownerCertifications: [null] },
{ ownerCertifications: ['字'.repeat(OWNER_CERTIFICATION_MAX_LENGTH + 1)] },
{ ownerCertifications: Array(OWNER_CERTIFICATIONS_MAX_COUNT + 1).fill('认证') },
])('rejects malformed or oversized content: %j', async input => {
expect((await errorsFor(input)).length).toBeGreaterThan(0)
})
})

View File

@@ -3,10 +3,17 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
IsUrl, ArrayMaxSize,
MaxLength,
ValidateIf,
Max, Max,
Min, Min,
} from 'class-validator' } from 'class-validator'
import {
OWNER_INTRODUCTION_MAX_LENGTH,
OWNER_CERTIFICATIONS_MAX_COUNT,
OWNER_CERTIFICATION_MAX_LENGTH,
} from '@mp-pilates/shared'
export class UpdateStudioDto { export class UpdateStudioDto {
@IsOptional() @IsOptional()
@@ -50,4 +57,16 @@ export class UpdateStudioDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
photos?: string[] photos?: string[]
@ValidateIf((_object, value) => value !== undefined)
@IsString()
@MaxLength(OWNER_INTRODUCTION_MAX_LENGTH)
ownerIntroduction?: string
@ValidateIf((_object, value) => value !== undefined)
@IsArray()
@ArrayMaxSize(OWNER_CERTIFICATIONS_MAX_COUNT)
@IsString({ each: true })
@MaxLength(OWNER_CERTIFICATION_MAX_LENGTH, { each: true })
ownerCertifications?: string[]
} }

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { StudioConfig as PrismaStudioConfig } from '@prisma/client' import { StudioConfig as PrismaStudioConfig } from '@prisma/client'
import { DEFAULT_OWNER_INTRODUCTION, DEFAULT_OWNER_CERTIFICATIONS } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { UpdateStudioDto } from './dto/update-studio.dto' import { UpdateStudioDto } from './dto/update-studio.dto'
@@ -30,7 +31,9 @@ export class StudioService {
const data = { const data = {
...dto, ...dto,
photos: dto.photos ? this.normalizePhotos(dto.photos) : undefined, photos: dto.photos ? this.normalizeStringList(dto.photos) : undefined,
ownerIntroduction: dto.ownerIntroduction?.trim(),
ownerCertifications: dto.ownerCertifications === undefined ? undefined : this.normalizeStringList(dto.ownerCertifications),
} }
const record = existing const record = existing
@@ -50,11 +53,15 @@ export class StudioService {
...config, ...config,
latitude: config.latitude == null ? null : Number(config.latitude), latitude: config.latitude == null ? null : Number(config.latitude),
longitude: config.longitude == null ? null : Number(config.longitude), longitude: config.longitude == null ? null : Number(config.longitude),
photos: this.normalizePhotos(config.photos), photos: this.normalizeStringList(config.photos),
ownerIntroduction: config.ownerIntroduction ?? DEFAULT_OWNER_INTRODUCTION,
ownerCertifications: config.ownerCertifications == null
? [...DEFAULT_OWNER_CERTIFICATIONS]
: this.normalizeStringList(config.ownerCertifications),
} }
} }
private normalizePhotos(value: unknown): string[] { private normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return [] return []
} }

View File

@@ -75,6 +75,8 @@ export type {
} from './membership-renewal' } from './membership-renewal'
// Types // Types
export * from './owner-profile'
export type { export type {
TeachingAnalytics, TeachingAnalytics,
TeachingAnalyticsRecord, TeachingAnalyticsRecord,

View File

@@ -0,0 +1,14 @@
// Null database fields use these initial values. Empty content is intentional.
export const DEFAULT_OWNER_INTRODUCTION = '我是 Iris,一名注重体态、控制与身体感受的普拉提教练。\n\n我希望带你在稳定、安心的节奏里,找回核心力量、挺拔线条和更轻松的身体状态。'
export const DEFAULT_OWNER_CERTIFICATIONS = [
'斯多特塑身机初&中级认证教练',
'斯多特全场馆认证教练',
'曼丽丘斯多特塑身机弹跳网芭杆工作坊',
'塑身机上的高尔夫与旋转运动调理工作坊',
'四维人体运动解剖认证',
]
export const OWNER_INTRODUCTION_MAX_LENGTH = 5000
export const OWNER_CERTIFICATIONS_MAX_COUNT = 30
export const OWNER_CERTIFICATION_MAX_LENGTH = 200

View File

@@ -9,6 +9,8 @@ export interface StudioConfig {
readonly longitude: number | null readonly longitude: number | null
readonly cancelHoursLimit: number readonly cancelHoursLimit: number
readonly photos: string[] readonly photos: string[]
readonly ownerIntroduction: string
readonly ownerCertifications: string[]
readonly updatedAt: string readonly updatedAt: string
} }
@@ -24,6 +26,8 @@ export interface UpdateStudioConfigDto {
readonly longitude?: number readonly longitude?: number
readonly cancelHoursLimit?: number readonly cancelHoursLimit?: number
readonly photos?: string[] readonly photos?: string[]
readonly ownerIntroduction?: string
readonly ownerCertifications?: string[]
} }
export interface CreateStudioUploadCredentialDto { export interface CreateStudioUploadCredentialDto {