feat: 支持教学案例展示功能
This commit is contained in:
18
packages/app/src/components/TeachingCaseCard.vue
Normal file
18
packages/app/src/components/TeachingCaseCard.vue
Normal 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>
|
||||
40
packages/app/src/components/TeachingCaseGallery.vue
Normal file
40
packages/app/src/components/TeachingCaseGallery.vue
Normal 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>
|
||||
17
packages/app/src/components/TeachingCaseSection.vue
Normal file
17
packages/app/src/components/TeachingCaseSection.vue
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
132
packages/app/src/pages/admin/teaching-case-edit.vue
Normal file
132
packages/app/src/pages/admin/teaching-case-edit.vue
Normal 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>
|
||||
53
packages/app/src/pages/admin/teaching-cases.vue
Normal file
53
packages/app/src/pages/admin/teaching-cases.vue
Normal 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>
|
||||
@@ -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(
|
||||
|
||||
50
packages/app/src/pages/home/teaching-case.vue
Normal file
50
packages/app/src/pages/home/teaching-case.vue
Normal 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>
|
||||
39
packages/app/src/pages/home/teaching-cases.vue
Normal file
39
packages/app/src/pages/home/teaching-cases.vue
Normal 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>
|
||||
Reference in New Issue
Block a user