feat: 支持教学案例展示功能

This commit is contained in:
richarjiang
2026-09-18 18:26:47 +08:00
parent 95b9f6511c
commit 87afb786b9
28 changed files with 950 additions and 5 deletions

View File

@@ -0,0 +1,18 @@
<template>
<view class="case-card">
<view class="heading" @tap="open"><view class="copy"><text class="name">{{ item.displayName }} · 练习记录</text><text class="title">{{ item.title }}</text></view><view class="lessons"><text class="number">{{ item.lessonCount }}</text><text>节练习</text></view></view>
<TeachingCaseGallery :frames="item.frames" :lesson-count="item.lessonCount" @refresh="$emit('refresh')" />
<text class="achievement">{{ item.achievement }}</text>
<button class="link" @tap="open">看这段改变的过程 <text>↗</text></button>
</view>
</template>
<script setup lang="ts">
import type { TeachingCasePublic } from '@mp-pilates/shared'
import TeachingCaseGallery from './TeachingCaseGallery.vue'
const props = defineProps<{ item: TeachingCasePublic }>()
defineEmits<{ refresh: [] }>()
function open() { uni.navigateTo({ url: `/pages/home/teaching-case?id=${props.item.id}` }) }
</script>
<style scoped>
.case-card{padding:28rpx 0;border-bottom:1rpx solid #e4ded4;}.heading{display:flex;justify-content:space-between;gap:20rpx;align-items:center;margin-bottom:24rpx;}.copy{min-width:0;}.name{display:block;font-size:22rpx;color:#8b8174;margin-bottom:12rpx;}.title{font-size:34rpx;line-height:1.5;color:#4b4940;font-family:'Songti SC','STSong',serif;}.lessons{flex-shrink:0;text-align:center;color:#64705c;font-size:20rpx;}.lessons text{display:block;}.lessons .number{font-family:'Baskerville',serif;font-size:58rpx;line-height:1.1;}.achievement{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:22rpx;font-size:25rpx;line-height:1.8;color:#746d61;white-space:pre-wrap;}.link{padding:0;margin:10rpx 0 0;text-align:left;font-size:25rpx;line-height:80rpx;color:#596b50;background:transparent;}.link text{float:right;}.link::after{border:0;}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<view class="gallery">
<swiper class="slides" :current="current" :autoplay="false" @change="current = $event.detail.current">
<swiper-item v-for="(frame, index) in frames" :key="index">
<view class="frame" :class="{ pair: frame.kind === 'pair' }">
<view class="photo" @tap="preview(frame.beforeUrl)">
<image v-if="frame.beforeUrl" :src="frame.beforeUrl" mode="aspectFit" lazy-load @error="failed = true" />
<text v-if="frame.kind === 'pair'" class="badge">练习前</text>
</view>
<view v-if="frame.kind === 'pair'" class="photo" @tap="preview(frame.afterUrl)">
<image v-if="frame.afterUrl" :src="frame.afterUrl" mode="aspectFit" lazy-load @error="failed = true" />
<text class="badge after">{{ lessonCount }} 节后</text>
</view>
</view>
</swiper-item>
</swiper>
<view class="gallery-footer">
<text class="caption">{{ frames[current]?.caption || '每一次练习,都留下改变的痕迹' }}</text>
<view class="pager"><button v-if="frames.length > 1" aria-label="上一组" @tap="move(-1)">‹</button><text>{{ current + 1 }} / {{ frames.length }}</text><button v-if="frames.length > 1" aria-label="下一组" @tap="move(1)">›</button></view>
</view>
<button v-if="failed" class="retry" @tap="$emit('refresh')">图片未能加载,点击刷新</button>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { TeachingCaseFrame } from '@mp-pilates/shared'
const props = defineProps<{ frames: TeachingCaseFrame[]; lessonCount: number }>()
defineEmits<{ refresh: [] }>()
const current = ref(0), failed = ref(false)
watch(() => props.frames, () => { current.value = 0; failed.value = false })
function move(delta: number) { current.value = (current.value + delta + props.frames.length) % props.frames.length }
function preview(url?: string) {
if (!url) return
const urls = props.frames.flatMap(frame => [frame.beforeUrl, frame.afterUrl].filter((item): item is string => Boolean(item)))
uni.previewImage({ current: url, urls })
}
</script>
<style scoped>
.gallery{overflow:hidden;background:#f0eee8;border-radius:18rpx;}.slides{height:480rpx;}.frame{height:100%;display:flex;}.photo{position:relative;flex:1;min-width:0;height:100%;}.pair .photo+.photo{border-left:4rpx solid #fbf9f6;}.photo image{width:100%;height:100%;}.badge{position:absolute;left:14rpx;bottom:16rpx;padding:8rpx 16rpx;border-radius:8rpx;background:rgba(255,253,249,.93);color:#62584d;font-size:22rpx;}.after{background:#53624f;color:#fff;}.gallery-footer{padding:12rpx 18rpx;display:flex;align-items:center;gap:12rpx;min-height:64rpx;}.caption{flex:1;font-size:22rpx;color:#817566;line-height:1.6;}.pager{display:flex;align-items:center;flex-shrink:0;color:#716353;font-size:22rpx;}.pager button{margin:0;padding:0;width:64rpx;line-height:68rpx;background:transparent;color:#53624f;font-size:36rpx;}.pager button::after,.retry::after{border:0;}.retry{background:#eee5da;color:#765c44;font-size:24rpx;line-height:80rpx;}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<view v-if="items.length" class="section">
<view class="section-head"><view><text class="eyebrow">练习,有迹可循</text><text class="title">看见真实的改变</text></view><button @tap="openAll">全部案例 ›</button></view>
<TeachingCaseCard v-for="item in items" :key="item.id" :item="item" @refresh="$emit('refresh')" />
<text class="footnote">经学员授权分享 · 个体经历,实际变化因人而异</text>
</view>
</template>
<script setup lang="ts">
import type { TeachingCasePublic } from '@mp-pilates/shared'
import TeachingCaseCard from './TeachingCaseCard.vue'
defineProps<{ items: TeachingCasePublic[] }>()
defineEmits<{ refresh: [] }>()
function openAll() { uni.navigateTo({ url: '/pages/home/teaching-cases' }) }
</script>
<style scoped>
.section{margin:28rpx 32rpx 40rpx;}.section-head{display:flex;justify-content:space-between;align-items:center;gap:12rpx;}.eyebrow{display:block;font-size:21rpx;letter-spacing:3rpx;color:#8a876e;margin-bottom:13rpx;}.title{font-family:'Songti SC','STSong',serif;font-size:40rpx;line-height:1.5;color:#494c40;}.section-head button{flex-shrink:0;margin:0;padding:0 10rpx;font-size:23rpx;color:#738064;line-height:88rpx;background:transparent;}.section-head button::after{border:0;}.footnote{display:block;margin-top:24rpx;font-size:21rpx;color:#948b7c;line-height:1.7;}
</style>

View File

@@ -80,6 +80,18 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/home/teaching-cases",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/home/teaching-case",
"style": {
"navigationStyle": "custom"
}
}
],
"subPackages": [
@@ -218,6 +230,18 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "teaching-cases",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "teaching-case-edit",
"style": {
"navigationStyle": "custom"
}
}
]
},

View File

@@ -41,6 +41,10 @@
<text class="item-title">统计分析</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/teaching-cases')">
<text class="item-title">教学案例展示</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/reviews')">
<text class="item-title">课后评价</text>
<text class="arrow-text">›</text>

View File

