diff --git a/docs/owner-introduction.md b/docs/owner-introduction.md
new file mode 100644
index 0000000..455b824
--- /dev/null
+++ b/docs/owner-introduction.md
@@ -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. 修改后返回,确认出现未保存提醒;网络加载或保存失败时可重试,编辑内容不丢失。
diff --git a/packages/app/src/components/AboutSection.vue b/packages/app/src/components/AboutSection.vue
index 6152ecf..1004a00 100644
--- a/packages/app/src/components/AboutSection.vue
+++ b/packages/app/src/components/AboutSection.vue
@@ -2,7 +2,7 @@
@@ -19,7 +19,7 @@
{{ teacher.name }}
-
+ 馆主
{{ teacher.title }}
@@ -46,12 +46,12 @@
+
+
diff --git a/packages/app/src/composables/use-owner-profile.ts b/packages/app/src/composables/use-owner-profile.ts
new file mode 100644
index 0000000..f95cfab
--- /dev/null
+++ b/packages/app/src/composables/use-owner-profile.ts
@@ -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,
+ }))
+}
diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json
index 556b632..54184e2 100644
--- a/packages/app/src/pages.json
+++ b/packages/app/src/pages.json
@@ -201,6 +201,12 @@
"navigationStyle": "custom"
}
},
+ {
+ "path": "owner",
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
{
"path": "member-progress",
"style": {
diff --git a/packages/app/src/pages/admin/index.vue b/packages/app/src/pages/admin/index.vue
index ba26f06..208dae5 100644
--- a/packages/app/src/pages/admin/index.vue
+++ b/packages/app/src/pages/admin/index.vue
@@ -82,6 +82,10 @@
工作室设置
›
+
+ 馆主介绍
+ ›
+
增加订阅次数
diff --git a/packages/app/src/pages/admin/owner.vue b/packages/app/src/pages/admin/owner.vue
new file mode 100644
index 0000000..4a19e81
--- /dev/null
+++ b/packages/app/src/pages/admin/owner.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+ 正在读取馆主介绍…
+
+
+ 暂时无法读取内容
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 以下为当前内容预览,保存后学员可见
+
+
+ 把你的故事写在这里
+ 填写个人介绍或添加认证后,即可预览展示效果。
+
+
+
+
+
+
+ 01个人介绍
+ {{ introduction.length }} / {{ OWNER_INTRODUCTION_MAX_LENGTH }}
+
+ 写下你的教学经历、练习理念,或想对学员说的话。
+
+ 支持换行与分段,首页会同步展示介绍摘要。
+
+
+
+
+ 02专业认证
+ {{ certifications.length }} / {{ OWNER_CERTIFICATIONS_MAX_COUNT }}
+
+ 逐项填写证书或进修经历,按你希望的顺序展示。
+
+
+ 认证 {{ String(index + 1).padStart(2, '0') }}
+
+
+
+
+
+
+
+ {{ item.text.length }} / {{ OWNER_CERTIFICATION_MAX_LENGTH }}
+
+
+ 每一次进修,都值得被看见
+ 点击下方按钮,添加第一项专业认证
+
+
+ 留空的模块保存后不展示,可随时回来补充。
+
+
+
+
+ {{ isDirty ? '有未保存的修改' : '内容已同步' }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages/teacher/detail.vue b/packages/app/src/pages/teacher/detail.vue
index 11afc2f..16450cb 100644
--- a/packages/app/src/pages/teacher/detail.vue
+++ b/packages/app/src/pages/teacher/detail.vue
@@ -1,6 +1,6 @@
-
+
@@ -11,7 +11,8 @@
- {{ item }}
+ 馆主 · 亲自授课
+ 一对一私教
{{ teacher.name }}
{{ teacher.title }}
@@ -22,7 +23,7 @@
- Private Pilates Coach
+ Meet Your Studio Owner
为你定制更细腻的训练节奏
@@ -31,7 +32,7 @@
{{ item }}
- {{ teacher.intro }}
+ 一人一馆,专注每一次与你的练习。
@@ -41,23 +42,7 @@
-
- 教练介绍
-
- 我是 Iris,一名注重体态、控制与身体感受的普拉提教练。
-
-
- 我希望带你在稳定、安心的节奏里,找回核心力量、挺拔线条和更轻松的身体状态。
-
-
-
-
- 认证背景
-
-
- {{ item }}
-
-
+
授课重点
@@ -83,7 +68,7 @@
如果你想改善体态、缓解肩颈腰背不适,提升核心稳定、线条感与身体控制力,这里会是一个很好的开始。
- 去约 Iris 的课程
+ 预约馆主的课程
›
@@ -106,28 +91,30 @@
diff --git a/packages/app/src/utils/teacher.ts b/packages/app/src/utils/teacher.ts
index 72ed3e0..0bedd21 100644
--- a/packages/app/src/utils/teacher.ts
+++ b/packages/app/src/utils/teacher.ts
@@ -1,3 +1,5 @@
+import { DEFAULT_OWNER_INTRODUCTION, DEFAULT_OWNER_CERTIFICATIONS } from '@mp-pilates/shared'
+
export interface TeacherProfile {
id: 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',
badges: ['高级', 'STOTT PILATES'],
specialties: ['塑性训练', '体态调整', '产后恢复'],
- intro: '我擅长把专业训练拆解成身体真正听得懂的节奏,让你在安全、稳定与被看见的陪伴里,一点点找回力量、线条与自信。',
- certifications: [
- '斯多特塑身机初&中级认证教练',
- '斯多特全场馆认证教练',
- '曼丽丘斯多特塑身机弹跳网芭杆工作坊',
- '塑身机上的高尔夫与旋转运动调理工作坊',
- '四维人体运动解剖认证',
- ],
+ intro: DEFAULT_OWNER_INTRODUCTION,
+ certifications: [...DEFAULT_OWNER_CERTIFICATIONS],
teachingFocus: [
{
title: '核心激活',
diff --git a/packages/server/prisma/migrations/20261025090000_owner_introduction/migration.sql b/packages/server/prisma/migrations/20261025090000_owner_introduction/migration.sql
new file mode 100644
index 0000000..f28728a
--- /dev/null
+++ b/packages/server/prisma/migrations/20261025090000_owner_introduction/migration.sql
@@ -0,0 +1,3 @@
+ALTER TABLE `studio_config`
+ ADD COLUMN `owner_introduction` TEXT NULL,
+ ADD COLUMN `owner_certifications` JSON NULL;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index bf50195..f3a9fdf 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -339,6 +339,8 @@ model StudioConfig {
longitude Decimal? @db.Decimal(10, 7)
cancelHoursLimit Int @default(2) @map("cancel_hours_limit")
photos Json @default("[]")
+ ownerIntroduction String? @map("owner_introduction") @db.Text
+ ownerCertifications Json? @map("owner_certifications")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("studio_config")
diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts
index a8a5e3a..0fd9248 100644
--- a/packages/server/src/booking/__tests__/booking.service.spec.ts
+++ b/packages/server/src/booking/__tests__/booking.service.spec.ts
@@ -132,6 +132,8 @@ const mockStudioConfig = {
longitude: null,
cancelHoursLimit: 2,
photos: [],
+ ownerIntroduction: '',
+ ownerCertifications: [],
updatedAt: new Date(),
}
diff --git a/packages/server/src/studio/__tests__/studio.service.spec.ts b/packages/server/src/studio/__tests__/studio.service.spec.ts
index 3b11f7b..060de68 100644
--- a/packages/server/src/studio/__tests__/studio.service.spec.ts
+++ b/packages/server/src/studio/__tests__/studio.service.spec.ts
@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'
import { StudioService } from '../studio.service'
import { PrismaService } from '../../prisma/prisma.service'
import { UpdateStudioDto } from '../dto/update-studio.dto'
+import { DEFAULT_OWNER_INTRODUCTION, DEFAULT_OWNER_CERTIFICATIONS } from '@mp-pilates/shared'
const mockStudioConfig = {
id: 'test-id-001',
@@ -14,6 +15,8 @@ const mockStudioConfig = {
longitude: null,
cancelHoursLimit: 2,
photos: [],
+ ownerIntroduction: DEFAULT_OWNER_INTRODUCTION,
+ ownerCertifications: [...DEFAULT_OWNER_CERTIFICATIONS],
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
@@ -41,6 +44,25 @@ describe('StudioService', () => {
})
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 () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
@@ -67,6 +89,42 @@ describe('StudioService', () => {
})
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 () => {
const dto: UpdateStudioDto = {
name: 'New Studio Name',
diff --git a/packages/server/src/studio/__tests__/update-studio.dto.spec.ts b/packages/server/src/studio/__tests__/update-studio.dto.spec.ts
new file mode 100644
index 0000000..7253ccc
--- /dev/null
+++ b/packages/server/src/studio/__tests__/update-studio.dto.spec.ts
@@ -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) => 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)
+ })
+})
diff --git a/packages/server/src/studio/dto/update-studio.dto.ts b/packages/server/src/studio/dto/update-studio.dto.ts
index 1a85db3..1a682bb 100644
--- a/packages/server/src/studio/dto/update-studio.dto.ts
+++ b/packages/server/src/studio/dto/update-studio.dto.ts
@@ -3,10 +3,17 @@ import {
IsNumber,
IsOptional,
IsString,
- IsUrl,
+ ArrayMaxSize,
+ MaxLength,
+ ValidateIf,
Max,
Min,
} from 'class-validator'
+import {
+ OWNER_INTRODUCTION_MAX_LENGTH,
+ OWNER_CERTIFICATIONS_MAX_COUNT,
+ OWNER_CERTIFICATION_MAX_LENGTH,
+} from '@mp-pilates/shared'
export class UpdateStudioDto {
@IsOptional()
@@ -50,4 +57,16 @@ export class UpdateStudioDto {
@IsArray()
@IsString({ each: true })
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[]
}
diff --git a/packages/server/src/studio/studio.service.ts b/packages/server/src/studio/studio.service.ts
index bb7338a..ccab352 100644
--- a/packages/server/src/studio/studio.service.ts
+++ b/packages/server/src/studio/studio.service.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'
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 { UpdateStudioDto } from './dto/update-studio.dto'
@@ -30,7 +31,9 @@ export class StudioService {
const data = {
...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
@@ -50,11 +53,15 @@ export class StudioService {
...config,
latitude: config.latitude == null ? null : Number(config.latitude),
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)) {
return []
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 213eb98..64c5971 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -75,6 +75,8 @@ export type {
} from './membership-renewal'
// Types
+export * from './owner-profile'
+
export type {
TeachingAnalytics,
TeachingAnalyticsRecord,
diff --git a/packages/shared/src/owner-profile.ts b/packages/shared/src/owner-profile.ts
new file mode 100644
index 0000000..24fbd78
--- /dev/null
+++ b/packages/shared/src/owner-profile.ts
@@ -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
diff --git a/packages/shared/src/types/studio.ts b/packages/shared/src/types/studio.ts
index 4516739..8cf1989 100644
--- a/packages/shared/src/types/studio.ts
+++ b/packages/shared/src/types/studio.ts
@@ -9,6 +9,8 @@ export interface StudioConfig {
readonly longitude: number | null
readonly cancelHoursLimit: number
readonly photos: string[]
+ readonly ownerIntroduction: string
+ readonly ownerCertifications: string[]
readonly updatedAt: string
}
@@ -24,6 +26,8 @@ export interface UpdateStudioConfigDto {
readonly longitude?: number
readonly cancelHoursLimit?: number
readonly photos?: string[]
+ readonly ownerIntroduction?: string
+ readonly ownerCertifications?: string[]
}
export interface CreateStudioUploadCredentialDto {