feat: 支持馆主个人信息编辑
This commit is contained in:
25
docs/owner-introduction.md
Normal file
25
docs/owner-introduction.md
Normal 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. 修改后返回,确认出现未保存提醒;网络加载或保存失败时可重试,编辑内容不丢失。
|
||||||
@@ -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; }
|
||||||
|
|||||||
44
packages/app/src/components/OwnerIntroduction.vue
Normal file
44
packages/app/src/components/OwnerIntroduction.vue
Normal 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>
|
||||||
12
packages/app/src/composables/use-owner-profile.ts
Normal file
12
packages/app/src/composables/use-owner-profile.ts
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -201,6 +201,12 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "owner",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "member-progress",
|
"path": "member-progress",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
239
packages/app/src/pages/admin/owner.vue
Normal file
239
packages/app/src/pages/admin/owner.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '核心激活',
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `studio_config`
|
||||||
|
ADD COLUMN `owner_introduction` TEXT NULL,
|
||||||
|
ADD COLUMN `owner_certifications` JSON NULL;
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ const mockStudioConfig = {
|
|||||||
longitude: null,
|
longitude: null,
|
||||||
cancelHoursLimit: 2,
|
cancelHoursLimit: 2,
|
||||||
photos: [],
|
photos: [],
|
||||||
|
ownerIntroduction: '',
|
||||||
|
ownerCertifications: [],
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
14
packages/shared/src/owner-profile.ts
Normal file
14
packages/shared/src/owner-profile.ts
Normal 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user