@@ -1,3 +1,4 @@
import type { TeachingCaseAdmin, TeachingCaseInput, TeachingCasePage, TeachingCaseUpload } from '@mp-pilates/shared'
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -296,7 +297,17 @@ export const useAdminStore = defineStore('admin', () => {
)
}
function fetchTeachingCases(page = 1) { return get<TeachingCasePage<TeachingCaseAdmin>>('/admin/teaching-cases', { page, limit: 10 }) }
function fetchTeachingCase(id: string) { return get<TeachingCaseAdmin>(`/admin/teaching-cases/${id}`) }
function saveTeachingCase(input: TeachingCaseInput, id?: string) {
return id ? put<TeachingCaseAdmin>(`/admin/teaching-cases/${id}`, { ...input }) : post<TeachingCaseAdmin>('/admin/teaching-cases', { ...input })
}
function withdrawTeachingCase(id: string) { return post(`/admin/teaching-cases/${id}/withdraw`) }
function createTeachingCaseUpload(fileName: string) { return post<TeachingCaseUpload>('/admin/teaching-cases/assets/credentials', { fileName }) }
function completeTeachingCaseUpload(id: string) { return post<{ id: string; url: string }>(`/admin/teaching-cases/assets/${id}/complete`) }
return {
fetchTeachingCases, fetchTeachingCase, saveTeachingCase, withdrawTeachingCase, createTeachingCaseUpload, completeTeachingCaseUpload,
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
// State

View File

@@ -0,0 +1,132 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar :title="id ? '编辑教学案例' : '新建教学案例'" show-back />
<view v-if="loading" class="state">正在读取案例…</view>
<view v-else-if="error" class="state">{{ error }}<button @tap="load">重新加载</button></view>
<view v-else class="content">
<view class="head"><text class="title">记录一段真实的改变</text><text class="hint">用真实照片和具体感受,让第一次来的学员了解你的教学。</text></view>
<view class="panel">
<text class="label">案例标题</text><input v-model="form.title" :disabled="busy" maxlength="80" placeholder="例如:从久坐紧绷,到舒展自如" />
<view class="duo"><view><text class="label">学员称呼 · 建议化名</text><input v-model="form.displayName" :disabled="busy" maxlength="30" placeholder="例如:小林" /></view><view><text class="label">经历课时 · 节</text><input v-model="lessonCount" :disabled="busy" type="number" maxlength="4" placeholder="例如:24" /></view></view>
<text class="label">练习的起点 · 选填</text><textarea v-model="form.background" :disabled="busy" maxlength="500" placeholder="学员最初的状态、目标与练习频率" auto-height />
<text class="label">变化与收获</text><textarea v-model="form.achievement" :disabled="busy" maxlength="1000" placeholder="描述可观察的变化和学员感受,避免保证效果或疗效承诺" auto-height />
</view>
<view class="section-heading"><text>对比照片</text><text class="hint">{{ form.frames.length }} / 9 组</text></view>
<text class="hint">第一组作为封面。建议前后照片采用相同角度、距离与光线,上传前遮挡面部及身份信息。每张最多 10 MB。</text>
<view v-for="(frame, index) in form.frames" :key="frame.localId" class="panel photo-panel">
<view class="frame-heading"><text>{{ index === 0 ? '封面' : '第 ' + (index + 1) + ' 组' }} · {{ frame.kind === 'pair' ? '前后双图' : '已拼好的对比图' }}</text><button :disabled="busy" @tap="removeFrame(index)">移除</button></view>
<view class="upload-grid" :class="{ single: frame.kind === 'composite' }">
<button class="upload" :disabled="busy" @tap="upload(index, 'before')"><image v-if="frame.beforeUrl" :src="frame.beforeUrl" mode="aspectFit" /><text>{{ frame.beforeId ? '更换' : '+ 上传' }}{{ frame.kind === 'pair' ? '练习前' : '对比图' }}</text></button>
<button v-if="frame.kind === 'pair'" class="upload" :disabled="busy" @tap="upload(index, 'after')"><image v-if="frame.afterUrl" :src="frame.afterUrl" mode="aspectFit" /><text>{{ frame.afterId ? '更换' : '+ 上传' }}练习后</text></button>
</view>
<input v-model="frame.caption" :disabled="busy" maxlength="100" placeholder="图片说明,如:侧面站姿 · 同角度记录" />
<view class="frame-actions"><button :disabled="busy || index === 0" @tap="moveFrame(index, -1)">向前移</button><button :disabled="busy || index === form.frames.length - 1" @tap="moveFrame(index, 1)">向后移</button><button :disabled="busy || !frame.beforeId || (frame.kind === 'pair' && !frame.afterId)" @tap="exportFrame(index)">生成一张对比图</button></view>
</view>
<view class="add-actions"><button :disabled="busy || form.frames.length >= 9" @tap="addFrame('pair')">+ 前后双图</button><button :disabled="busy || form.frames.length >= 9" @tap="addFrame('composite')">+ 已拼好的图</button></view>
<view class="panel">
<text class="label">首页排序</text><input v-model="sortOrder" :disabled="busy" type="number" maxlength="4" placeholder="0" /><text class="hint">数字越大越靠前;相同数字按新建时间排序。</text>
<view class="consent"><switch :checked="form.consentConfirmed" :disabled="busy" color="#627454" @change="changeConsent" /><text>已取得学员单独同意,将本案例照片及文字用于小程序公开宣传</text></view>
<text class="label">授权记录 · 仅管理员可见</text><textarea v-model="form.consentNote" :disabled="busy" maxlength="500" placeholder="记录授权日期、方式及凭证存放位置。请勿填写身份证号等敏感信息。" auto-height />
<text class="hint">私密成长档案的授权不能替代公开宣传授权。修改照片或用途后,请再次确认授权范围。</text>
</view>
<button class="preview-button" :disabled="busy" @tap="previewing = !previewing">{{ previewing ? '收起预览' : '预览展示效果' }}</button>
<view v-if="previewing" class="preview"><text class="preview-title">{{ form.title || '案例标题' }}</text><text class="hint">{{ form.displayName || '学员化名' }} · {{ lessonCount || '—' }} 节课</text><TeachingCaseGallery v-if="form.frames.length" :frames="form.frames" :lesson-count="Number(lessonCount) || 0" /><text class="preview-copy">{{ form.achievement }}</text></view>
<view class="save-actions"><button :disabled="busy" @tap="save(false)">{{ form.published ? '保存并下架' : '保存草稿' }}</button><button class="primary" :disabled="busy" @tap="save(true)">{{ saving ? '保存中…' : form.published ? '更新发布' : '确认发布' }}</button></view>
<text v-if="uploading" class="hint state">正在处理图片,请稍候…</text>
</view>
<canvas canvas-id="case-export" class="export-canvas" :style="{ width: '1000px', height: '1160px' }" />
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { TeachingCaseFrame, TeachingCaseInput } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import TeachingCaseGallery from '../../components/TeachingCaseGallery.vue'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { useAdminStore } from './stores/admin'
const navBarHeight = getSystemLayout().navBarHeight, store = useAdminStore(), user = useUserStore()
type EditorFrame = TeachingCaseFrame & { localId: number }
let localId = 0
const id = ref(''), loading = ref(false), error = ref(''), saving = ref(false), uploading = ref(false), previewing = ref(false)
const busy = computed(() => saving.value || uploading.value)
const lessonCount = ref(''), sortOrder = ref('0')
const form = ref<Omit<TeachingCaseInput, 'frames'> & { frames: EditorFrame[] }>({ title: '', displayName: '', lessonCount: 0, background: '', achievement: '', frames: [], sortOrder: 0, published: false, consentConfirmed: false, consentNote: '', version: 0 })
function changeConsent(event: Event) { form.value.consentConfirmed = Boolean((event as unknown as { detail: { value: boolean } }).detail.value) }
function message(error: unknown) { uni.showToast({ title: error instanceof Error ? error.message : '操作失败,请重试', icon: 'none', duration: 3500 }) }
async function load() {
if (!user.isAdmin) { error.value = '仅管理员可以编辑教学案例'; return }
if (!id.value) return
loading.value = true; error.value = ''
try { const row = await store.fetchTeachingCase(id.value); form.value = { ...row, frames: row.frames.map(frame => ({ ...frame, localId: ++localId })) }; lessonCount.value = String(row.lessonCount); sortOrder.value = String(row.sortOrder) }
catch (e) { error.value = e instanceof Error ? e.message : '加载失败' }
finally { loading.value = false }
}
function addFrame(kind: 'pair' | 'composite') { if (!busy.value && form.value.frames.length < 9) form.value.frames.push({ localId: ++localId, kind, beforeId: '', caption: '' }) }
function removeFrame(index: number) { if (busy.value) return; uni.showModal({ title: '移除这组照片?', content: '保存后生效。', success: result => { if (result.confirm && !busy.value) form.value.frames.splice(index, 1) } }) }
function moveFrame(index: number, delta: number) { if (busy.value) return; const frames = [...form.value.frames]; const target = index + delta; if (target < 0 || target >= frames.length) return; [frames[index], frames[target]] = [frames[target], frames[index]]; form.value.frames = frames }
async function upload(index: number, side: 'before' | 'after') {
if (busy.value) return
uploading.value = true
try {
const selected = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
const filePath = selected.tempFilePaths[0]
const files = selected.tempFiles as UniApp.ChooseImageSuccessCallbackResultFile[]
if (files[0]?.size > 10 * 1024 * 1024) throw new Error('图片超过 10 MB,请压缩后重试')
const fileName = filePath.split('/').pop() || 'photo.jpg'
const credential = await store.createTeachingCaseUpload(fileName)
await new Promise<void>((resolve, reject) => { uni.uploadFile({ url: credential.uploadUrl, filePath, name: 'file', formData: credential.formData, success: result => result.statusCode >= 200 && result.statusCode < 300 ? resolve() : reject(new Error('图片上传失败,请重试')), fail: () => reject(new Error('图片上传失败,请检查网络')) }) })
const asset = await store.completeTeachingCaseUpload(credential.id)
const frame = form.value.frames[index]
if (side === 'before') { frame.beforeId = asset.id; frame.beforeUrl = filePath } else { frame.afterId = asset.id; frame.afterUrl = filePath }
} catch (e) { if (!String((e as { errMsg?: string })?.errMsg || '').includes('cancel')) message(e) }
finally { uploading.value = false }
}
async function save(published: boolean) {
if (busy.value) return
try {
if (!form.value.title.trim() || !form.value.displayName.trim() || !form.value.achievement.trim()) throw new Error('请填写标题、学员称呼和变化与收获')
const count = Number(lessonCount.value), order = Number(sortOrder.value || '0')
if (!Number.isInteger(count) || count < 1 || count > 9999) throw new Error('课时须为 1–9999 的整数')
if (!Number.isInteger(order) || order < 0 || order > 9999) throw new Error('排序须为 0–9999 的整数')
if (!form.value.frames.length || form.value.frames.some(frame => !frame.beforeId || (frame.kind === 'pair' && !frame.afterId))) throw new Error('请补齐每组照片,至少上传一组')
if (published && (!form.value.consentConfirmed || !form.value.consentNote.trim())) throw new Error('发布前请确认公开授权并填写授权记录')
saving.value = true
const { title, displayName, background, achievement, consentConfirmed, consentNote, version } = form.value
const payload: TeachingCaseInput = { title: title.trim(), displayName: displayName.trim(), background: background.trim(), achievement: achievement.trim(), consentConfirmed, consentNote: consentNote.trim(), version, lessonCount: count, sortOrder: order, published,
frames: form.value.frames.map(frame => ({ kind: frame.kind, beforeId: frame.beforeId, ...(frame.afterId ? { afterId: frame.afterId } : {}), caption: frame.caption.trim() })) }
const result = await store.saveTeachingCase(payload, id.value || undefined)
id.value = result.id; form.value.version = result.version; form.value.published = result.published
uni.showToast({ title: published ? '案例已发布' : '已保存为草稿', icon: 'success' })
uni.navigateBack()
} catch (e) { message(e) } finally { saving.value = false }
}
async function exportFrame(index: number) {
if (busy.value) return
uploading.value = true
try {
const frame = form.value.frames[index]
const sources = [frame.beforeUrl, ...(frame.kind === 'pair' ? [frame.afterUrl] : [])]
const images = await Promise.all(sources.map(src => uni.getImageInfo({ src: src || '' })))
const context = uni.createCanvasContext('case-export')
context.setFillStyle('#fbf9f6'); context.fillRect(0, 0, 1000, 1160)
context.setFillStyle('#4d5946'); context.setFontSize(30); context.fillText((form.value.title || '练习的改变').slice(0, 28), 40, 64)
context.setFontSize(22); context.fillText(`${form.value.displayName || '学员'} · ${lessonCount.value || '—'} 节练习`, 40, 105)
const slot = images.length === 2 ? 454 : 920
images.forEach((image, imageIndex) => {
const ratio = Math.min(slot / image.width, 880 / image.height), width = image.width * ratio, height = image.height * ratio, left = 40 + imageIndex * 466
context.drawImage(image.path, left + (slot - width) / 2, 140 + (880 - height) / 2, width, height)
if (images.length === 2) { context.setFillStyle('#4d5946'); context.setFontSize(24); context.fillText(imageIndex === 0 ? '练习前' : `${lessonCount.value || '—'} 节后`, left, 1060) }
})
context.setFontSize(18); context.setFillStyle('#8a8070'); context.fillText('真实练习记录 · 个体经历,实际变化因人而异', 40, 1120)
await new Promise<void>(resolve => context.draw(false, () => resolve()))
const image = await uni.canvasToTempFilePath({ canvasId: 'case-export', width: 1000, height: 1160, destWidth: 1000, destHeight: 1160, fileType: 'jpg', quality: 0.95 })
uni.previewImage({ urls: [image.tempFilePath], current: image.tempFilePath })
} catch (e) { message(e) } finally { uploading.value = false }
}
onLoad(query => { id.value = String(query?.id || ''); if (!id.value) addFrame('pair'); void load() })
</script>
<style scoped>
.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;color:#555346;}.content{padding:32rpx 28rpx calc(56rpx + env(safe-area-inset-bottom));}.head{padding:0 4rpx 24rpx;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:39rpx;margin-bottom:16rpx;}.hint{display:block;color:#918573;font-size:23rpx;line-height:1.8;}.panel{padding:26rpx;margin:24rpx 0;background:#fffdfa;border:1rpx solid #e8e0d5;border-radius:18rpx;}.label{display:block;font-size:25rpx;color:#68624f;margin:18rpx 0 12rpx;}input,textarea{box-sizing:border-box;width:100%;background:#f5f2ec;border-radius:10rpx;padding:20rpx;font-size:27rpx;line-height:1.7;color:#4f5046;}input{height:88rpx;}textarea{min-height:150rpx;}.duo{display:flex;gap:18rpx;}.duo>view{flex:1;min-width:0;}.section-heading{display:flex;align-items:center;justify-content:space-between;font-size:31rpx;margin:34rpx 0 12rpx;}.frame-heading{display:flex;align-items:center;justify-content:space-between;gap:10rpx;font-size:24rpx;margin-bottom:18rpx;}.frame-heading button{margin:0;padding:0 18rpx;font-size:24rpx;line-height:70rpx;background:transparent;color:#997661;}.upload-grid{display:flex;gap:14rpx;margin-bottom:18rpx;}.upload{flex:1;min-width:0;margin:0;padding:0;height:290rpx;background:#eeeae2;display:flex;flex-direction:column;justify-content:center;align-items:center;border-radius:12rpx;}.upload image{height:210rpx;width:100%;}.upload text{font-size:24rpx;line-height:72rpx;color:#778066;}.single .upload{height:360rpx;}.single .upload image{height:280rpx;}.frame-actions,.add-actions,.save-actions{display:flex;gap:14rpx;}.frame-actions button{flex:1;padding:0;margin:10rpx 0 0;font-size:23rpx;background:transparent;color:#778265;line-height:80rpx;}.add-actions button,.save-actions button{flex:1;padding:0;margin:0;font-size:26rpx;line-height:90rpx;background:#eaeade;color:#66715a;}.save-actions{margin-top:30rpx;}.save-actions .primary{background:#596f50;color:#fff;}.consent{display:flex;align-items:flex-start;gap:16rpx;padding:30rpx 0 12rpx;}.consent switch{transform:scale(.85);transform-origin:left top;flex-shrink:0;width:90rpx;}.consent text{font-size:24rpx;line-height:1.8;}.preview-button{font-size:27rpx;background:transparent;color:#687a5c;line-height:88rpx;}.preview{padding:24rpx;background:#f3f1e9;}.preview-title{display:block;font-family:'Songti SC','STSong',serif;font-size:35rpx;line-height:1.7;margin-bottom:12rpx;}.preview .hint{margin-bottom:24rpx;}.preview-copy{display:block;font-size:26rpx;line-height:1.8;white-space:pre-wrap;margin-top:24rpx;}.state{text-align:center;padding:70rpx 30rpx;font-size:27rpx;color:#857d6d;}button::after{border:0;}button[disabled]{opacity:.45;}.export-canvas{position:fixed;left:-1200px;top:0;pointer-events:none;}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="教学案例展示" show-back />
<view class="content">
<text class="title">让进步被看见。</text><text class="intro">发布后的案例会优先展示在游客首页。使用化名,上传前请取得学员单独的公开宣传授权。</text>
<button class="primary" @tap="edit()">+ 新建教学案例</button>
<view v-if="error" class="state">{{ error }}<button @tap="reload">重新加载</button></view>
<template v-else>
<view v-for="row in rows" :key="row.id" class="row">
<view class="row-head"><text class="status" :class="{ published: row.published }">{{ row.published ? '已发布' : '草稿 / 已下架' }}</text><text class="meta">排序 {{ row.sortOrder }} · {{ row.frames.length }} 组图片</text></view>
<view class="summary" @tap="edit(row.id)"><image v-if="row.frames[0]?.beforeUrl" :src="row.frames[0].beforeUrl" mode="aspectFit" /><view class="copy"><text class="name">{{ row.title }}</text><text class="meta">{{ row.displayName }} · {{ row.lessonCount }} 节课</text></view></view>
<view class="actions"><button @tap="edit(row.id)">编辑与预览</button><button v-if="row.published || row.consentConfirmed" :disabled="busy === row.id" @tap="withdraw(row)">撤回授权并下架</button></view>
</view>
<view v-if="loading" class="state">正在加载…</view><view v-else-if="!rows.length" class="state">还没有教学案例<text class="intro">从一个真实的练习故事开始,保存为草稿后再发布。</text></view><button v-else-if="rows.length < total" @tap="more">加载更多</button>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import type { TeachingCaseAdmin } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { useAdminStore } from './stores/admin'
const navBarHeight = getSystemLayout().navBarHeight, store = useAdminStore(), user = useUserStore()
const rows = ref<TeachingCaseAdmin[]>([]), total = ref(0), page = ref(0), loading = ref(false), error = ref(''), busy = ref('')
let sequence = 0
async function load(reset: boolean) {
if (!user.isAdmin) { error.value = '仅管理员可以管理教学案例'; return }
if (!reset && (loading.value || rows.value.length >= total.value)) return
const seq = ++sequence, target = reset ? 1 : page.value + 1
loading.value = true; error.value = ''
try { const result = await store.fetchTeachingCases(target); if (seq !== sequence) return; rows.value = reset ? result.items : [...rows.value, ...result.items.filter(row => !rows.value.some(item => item.id === row.id))]; page.value = target; total.value = result.total }
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '加载失败' }
finally { if (seq === sequence) loading.value = false }
}
function reload() { void load(true) }
function more() { void load(false) }
function edit(id?: string) { uni.navigateTo({ url: '/pages/admin/teaching-case-edit' + (id ? `?id=${id}` : '') }) }
function withdraw(row: TeachingCaseAdmin) {
uni.showModal({ title: '撤回此案例的公开授权?', content: '将立即从公开列表下架,保留管理记录。再次发布需要重新确认授权。', success: async result => {
if (!result.confirm || busy.value) return
busy.value = row.id
try { await store.withdrawTeachingCase(row.id); await load(true) } catch (e) { uni.showToast({ title: e instanceof Error ? e.message : '下架失败', icon: 'none' }) } finally { busy.value = '' }
} })
}
onShow(reload); onReachBottom(more)
</script>
<style scoped>
.page{min-height:100vh;box-sizing:border-box;background:#fbf9f6;}.content{padding:36rpx 32rpx 80rpx;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:42rpx;color:#4d5445;}.intro{display:block;font-size:24rpx;line-height:1.9;color:#8b8171;margin:18rpx 0 28rpx;}.primary{background:#5a6b50;color:#fff;font-size:28rpx;line-height:88rpx;margin-bottom:30rpx;}.row{padding:28rpx 0;border-bottom:1rpx solid #e4ddd1;}.row-head{display:flex;align-items:center;justify-content:space-between;gap:16rpx;}.status{background:#eee7dc;color:#8a7560;border-radius:8rpx;padding:7rpx 12rpx;font-size:22rpx;}.published{background:#e6edde;color:#617750;}.meta{font-size:22rpx;color:#928575;line-height:1.8;}.summary{display:flex;align-items:center;gap:22rpx;margin-top:22rpx;}.summary image{width:138rpx;height:150rpx;flex-shrink:0;background:#efede6;border-radius:12rpx;}.copy{flex:1;min-width:0;}.name{display:block;font-size:30rpx;color:#514f45;line-height:1.6;margin-bottom:12rpx;}.actions{display:flex;gap:20rpx;margin-top:16rpx;}.actions button{flex:1;margin:0;padding:0;font-size:23rpx;line-height:80rpx;background:transparent;color:#6c775f;}.actions button::after{border:0;}.state{padding:70rpx 0;text-align:center;color:#8a8171;font-size:27rpx;}
</style>

View File

@@ -9,8 +9,10 @@
<view class="banner-spacer" />
<view class="floating-card">
<view class="card-handle"><view class="card-handle-bar" /></view>
<TeachingCaseSection v-if="!userStore.loggedIn" :items="teachingCases" @refresh="fetchTeachingCases" />
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<UpcomingBooking />
<TeachingCaseSection v-if="userStore.loggedIn" :items="teachingCases" @refresh="fetchTeachingCases" />
<ReviewSummaryCard />
<StudioInfo :studio-info="studioStore.studioInfo" />
<view :id="cardShopAnchorId">
@@ -28,6 +30,9 @@
import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import type { TeachingCasePage, TeachingCasePublic } from '@mp-pilates/shared'
import { get } from '../../utils/request'
import TeachingCaseSection from '../../components/TeachingCaseSection.vue'
import BrandBanner from '../../components/BrandBanner.vue'
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
import StudioInfo from '../../components/StudioInfo.vue'
@@ -44,6 +49,15 @@ import { useBookingStore } from '../../stores/booking'
const userStore = useUserStore()
const studioStore = useStudioStore()
const bookingStore = useBookingStore()
const teachingCases = ref<TeachingCasePublic[]>([])
let caseRequest = 0
async function fetchTeachingCases() {
const request = ++caseRequest
try {
const result = await get<TeachingCasePage>('/teaching-cases', { page: 1, limit: 3 })
if (request === caseRequest) teachingCases.value = result.items
} catch { if (request === caseRequest) teachingCases.value = [] }
}
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
@@ -82,6 +96,10 @@ onUnmounted(() => {
// Refresh all data on every show
onShow(async () => {
if (uni.getStorageSync('home:card-shop-requested')) {
uni.removeStorageSync('home:card-shop-requested')
pendingScrollToCardShop.value = true
}
await refreshData()
// If another page requested scroll to card shop, execute after data is ready
@@ -93,7 +111,7 @@ onShow(async () => {
})
async function refreshData() {
const tasks: Promise<unknown>[] = [studioStore.fetchStudioInfo()]
const tasks: Promise<unknown>[] = [studioStore.fetchStudioInfo(), fetchTeachingCases()]
if (userStore.loggedIn) {
tasks.push(

View File

@@ -0,0 +1,50 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="练习的改变" show-back />
<view class="content">
<view v-if="loading" class="state">正在读取案例…</view>
<view v-else-if="error" class="state">{{ error }}<button @tap="reload">重新加载</button></view>
<template v-else-if="item">
<text class="eyebrow">{{ item.displayName }} · 真实练习记录</text><text class="title">{{ item.title }}</text>
<view class="stat"><text class="number">{{ item.lessonCount }}</text><text>节课,记录这一段改变</text></view>
<TeachingCaseGallery :frames="item.frames" :lesson-count="item.lessonCount" @refresh="reload" />
<view v-if="item.background" class="story"><text class="label">练习的起点</text><text class="body">{{ item.background }}</text></view>
<view class="story"><text class="label">看得见,也感受得到</text><text class="body">{{ item.achievement }}</text></view>
<text class="notice">经学员单独授权公开分享。每个人的基础、练习频率与感受不同,案例不代表相同课时下的效果承诺。</text>
<view class="invite"><text class="invite-title">你的练习,从了解自己开始。</text><text class="notice">找到适合自己的节奏,和老师一起开始。</text><button class="primary" @tap="explore">了解课程与体验</button></view>
<button class="share" open-type="share">分享这段改变</button>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow, onShareAppMessage } from '@dcloudio/uni-app'
import type { TeachingCasePublic } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import TeachingCaseGallery from '../../components/TeachingCaseGallery.vue'
import { getSystemLayout } from '../../utils/system'
import { get, HttpRequestError } from '../../utils/request'
const navBarHeight = getSystemLayout().navBarHeight
const id = ref(''), item = ref<TeachingCasePublic | null>(null), loading = ref(true), error = ref('')
let sequence = 0
async function reload() {
const seq = ++sequence; loading.value = true; error.value = ''; item.value = null
try { if (!id.value) throw new Error('案例链接不完整'); const result = await get<TeachingCasePublic>(`/teaching-cases/${encodeURIComponent(id.value)}`); if (seq === sequence) item.value = result }
catch (e) { if (seq === sequence) error.value = e instanceof HttpRequestError && e.statusCode === 404 ? '案例已下架或暂未公开。' : '案例暂时无法读取,请重试。' }
finally { if (seq === sequence) loading.value = false }
}
function explore() {
// A shared detail link can be the launch page, before home has registered events.
uni.setStorageSync('home:card-shop-requested', true)
uni.switchTab({ url: '/pages/home/index', fail: () => {
uni.removeStorageSync('home:card-shop-requested')
uni.showToast({ title: '打开课程失败,请重试', icon: 'none' })
} })
}
onLoad(query => { id.value = String(query?.id || '') }); onShow(reload)
onShareAppMessage(() => ({ title: item.value?.title || '教学案例', path: item.value ? `/pages/home/teaching-case?id=${item.value.id}` : '/pages/home/teaching-cases' }))
</script>
<style scoped>
.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}.content{padding:42rpx 32rpx 80rpx;}.eyebrow{font-size:23rpx;color:#7b866e;letter-spacing:2rpx;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:46rpx;line-height:1.5;color:#454c3f;margin:22rpx 0;}.stat{display:flex;align-items:baseline;gap:18rpx;color:#7a806a;font-size:25rpx;margin:26rpx 0 34rpx;}.number{font-family:'Baskerville',serif;font-size:80rpx;line-height:1;color:#5c7151;}.story{padding:34rpx 0;border-bottom:1rpx solid #e5e0d5;}.label{display:block;font-size:24rpx;color:#8b806d;margin-bottom:18rpx;}.body{display:block;font-size:29rpx;color:#555448;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere;}.notice{display:block;font-size:22rpx;line-height:1.9;color:#918775;margin-top:25rpx;}.invite{margin-top:40rpx;padding:28rpx;background:#eef0e6;border-radius:18rpx;}.invite-title{font-family:'Songti SC','STSong',serif;font-size:32rpx;color:#55634b;}.primary{background:#586c50;color:#fff;font-size:28rpx;line-height:88rpx;margin-top:26rpx;}.share{margin-top:22rpx;background:transparent;font-size:25rpx;color:#6f7a62;line-height:88rpx;}.share::after{border:0;}.state{text-align:center;padding:80rpx 0;font-size:28rpx;color:#857b6a;}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="教学案例" show-back />
<view class="content"><text class="eyebrow">练习,有迹可循</text><text class="title">改变,从一次次练习开始。</text><text class="intro">每个人的起点不同,值得被看见的进步也不同。</text>
<view v-if="error" class="state">{{ error }}<button @tap="reload">重新加载</button></view>
<template v-else><TeachingCaseCard v-for="item in items" :key="item.id" :item="item" @refresh="reload" />
<view v-if="loading" class="state">正在读取案例…</view><view v-else-if="!items.length" class="state">案例正在整理中,期待与你分享。</view><button v-else-if="items.length < total" class="more" @tap="loadMore">查看更多案例</button>
</template>
<text class="intro footnote">所有案例经学员授权分享。课时指记录这段变化所经历的课程节数,不代表效果承诺。</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onReachBottom, onShareAppMessage } from '@dcloudio/uni-app'
import type { TeachingCasePage, TeachingCasePublic } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import TeachingCaseCard from '../../components/TeachingCaseCard.vue'
import { getSystemLayout } from '../../utils/system'
import { get } from '../../utils/request'
const navBarHeight = getSystemLayout().navBarHeight
const items = ref<TeachingCasePublic[]>([]), total = ref(0), page = ref(0), loading = ref(false), error = ref('')
let sequence = 0
async function load(reset: boolean) {
if (!reset && (loading.value || items.value.length >= total.value)) return
const seq = ++sequence, target = reset ? 1 : page.value + 1
loading.value = true; error.value = ''
try { const result = await get<TeachingCasePage>('/teaching-cases', { page: target, limit: 10 }); if (seq !== sequence) return; items.value = reset ? result.items : [...items.value, ...result.items.filter(row => !items.value.some(item => item.id === row.id))]; total.value = result.total; page.value = target }
catch (e) { if (seq === sequence) { if (reset) { items.value = []; error.value = e instanceof Error ? e.message : '暂时无法加载' } else uni.showToast({ title: '加载失败,请重试', icon: 'none' }) } }
finally { if (seq === sequence) loading.value = false }
}
function reload() { void load(true) }
function loadMore() { void load(false) }
onShow(reload); onReachBottom(loadMore)
onShareAppMessage(() => ({ title: '看见真实的改变 · 教学案例', path: '/pages/home/teaching-cases' }))
</script>
<style scoped>
.page{min-height:100vh;box-sizing:border-box;background:#fbf9f6;}.content{padding:40rpx 32rpx 80rpx;}.eyebrow{display:block;font-size:22rpx;letter-spacing:3rpx;color:#7c866c;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:40rpx;color:#494c40;margin:22rpx 0;line-height:1.6;}.intro{display:block;font-size:24rpx;color:#918677;line-height:1.9;}.state{padding:70rpx 20rpx;text-align:center;font-size:27rpx;color:#837866;}.state button,.more{font-size:26rpx;background:#e9edde;color:#58694e;margin-top:24rpx;}.footnote{margin-top:36rpx;font-size:22rpx;}
</style>

View File

@@ -0,0 +1,27 @@
CREATE TABLE `TeachingCase` (
`id` VARCHAR(191) NOT NULL,
`title` VARCHAR(80) NOT NULL,
`displayName` VARCHAR(30) NOT NULL,
`lessonCount` INTEGER NOT NULL,
`background` VARCHAR(500) NOT NULL,
`achievement` VARCHAR(1000) NOT NULL,
`frames` JSON NOT NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`published` BOOLEAN NOT NULL DEFAULT false,
`consentConfirmed` BOOLEAN NOT NULL DEFAULT false,
`consentNote` VARCHAR(500) NOT NULL,
`consentedAt` DATETIME(3) NULL,
`version` INTEGER NOT NULL DEFAULT 1,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `TeachingCase_published_sortOrder_createdAt_idx` (`published`, `sortOrder`, `createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `TeachingCaseAsset` (
`id` VARCHAR(191) NOT NULL,
`objectKey` VARCHAR(255) NOT NULL,
`ready` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `TeachingCaseAsset_objectKey_key` (`objectKey`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -691,3 +691,31 @@ model ProductStockChange {
@@index([productId, createdAt])
@@map("product_stock_changes")
}
// Public teaching stories never reference private ProgressPhoto records.
model TeachingCase {
id String @id @default(cuid())
title String @db.VarChar(80)
displayName String @db.VarChar(30)
lessonCount Int
background String @db.VarChar(500)
achievement String @db.VarChar(1000)
frames Json
sortOrder Int @default(0)
published Boolean @default(false)
consentConfirmed Boolean @default(false)
consentNote String @db.VarChar(500)
consentedAt DateTime?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([published, sortOrder, createdAt])
}
model TeachingCaseAsset {
id String @id @default(cuid())
objectKey String @unique @db.VarChar(255)
ready Boolean @default(false)
createdAt DateTime @default(now())
}

View File

@@ -9,6 +9,7 @@ const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'sessionKey',
'encryptedData',
'iv',
'consentNote',
])
const BODY_METHODS: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH'])

View File

@@ -0,0 +1,23 @@
import { ConfigService } from '@nestjs/config'
import { TeachingCaseStorageService } from '../teaching-case-storage.service'
describe('Teaching case private storage', () => {
const values: Record<string, string> = { COS_BUCKET: 'test-123', COS_REGION: 'ap-guangzhou', COS_SECRET_ID: 'test-id', COS_SECRET_KEY: 'test-key' }
const service = new TeachingCaseStorageService({ get: (key: string) => values[key] } as ConfigService)
it('isolates public-case assets from studio gallery and private member archives', () => {
const result = service.createCaseCredential('photo.PNG')
expect(result.key).toMatch(/^teaching-cases\/[a-f0-9-]+\.png$/)
expect(result.formData['x-cos-acl']).toBe('private')
const policy = JSON.parse(Buffer.from(result.formData.policy, 'base64').toString())
expect(policy.conditions).toContainEqual({ 'x-cos-acl': 'private' })
expect(policy.conditions).toContainEqual(['eq', '$key', result.key])
expect(policy.conditions).toContainEqual(['content-length-range', 0, 10485760])
})
it.each(['file.svg', 'file.pdf', 'photo.heic'])('rejects unsupported public image formats: %s', file => { expect(() => service.createCaseCredential(file)).toThrow() })
it('uses bounded signed links for public displays', () => {
const url = new URL(service.signedUrl('teaching-cases/photo.jpg', 'get', 900))
const [start, end] = url.searchParams.get('q-sign-time')!.split(';').map(Number)
expect(end - start).toBe(905)
expect(url.searchParams.get('q-signature')).toBeTruthy()
})
})

View File

@@ -0,0 +1,58 @@
import { INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import type { ExecutionContext } from '@nestjs/common'
import type { Request } from 'express'
import type { AddressInfo } from 'node:net'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { TeachingCaseAdminController, TeachingCasePublicController } from '../teaching-case.controller'
import { TeachingCaseService } from '../teaching-case.service'
// Authentication is simulated; the actual RolesGuard and global DTO validation run here.
describe('Teaching cases HTTP roles and validation', () => {
let app: INestApplication
// Native fetch keeps the HTTP test independent of optional supertest typings.
function request(_server: unknown) {
function call(method: string, path: string) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
let body: string | undefined
const chain = {
set(key: string, value: string) { headers[key] = value; return chain },
send(value: unknown) { body = JSON.stringify(value); return chain },
async expect(status: number) {
const address = app.getHttpServer().address() as AddressInfo
const response = await fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers, body })
await response.text()
expect(response.status).toBe(status)
},
}
return chain
}
return { get: (path: string) => call('GET', path), post: (path: string) => call('POST', path), put: (path: string) => call('PUT', path) }
}
const service = { list: jest.fn().mockResolvedValue({ items: [], total: 0 }), detail: jest.fn().mockResolvedValue({}), save: jest.fn().mockResolvedValue({}), upload: jest.fn().mockResolvedValue({}), completeUpload: jest.fn().mockResolvedValue({}), withdraw: jest.fn().mockResolvedValue({}) }
const valid = { title: '案例', displayName: '化名', lessonCount: 12, background: '', achievement: '收获', frames: [{ kind: 'composite', beforeId: 'asset-id', caption: '' }], sortOrder: 0, published: false, consentConfirmed: false, consentNote: '', version: 0 }
beforeAll(async () => {
const module = await Test.createTestingModule({ controllers: [TeachingCasePublicController, TeachingCaseAdminController], providers: [{ provide: TeachingCaseService, useValue: service }, RolesGuard] })
.overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) { const req = context.switchToHttp().getRequest<Request & { user: { role: string } }>(); if (!req.headers['x-test-role']) throw new UnauthorizedException(); req.user = { role: String(req.headers['x-test-role']) }; return true } }).compile()
app = module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
await app.listen(0, '127.0.0.1')
})
afterAll(async () => { await app.close() })
it('lets visitors list cases without authentication', async () => { await request(app.getHttpServer()).get('/teaching-cases?limit=3').expect(200); expect(service.list).toHaveBeenCalledWith(expect.objectContaining({ page: 1, limit: 3 })) })
it.each(['', 'MEMBER'])('prevents writes and admin reads by role %s', async role => {
const code = role ? 403 : 401
await request(app.getHttpServer()).get('/admin/teaching-cases').set('x-test-role', role).expect(code)
await request(app.getHttpServer()).post('/admin/teaching-cases').set('x-test-role', role).send(valid).expect(code)
await request(app.getHttpServer()).post('/admin/teaching-cases/assets/credentials').set('x-test-role', role).send({ fileName: 'a.jpg' }).expect(code)
await request(app.getHttpServer()).post('/admin/teaching-cases/assets/a/complete').set('x-test-role', role).expect(code)
await request(app.getHttpServer()).put('/admin/teaching-cases/a').set('x-test-role', role).send(valid).expect(code)
await request(app.getHttpServer()).post('/admin/teaching-cases/a/withdraw').set('x-test-role', role).expect(code)
})
it('accepts a valid administrator save', async () => { await request(app.getHttpServer()).post('/admin/teaching-cases').set('x-test-role', 'ADMIN').send(valid).expect(201) })
it.each([{ lessonCount: 0 }, { lessonCount: 1.5 }, { lessonCount: '12' }, { title: ' ' }, { frames: [] }, { frames: Array(10).fill(valid.frames[0]) }, { frames: [{ ...valid.frames[0], kind: 'other' }] }, { frames: [{ ...valid.frames[0], beforeUrl: 'https://example.com' }] }, { consentConfirmed: 'true' }, { version: -1 }])('rejects invalid payload %j', async patch => {
await request(app.getHttpServer()).post('/admin/teaching-cases').set('x-test-role', 'ADMIN').send({ ...valid, ...patch }).expect(400)
})
it.each(['page=0', 'limit=1000', 'page=abc', 'limit=-1'])('bounds anonymous pagination: %s', async query => { await request(app.getHttpServer()).get(`/teaching-cases?${query}`).expect(400) })
})

View File

@@ -0,0 +1,110 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'
import { PrismaService } from '../../prisma/prisma.service'
import { TeachingCaseService } from '../teaching-case.service'
import { TeachingCaseStorageService } from '../teaching-case-storage.service'
import { SaveTeachingCaseDto } from '../dto/teaching-case.dto'
const input = (): SaveTeachingCaseDto => ({ title: '舒展的变化', displayName: '小林', lessonCount: 24, background: '久坐', achievement: '站姿更舒展', frames: [{ kind: 'pair', beforeId: 'before', afterId: 'after', caption: '侧面' }], sortOrder: 5, published: true, consentConfirmed: true, consentNote: '2026-09-18 书面授权', version: 1 })
const record = () => ({ ...input(), id: 'case-1', createdAt: new Date(), updatedAt: new Date(), consentedAt: new Date() })
describe('TeachingCaseService publication and storage boundaries', () => {
const prisma = {
teachingCase: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn(), create: jest.fn(), updateMany: jest.fn() },
teachingCaseAsset: { count: jest.fn(), findMany: jest.fn(), create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
$transaction: jest.fn(),
}
const storage = { signedUrl: jest.fn(), createCaseCredential: jest.fn(), verify: jest.fn() }
let service: TeachingCaseService
beforeEach(() => {
jest.resetAllMocks()
prisma.$transaction.mockImplementation((queries: Promise<unknown>[]) => Promise.all(queries))
prisma.teachingCase.findMany.mockResolvedValue([record()])
prisma.teachingCase.count.mockResolvedValue(1)
prisma.teachingCase.findFirst.mockResolvedValue(record())
prisma.teachingCase.findUnique.mockResolvedValue(record())
prisma.teachingCase.updateMany.mockResolvedValue({ count: 1 })
prisma.teachingCaseAsset.count.mockResolvedValue(2)
prisma.teachingCaseAsset.findMany.mockResolvedValue([{ id: 'before', objectKey: 'teaching-cases/before.jpg' }, { id: 'after', objectKey: 'teaching-cases/after.jpg' }])
storage.signedUrl.mockImplementation((key: string) => `signed:${key}`)
service = new TeachingCaseService(prisma as unknown as PrismaService, storage as unknown as TeachingCaseStorageService)
})
it('only lists published consented cases in deterministic order and excludes private consent fields', async () => {
const result = await service.list({ page: 2, limit: 3 })
expect(prisma.teachingCase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { published: true, consentConfirmed: true }, skip: 3, take: 3, orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }] }))
expect(prisma.teachingCase.count).toHaveBeenCalledWith({ where: { published: true, consentConfirmed: true } })
expect(result.items[0]).not.toHaveProperty('consentNote')
expect(result.items[0]).not.toHaveProperty('consentedAt')
expect(result.items[0]).not.toHaveProperty('version')
expect(result.items[0].frames[0]).toMatchObject({ beforeUrl: 'signed:teaching-cases/before.jpg', afterUrl: 'signed:teaching-cases/after.jpg' })
expect(storage.signedUrl).toHaveBeenCalledWith('teaching-cases/before.jpg', 'get', 900)
})
it('does not reveal a draft or withdrawn case through a direct public URL', async () => {
prisma.teachingCase.findFirst.mockResolvedValue(null)
await expect(service.detail('draft-id')).rejects.toBeInstanceOf(NotFoundException)
expect(prisma.teachingCase.findFirst).toHaveBeenCalledWith({ where: { id: 'draft-id', published: true, consentConfirmed: true } })
expect(storage.signedUrl).not.toHaveBeenCalled()
})
it('returns private consent details only in the admin view', async () => {
const result = await service.list({ page: 1, limit: 10 }, true)
expect(prisma.teachingCase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: {} }))
expect(result.items[0]).toHaveProperty('consentNote', input().consentNote)
})
it.each([{ consentConfirmed: false }, { consentNote: ' ' }])('requires separate documented consent before publishing: %j', async patch => {
await expect(service.save({ ...input(), ...patch })).rejects.toBeInstanceOf(BadRequestException)
expect(prisma.teachingCase.create).not.toHaveBeenCalled()
})
it.each([
[{ kind: 'pair' as const, beforeId: 'before', caption: '' }],
[{ kind: 'pair' as const, beforeId: 'before', afterId: 'before', caption: '' }],
[{ kind: 'composite' as const, beforeId: 'before', afterId: 'after', caption: '' }],
])('rejects incomplete or ambiguous comparison frames', async frame => {
await expect(service.save({ ...input(), frames: [frame] })).rejects.toBeInstanceOf(BadRequestException)
})
it('rejects fabricated asset IDs, private archive photos and incomplete uploads', async () => {
prisma.teachingCaseAsset.count.mockResolvedValue(1)
await expect(service.save(input())).rejects.toThrow('未上传完成')
expect(prisma.teachingCaseAsset.count).toHaveBeenCalledWith({ where: { id: { in: ['before', 'after'] }, ready: true } })
expect(prisma.teachingCase.create).not.toHaveBeenCalled()
})
it('allows unpublished drafts without consent and strips presentation URLs from persisted frames', async () => {
prisma.teachingCase.create.mockResolvedValue(record())
const dto = { ...input(), published: false, consentConfirmed: false, consentNote: '', frames: [{ ...input().frames[0], beforeUrl: 'https://untrusted.test/photo.jpg' }] }
await service.save(dto)
expect(prisma.teachingCase.create).toHaveBeenCalledWith({ data: expect.objectContaining({ published: false, consentedAt: null, frames: input().frames }) })
})
it('preserves frame ordering and supports multiple composite photos', async () => {
prisma.teachingCase.create.mockResolvedValue(record())
const frames = [{ kind: 'composite' as const, beforeId: 'after', caption: '封面' }, { kind: 'composite' as const, beforeId: 'before', caption: '第二组' }]
await service.save({ ...input(), frames })
expect(prisma.teachingCase.create).toHaveBeenCalledWith({ data: expect.objectContaining({ frames }) })
})
it('rejects stale updates instead of overwriting another administrator or withdrawal', async () => {
prisma.teachingCase.updateMany.mockResolvedValue({ count: 0 })
await expect(service.save(input(), 'case-1')).rejects.toBeInstanceOf(ConflictException)
expect(prisma.teachingCase.updateMany).toHaveBeenCalledWith({ where: { id: 'case-1', version: 1 }, data: expect.objectContaining({ version: { increment: 1 } }) })
})
it('withdraws consent and publication together and invalidates older editors', async () => {
await service.withdraw('case-1')
expect(prisma.teachingCase.updateMany).toHaveBeenCalledWith({ where: { id: 'case-1' }, data: { published: false, consentConfirmed: false, consentedAt: null, version: { increment: 1 } } })
})
it('does not complete an upload before COS confirms the object', async () => {
prisma.teachingCaseAsset.findUnique.mockResolvedValue({ id: 'asset', objectKey: 'teaching-cases/a.jpg' })
storage.verify.mockRejectedValue(new Error('not uploaded'))
await expect(service.completeUpload('asset')).rejects.toThrow('not uploaded')
expect(prisma.teachingCaseAsset.update).not.toHaveBeenCalled()
storage.verify.mockResolvedValue(undefined)
await service.completeUpload('asset')
expect(prisma.teachingCaseAsset.update).toHaveBeenCalledWith({ where: { id: 'asset' }, data: { ready: true } })
})
})

View File

@@ -0,0 +1,31 @@
import { Transform, Type } from 'class-transformer'
import { ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Length, Max, MaxLength, Min, ValidateNested } from 'class-validator'
const Trim = () => Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value)
export class TeachingCaseFrameDto {
@IsIn(['pair', 'composite']) kind!: 'pair' | 'composite'
@IsString() @Length(1, 100) beforeId!: string
@IsOptional() @IsString() @Length(1, 100) afterId?: string
@Trim() @IsString() @MaxLength(100) caption!: string
}
export class SaveTeachingCaseDto {
@Trim() @IsString() @Length(1, 80) title!: string
@Trim() @IsString() @Length(1, 30) displayName!: string
@IsInt() @Min(1) @Max(9999) lessonCount!: number
@Trim() @IsString() @MaxLength(500) background!: string
@Trim() @IsString() @Length(1, 1000) achievement!: string
@IsArray() @ArrayMinSize(1) @ArrayMaxSize(9)
@ValidateNested({ each: true }) @Type(() => TeachingCaseFrameDto) frames!: TeachingCaseFrameDto[]
@IsInt() @Min(0) @Max(9999) sortOrder!: number
@IsBoolean() published!: boolean
@IsBoolean() consentConfirmed!: boolean
@Trim() @IsString() @MaxLength(500) consentNote!: string
@IsInt() @Min(0) version!: number
}
export class TeachingCaseQueryDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100000) page = 1
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(20) limit = 10
}
export class TeachingCaseUploadDto {
@IsString() @Length(1, 200) fileName!: string
}

View File

@@ -1,11 +1,14 @@
import { TeachingCasePublicController, TeachingCaseAdminController } from './teaching-case.controller'
import { TeachingCaseService } from './teaching-case.service'
import { TeachingCaseStorageService } from './teaching-case-storage.service'
import { Module } from '@nestjs/common'
import { StudioController } from './studio.controller'
import { StudioService } from './studio.service'
import { StudioUploadService } from './studio-upload.service'
@Module({
controllers: [StudioController],
providers: [StudioService, StudioUploadService],
controllers: [StudioController, TeachingCasePublicController, TeachingCaseAdminController],
providers: [StudioService, StudioUploadService, TeachingCaseService, TeachingCaseStorageService],
exports: [StudioService],
})
export class StudioModule {}

View File

@@ -0,0 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { randomUUID } from 'crypto'
import { ProgressPhotoStorageService } from '../user/progress-photo-storage.service'
@Injectable()
export class TeachingCaseStorageService extends ProgressPhotoStorageService {
constructor(config: ConfigService) { super(config) }
createCaseCredential(fileName: string) {
const extension = this.resolveExtension(fileName)
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) throw new BadRequestException('请使用 JPG、PNG 或 WebP 图片')
const bucket = this.getRequiredConfig('COS_BUCKET')
const region = this.getRequiredConfig('COS_REGION')
const key = `teaching-cases/${randomUUID()}.${extension}`
return { key, uploadUrl: `https://${bucket}.cos.${region}.myqcloud.com`, formData: this.buildPostPolicy({ bucket, key, expiresAt: Math.floor(Date.now() / 1000) + 300, privateRead: true }) }
}
}

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Get, Header, Param, Post, Put, Query, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { SaveTeachingCaseDto, TeachingCaseQueryDto, TeachingCaseUploadDto } from './dto/teaching-case.dto'
import { TeachingCaseService } from './teaching-case.service'
@Controller('teaching-cases')
export class TeachingCasePublicController {
constructor(private readonly service: TeachingCaseService) {}
@Get() @Header('Cache-Control', 'no-store')
list(@Query() query: TeachingCaseQueryDto) { return this.service.list(query) }
@Get(':id') @Header('Cache-Control', 'no-store')
detail(@Param('id') id: string) { return this.service.detail(id) }
}
@Controller('admin/teaching-cases')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class TeachingCaseAdminController {
constructor(private readonly service: TeachingCaseService) {}
@Get() list(@Query() query: TeachingCaseQueryDto) { return this.service.list(query, true) }
@Get(':id') detail(@Param('id') id: string) { return this.service.detail(id, true) }
@Post() create(@Body() dto: SaveTeachingCaseDto) { return this.service.save(dto) }
@Put(':id') update(@Param('id') id: string, @Body() dto: SaveTeachingCaseDto) { return this.service.save(dto, id) }
@Post(':id/withdraw') withdraw(@Param('id') id: string) { return this.service.withdraw(id) }
@Post('assets/credentials') upload(@Body() dto: TeachingCaseUploadDto) { return this.service.upload(dto.fileName) }
@Post('assets/:id/complete') complete(@Param('id') id: string) { return this.service.completeUpload(id) }
}

View File

@@ -0,0 +1,82 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import type { TeachingCase } from '@prisma/client'
import type { TeachingCaseFrame } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { SaveTeachingCaseDto, TeachingCaseQueryDto } from './dto/teaching-case.dto'
import { TeachingCaseStorageService } from './teaching-case-storage.service'
@Injectable()
export class TeachingCaseService {
constructor(private readonly prisma: PrismaService, private readonly storage: TeachingCaseStorageService) {}
async list(query: TeachingCaseQueryDto, admin = false) {
const where = admin ? {} : { published: true, consentConfirmed: true }
const [rows, total] = await this.prisma.$transaction([
this.prisma.teachingCase.findMany({ where, orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], skip: (query.page - 1) * query.limit, take: query.limit }),
this.prisma.teachingCase.count({ where }),
])
return { items: await this.present(rows, admin), total, page: query.page, limit: query.limit }
}
async detail(id: string, admin = false) {
const row = await this.prisma.teachingCase.findFirst({ where: { id, ...(admin ? {} : { published: true, consentConfirmed: true }) } })
if (!row) throw new NotFoundException('案例不存在或已下架')
return (await this.present([row], admin))[0]
}
async save(dto: SaveTeachingCaseDto, id?: string) {
if (dto.published && (!dto.consentConfirmed || !dto.consentNote.trim())) throw new BadRequestException('发布前请确认公开授权,并填写授权记录')
if (dto.consentConfirmed && !dto.consentNote.trim()) throw new BadRequestException('请填写授权记录')
const frames = dto.frames.map(frame => {
if (frame.kind === 'pair' && (!frame.afterId || frame.afterId === frame.beforeId)) throw new BadRequestException('请上传不同的练习前、后照片')
if (frame.kind === 'composite' && frame.afterId) throw new BadRequestException('合成图只需一张照片')
return { kind: frame.kind, beforeId: frame.beforeId, ...(frame.afterId ? { afterId: frame.afterId } : {}), caption: frame.caption }
})
const assetIds = [...new Set(frames.flatMap(frame => [frame.beforeId, ...(frame.afterId ? [frame.afterId] : [])]))]
const count = await this.prisma.teachingCaseAsset.count({ where: { id: { in: assetIds }, ready: true } })
if (count !== assetIds.length) throw new BadRequestException('存在未上传完成的照片,请重新上传')
const previous = id ? await this.prisma.teachingCase.findUnique({ where: { id } }) : null
if (id && !previous) throw new NotFoundException('案例不存在')
const { version, ...input } = dto
const data = { ...input, frames, consentedAt: dto.consentConfirmed ? (previous?.consentConfirmed && previous.consentNote === dto.consentNote ? previous.consentedAt : new Date()) : null }
if (!id) {
const row = await this.prisma.teachingCase.create({ data })
return this.detail(row.id, true)
}
const updated = await this.prisma.teachingCase.updateMany({ where: { id, version }, data: { ...data, version: { increment: 1 } } })
if (!updated.count) throw new ConflictException('案例已被修改,请重新打开后编辑')
return this.detail(id, true)
}
async withdraw(id: string) {
const result = await this.prisma.teachingCase.updateMany({ where: { id }, data: { published: false, consentConfirmed: false, consentedAt: null, version: { increment: 1 } } })
if (!result.count) throw new NotFoundException('案例不存在')
return { success: true }
}
async upload(fileName: string) {
const credential = this.storage.createCaseCredential(fileName)
const asset = await this.prisma.teachingCaseAsset.create({ data: { objectKey: credential.key } })
return { id: asset.id, uploadUrl: credential.uploadUrl, formData: credential.formData }
}
async completeUpload(id: string) {
const asset = await this.prisma.teachingCaseAsset.findUnique({ where: { id } })
if (!asset) throw new NotFoundException('图片不存在')
await this.storage.verify(asset.objectKey)
await this.prisma.teachingCaseAsset.update({ where: { id }, data: { ready: true } })
return { id, url: this.storage.signedUrl(asset.objectKey, 'get', 900) }
}
private async present(rows: TeachingCase[], admin: boolean) {
const framesOf = (row: TeachingCase) => row.frames as unknown as TeachingCaseFrame[]
const ids = rows.flatMap(row => framesOf(row).flatMap(frame => [frame.beforeId, ...(frame.afterId ? [frame.afterId] : [])]))
const assets = await this.prisma.teachingCaseAsset.findMany({ where: { id: { in: ids }, ready: true } })
const urls = new Map(assets.map(asset => [asset.id, this.storage.signedUrl(asset.objectKey, 'get', 900)]))
return rows.map(row => ({
id: row.id, title: row.title, displayName: row.displayName, lessonCount: row.lessonCount, background: row.background, achievement: row.achievement,
frames: framesOf(row).map(frame => ({ ...frame, beforeUrl: urls.get(frame.beforeId), ...(frame.afterId ? { afterUrl: urls.get(frame.afterId) } : {}) })),
...(admin ? { sortOrder: row.sortOrder, published: row.published, consentConfirmed: row.consentConfirmed, consentNote: row.consentNote, consentedAt: row.consentedAt, version: row.version } : {}),
}))
}
}

View File

@@ -17,9 +17,9 @@ export class ProgressPhotoStorageService extends StudioUploadService {
const expiresAt = Math.floor(Date.now() / 1000) + 300
return { key, uploadUrl: `https://${host}`, expiresAt, formData: this.buildPostPolicy({ bucket, key, expiresAt, privateRead: true }) }
}
signedUrl(key: string, method = 'get') {
signedUrl(key: string, method = 'get', durationSeconds = 60) {
const { host } = this.endpoint()
const keyTime = `${Math.floor(Date.now() / 1000) - 5};${Math.floor(Date.now() / 1000) + 60}`
const keyTime = `${Math.floor(Date.now() / 1000) - 5};${Math.floor(Date.now() / 1000) + durationSeconds}`
const signKey = createHmac('sha1', this.getRequiredConfig('COS_SECRET_KEY')).update(keyTime).digest('hex')
const http = `${method}\n/${key}\n\nhost=${encodeURIComponent(host)}\n`
const toSign = `sha1\n${keyTime}\n${createHash('sha1').update(http).digest('hex')}\n`

View File

@@ -140,3 +140,5 @@ export * from './types/member-care'
export * from './types/body-portrait'
export * from './types/shop'
export * from './types/teaching-case'

View File

@@ -0,0 +1,36 @@
/** Public teaching stories are separate from private member progress. */
export interface TeachingCaseFrame {
kind: 'pair' | 'composite'
beforeId: string
afterId?: string
caption: string
beforeUrl?: string
afterUrl?: string
}
export interface TeachingCaseInput {
title: string
displayName: string
lessonCount: number
background: string
achievement: string
frames: TeachingCaseFrame[]
sortOrder: number
published: boolean
consentConfirmed: boolean
consentNote: string
version: number
}
export interface TeachingCasePublic {
id: string
title: string
displayName: string
lessonCount: number
background: string
achievement: string
frames: TeachingCaseFrame[]
}
export interface TeachingCaseAdmin extends TeachingCasePublic, TeachingCaseInput {
consentedAt: string | null
}
export interface TeachingCasePage<T = TeachingCasePublic> { items: T[]; total: number; page: number; limit: number }
export interface TeachingCaseUpload { id: string; uploadUrl: string; formData: Record<string, string> }