feat(course-package): detail page, publish/unpublish UI, edit lock
- Add detail/show page with conditional action buttons - Route /course/course-package/detail with props query params - List page click navigates to detail instead of edit - Edit page redirects if package is published (published_at set) - Store: add publish/unpublish API functions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,6 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pickCourseGroups = () => {
|
const pickCourseGroups = () => {
|
||||||
store.hanging = true;
|
|
||||||
router.push("/course/group/pick?target=" + PickTargets.COURSE_PACKAGE);
|
router.push("/course/group/pick?target=" + PickTargets.COURSE_PACKAGE);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,7 +108,20 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.selectedGroups = [];
|
const saved = sessionStorage.getItem('cp-selected-groups');
|
||||||
|
if (saved) {
|
||||||
|
try { store.selectedGroups = JSON.parse(saved); } catch(e) {}
|
||||||
|
sessionStorage.removeItem('cp-selected-groups');
|
||||||
|
} else {
|
||||||
|
store.selectedGroups = [];
|
||||||
|
}
|
||||||
|
(window as any).__cp_debug = {
|
||||||
|
onMounted: true,
|
||||||
|
saved: saved?.substring(0, 100),
|
||||||
|
selectedGroupsLen: store.selectedGroups.length,
|
||||||
|
selectedGroupsNames: store.selectedGroups.map((g: any) => g.group_name),
|
||||||
|
ts: Date.now()
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main">
|
||||||
|
<div class="body">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="title">{{ pkg?.package_name }}</h1>
|
||||||
|
<van-tag :type="pkg?.package_status === 'ACTIVE' ? 'success' : 'warning'" size="medium">
|
||||||
|
{{ pkg?.package_status === 'ACTIVE' ? '上架中' : '已下架' }}
|
||||||
|
</van-tag>
|
||||||
|
<van-tag v-if="isPublished" type="info" size="medium" style="margin-left: 0.08rem;">已发布快照</van-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>基本信息</h2>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="描述" :value="pkg?.description || '无'" />
|
||||||
|
<van-cell title="课次数量" :value="pkg?.total_lessons?.toString() || '无'" />
|
||||||
|
<van-cell title="原价" :value="pkg?.original_price ? '¥' + (pkg.original_price / 100).toFixed(2) : '无'" />
|
||||||
|
<van-cell title="售价" :value="pkg?.selling_price ? '¥' + (pkg.selling_price / 100).toFixed(2) : '无'" />
|
||||||
|
<van-cell title="有效期" :value="pkg?.validity_days ? pkg.validity_days + ' 天' : '无'" />
|
||||||
|
<van-cell title="排序" :value="pkg?.sort_order?.toString() || '无'" />
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>包含课节</h2>
|
||||||
|
<div v-if="courseGroups.length === 0" class="empty">暂无课节</div>
|
||||||
|
<van-cell v-for="g in courseGroups" :key="g.id" :title="g.group_name">
|
||||||
|
<template #label>
|
||||||
|
<div v-if="g.course_sections?.length">
|
||||||
|
{{ g.course_sections.map((s: any) => (s.course_name || '') + '(' + s.section_name + ')').join('、') }}
|
||||||
|
</div>
|
||||||
|
<div v-else style="color: #999;">无课程</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<van-button v-if="!isPublished" type="primary" @click="onEdit">编辑</van-button>
|
||||||
|
<van-button v-if="!isPublished" type="danger" @click="onDelete">删除</van-button>
|
||||||
|
<van-button v-if="!isPublished" type="success" @click="onPublish">发布上架</van-button>
|
||||||
|
<van-button v-if="isPublished" type="warning" @click="onUnpublish">下架</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {computed, defineComponent, onMounted, ref} from 'vue';
|
||||||
|
import {Button, Cell, CellGroup, Tag, showConfirmDialog, showFailToast} from 'vant';
|
||||||
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
|
import useCoursePackage from '~/store/course-package';
|
||||||
|
import {CourseGroup} from '~/types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'course-package-detail',
|
||||||
|
components: {
|
||||||
|
[Button.name]: Button,
|
||||||
|
[Cell.name]: Cell,
|
||||||
|
[CellGroup.name]: CellGroup,
|
||||||
|
[Tag.name]: Tag,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const {store, findById, remove, listPackageItems, publish, unpublish} = useCoursePackage();
|
||||||
|
|
||||||
|
const pkg = ref<any>(null);
|
||||||
|
const courseGroups = ref<CourseGroup[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const isPublished = computed(() => !!pkg.value?.published_at);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const id = route.query.id as string;
|
||||||
|
if (!id) {
|
||||||
|
showFailToast('缺少课包 ID');
|
||||||
|
router.go(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
const data = await findById(id);
|
||||||
|
if (!data) {
|
||||||
|
router.go(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pkg.value = data;
|
||||||
|
|
||||||
|
if (data.published_snapshot?.course_groups) {
|
||||||
|
// 从快照读取课节数据
|
||||||
|
courseGroups.value = data.published_snapshot.course_groups;
|
||||||
|
} else {
|
||||||
|
// 实时获取课节数据
|
||||||
|
const groups = await listPackageItems(id);
|
||||||
|
courseGroups.value = groups || [];
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEdit = () => {
|
||||||
|
router.push(`/course/course-package/edit?id=${pkg.value?.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({message: `确认删除课包【${pkg.value?.package_name}】?`});
|
||||||
|
const ok = await remove(pkg.value?.id!);
|
||||||
|
if (ok) router.go(-1);
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPublish = async () => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '发布课包',
|
||||||
|
message: '发布后将锁定课包内容,生成快照,不可再编辑。确认发布?',
|
||||||
|
});
|
||||||
|
const id = pkg.value?.id!;
|
||||||
|
// 获取完整课节数据作为快照
|
||||||
|
let groups = courseGroups.value;
|
||||||
|
if (groups.length === 0) {
|
||||||
|
groups = (await listPackageItems(id)) || [];
|
||||||
|
}
|
||||||
|
const snapshot = { course_groups: groups };
|
||||||
|
const ok = await publish(id, snapshot);
|
||||||
|
if (ok) await loadData();
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUnpublish = async () => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '下架课包',
|
||||||
|
message: '下架后课包内容可编辑,确认下架?',
|
||||||
|
});
|
||||||
|
const ok = await unpublish(pkg.value?.id!);
|
||||||
|
if (ok) await loadData();
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pkg, courseGroups, loading, isPublished,
|
||||||
|
onEdit, onDelete, onPublish, onUnpublish,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.main {
|
||||||
|
.body {
|
||||||
|
height: calc(100% - 1.06rem);
|
||||||
|
padding: 0.32rem 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 0.48rem;
|
||||||
|
margin-block-start: 0;
|
||||||
|
margin-block-end: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 0.32rem;
|
||||||
|
padding: 0 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.26rem;
|
||||||
|
padding: 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.16rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.01rem 0.32rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.16rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -113,6 +113,11 @@ export default defineComponent({
|
|||||||
form.validity_days = pkg.validity_days;
|
form.validity_days = pkg.validity_days;
|
||||||
form.sort_order = pkg.sort_order;
|
form.sort_order = pkg.sort_order;
|
||||||
form.package_status = pkg.package_status || 'ACTIVE';
|
form.package_status = pkg.package_status || 'ACTIVE';
|
||||||
|
if (pkg.published_at) {
|
||||||
|
showFailToast('已发布的课包不可编辑,请先下架再修改');
|
||||||
|
router.go(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pkg.original_price != null) {
|
if (pkg.original_price != null) {
|
||||||
originalPriceStr.value = (pkg.original_price / 100).toFixed(2);
|
originalPriceStr.value = (pkg.original_price / 100).toFixed(2);
|
||||||
}
|
}
|
||||||
@@ -122,7 +127,6 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pickCourseGroups = () => {
|
const pickCourseGroups = () => {
|
||||||
store.hanging = true;
|
|
||||||
router.push("/course/group/pick?target=" + PickTargets.COURSE_PACKAGE);
|
router.push("/course/group/pick?target=" + PickTargets.COURSE_PACKAGE);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,7 +168,17 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => loadPackageAndItems());
|
onMounted(() => {
|
||||||
|
const saved = sessionStorage.getItem('cp-selected-groups');
|
||||||
|
if (saved) {
|
||||||
|
// 从 picker 返回,恢复选中课节,只加载表单数据
|
||||||
|
try { store.selectedGroups = JSON.parse(saved); } catch(e) {}
|
||||||
|
sessionStorage.removeItem('cp-selected-groups');
|
||||||
|
loadPackage();
|
||||||
|
} else {
|
||||||
|
loadPackageAndItems();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form, store, originalPriceStr, sellingPriceStr, formatPrice,
|
form, store, originalPriceStr, sellingPriceStr, formatPrice,
|
||||||
|
|||||||
@@ -142,8 +142,7 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const viewDetail = (item: CoursePackage) => {
|
const viewDetail = (item: CoursePackage) => {
|
||||||
// For now, edit is the detail view since course packages are simple
|
router.push(`/course/course-package/detail?id=${item.id}`);
|
||||||
edit(item);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const del = async (item: CoursePackage) => {
|
const del = async (item: CoursePackage) => {
|
||||||
|
|||||||
@@ -237,7 +237,9 @@ export default defineComponent({
|
|||||||
usingClazz.store.current.course_sections = {vals: course_sections};
|
usingClazz.store.current.course_sections = {vals: course_sections};
|
||||||
break;
|
break;
|
||||||
case PickTargets.COURSE_PACKAGE:
|
case PickTargets.COURSE_PACKAGE:
|
||||||
|
sessionStorage.setItem('cp-selected-groups', JSON.stringify(state.checked));
|
||||||
usingCoursePackage.store.selectedGroups = state.checked;
|
usingCoursePackage.store.selectedGroups = state.checked;
|
||||||
|
(window as any).__cp_debug = { action: 'onPick', checked: state.checked.length, target: params.target, ts: Date.now() };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
router.back();
|
router.back();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import CourseGroupDetail from '~/pages/qumu/group/detail.vue'
|
|||||||
import CoursePackage from '~/pages/qumu/course-package/index.vue'
|
import CoursePackage from '~/pages/qumu/course-package/index.vue'
|
||||||
import CoursePackageAdd from '~/pages/qumu/course-package/add.vue'
|
import CoursePackageAdd from '~/pages/qumu/course-package/add.vue'
|
||||||
import CoursePackageEdit from '~/pages/qumu/course-package/edit.vue'
|
import CoursePackageEdit from '~/pages/qumu/course-package/edit.vue'
|
||||||
|
import CoursePackageDetail from '~/pages/qumu/course-package/detail.vue'
|
||||||
import Tongzhi from '~/pages/tongzhi/index.vue';
|
import Tongzhi from '~/pages/tongzhi/index.vue';
|
||||||
import TongzhiDetail from '~/pages/tongzhi/detail.vue';
|
import TongzhiDetail from '~/pages/tongzhi/detail.vue';
|
||||||
import Tester from '~/pages/tester/index.vue'
|
import Tester from '~/pages/tester/index.vue'
|
||||||
@@ -181,6 +182,10 @@ export default [
|
|||||||
path: '/course/course-package/edit', component: CoursePackageEdit,
|
path: '/course/course-package/edit', component: CoursePackageEdit,
|
||||||
meta: {title: "编辑课包"}, props: ({query}: RouteQueryAndHash) => ({params: query})
|
meta: {title: "编辑课包"}, props: ({query}: RouteQueryAndHash) => ({params: query})
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/course/course-package/detail', component: CoursePackageDetail,
|
||||||
|
meta: {title: "课包详情"}, props: ({query}: RouteQueryAndHash) => ({params: query})
|
||||||
|
},
|
||||||
{path: '/tongzhi', component: Tongzhi, meta: {title: "消息通知"}},
|
{path: '/tongzhi', component: Tongzhi, meta: {title: "消息通知"}},
|
||||||
{
|
{
|
||||||
path: '/tongzhi/detail', component: TongzhiDetail, meta: {title: "通知详情"},
|
path: '/tongzhi/detail', component: TongzhiDetail, meta: {title: "通知详情"},
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ interface CoursePackageStore {
|
|||||||
list: CoursePackage[];
|
list: CoursePackage[];
|
||||||
total: number;
|
total: number;
|
||||||
selectedGroups: CourseGroup[];
|
selectedGroups: CourseGroup[];
|
||||||
|
hanging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = reactive<CoursePackageStore>({
|
const state = reactive<CoursePackageStore>({
|
||||||
list: [],
|
list: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
selectedGroups: [],
|
selectedGroups: [],
|
||||||
|
hanging: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function useCoursePackage() {
|
export default function useCoursePackage() {
|
||||||
@@ -141,6 +143,37 @@ export default function useCoursePackage() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publish(id: string, snapshot: any): Promise<boolean> {
|
||||||
|
load_start();
|
||||||
|
const {r, e} = await request({
|
||||||
|
url: `/api/v1/clazz/course-package/publish/${id}`,
|
||||||
|
method: 'POST',
|
||||||
|
data: snapshot,
|
||||||
|
});
|
||||||
|
load_done();
|
||||||
|
if (r) {
|
||||||
|
showSuccessToast({message: '课包发布成功!'});
|
||||||
|
} else {
|
||||||
|
showFailToast(e);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unpublish(id: string): Promise<boolean> {
|
||||||
|
load_start();
|
||||||
|
const {r, e} = await request({
|
||||||
|
url: `/api/v1/clazz/course-package/unpublish/${id}`,
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
load_done();
|
||||||
|
if (r) {
|
||||||
|
showSuccessToast({message: '课包已下架!'});
|
||||||
|
} else {
|
||||||
|
showFailToast(e);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
store: state,
|
store: state,
|
||||||
queryMyPackages,
|
queryMyPackages,
|
||||||
@@ -151,5 +184,7 @@ export default function useCoursePackage() {
|
|||||||
remove,
|
remove,
|
||||||
syncPackageItems,
|
syncPackageItems,
|
||||||
listPackageItems,
|
listPackageItems,
|
||||||
|
publish,
|
||||||
|
unpublish,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -770,4 +770,6 @@ export interface CoursePackage {
|
|||||||
updated_at?: string | Date;
|
updated_at?: string | Date;
|
||||||
is_delete?: boolean;
|
is_delete?: boolean;
|
||||||
org_id?: string;
|
org_id?: string;
|
||||||
|
published_snapshot?: any;
|
||||||
|
published_at?: string | Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user