feat: add course package storefront for unauth and home pages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 08:08:01 +08:00
parent e65ef12cfc
commit f033e303ce
5 changed files with 282 additions and 1 deletions
+222
View File
@@ -0,0 +1,222 @@
<template>
<div class="course-package-store">
<template v-if="state.loading">
<div class="skeleton-list" :class="{ compact }">
<div v-for="i in 4" :key="i" class="skeleton-card">
<van-skeleton title :row="2" />
</div>
</div>
</template>
<template v-else-if="state.error">
<van-empty description="加载失败">
<van-button size="small" type="primary" @click="loadPackages">重试</van-button>
</van-empty>
</template>
<template v-else-if="!packages.length">
<van-empty description="暂无可售课包" />
</template>
<div v-else class="package-list" :class="{ compact }">
<div v-for="pkg in packages" :key="pkg.id" class="package-card">
<div class="card-cover">
<van-image
:src="pkg.cover_image_url"
fit="cover"
height="3.2rem"
>
<template v-slot:error>
<div class="cover-placeholder">📦</div>
</template>
</van-image>
</div>
<div class="card-body">
<div class="card-title">{{ pkg.package_name }}</div>
<div class="card-desc" v-if="pkg.description">{{ pkg.description }}</div>
<div class="card-meta">
<span v-if="pkg.total_lessons" class="meta-item">{{ pkg.total_lessons }} 课次</span>
<span v-if="pkg.validity_days" class="meta-item">{{ pkg.validity_days }} 天有效</span>
</div>
<van-button size="small" round plain type="primary" class="preview-btn" @click="onPreview(pkg)">
试看
</van-button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { Button, Empty, Image, Skeleton, showDialog } from 'vant';
import useCoursePackage from '~/store/course-package';
import { CoursePackage } from '~/types';
export default defineComponent({
name: 'course-package-store',
components: {
[Button.name!]: Button,
[Empty.name!]: Empty,
[Image.name!]: Image,
[Skeleton.name!]: Skeleton,
},
props: {
orgId: { type: String, default: '' },
compact: { type: Boolean, default: false },
},
setup(props) {
const router = useRouter();
const { store, queryPublicPackages, queryOrgPackages } = useCoursePackage();
const state = reactive({
loading: false,
error: false,
loaded: false,
});
const packages = computed(() => store.list || []);
const isLoggedIn = () => !!localStorage.getItem('Authorization');
const loadPackages = async () => {
state.loading = true;
state.error = false;
let ok: boolean;
if (props.orgId) {
ok = await queryPublicPackages(props.orgId);
} else {
// Fallback: use auth'd API if logged in, otherwise skip
ok = isLoggedIn() ? await queryOrgPackages() : false;
if (!ok && !isLoggedIn()) {
state.loading = false;
state.error = false;
return;
}
}
state.error = !ok;
state.loading = false;
state.loaded = true;
};
const onPreview = (pkg: CoursePackage) => {
if (isLoggedIn()) {
router.push(`/course/course-package/detail?id=${pkg.id}`);
} else {
showDialog({
title: '提示',
message: '请通过微信登录后查看完整内容',
confirmButtonText: '知道了',
});
}
};
onMounted(loadPackages);
return { state, packages, loadPackages, onPreview };
},
});
</script>
<style scoped lang="less">
.course-package-store {
width: 100%;
.skeleton-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.24rem;
padding: 0 0.24rem;
&.compact {
grid-template-columns: 1fr 1fr;
}
.skeleton-card {
background: #fff;
border-radius: 8px;
padding: 0.24rem;
}
}
.package-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.24rem;
padding: 0 0.24rem 0.24rem;
&.compact {
grid-template-columns: 1fr 1fr;
}
}
.package-card {
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
.card-cover {
width: 100%;
.cover-placeholder {
width: 100%;
height: 3.2rem;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
font-size: 1rem;
}
}
.card-body {
padding: 0.2rem;
flex: 1;
display: flex;
flex-direction: column;
.card-title {
font-weight: 600;
font-size: 0.28rem;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-desc {
font-size: 0.22rem;
color: #999;
margin-top: 0.08rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.12rem;
margin-top: 0.12rem;
.meta-item {
font-size: 0.2rem;
color: #888;
background: #f5f5f5;
padding: 0.04rem 0.12rem;
border-radius: 4px;
}
}
.preview-btn {
margin-top: 0.16rem;
align-self: flex-start;
}
}
}
}
</style>
+3 -1
View File
@@ -18,7 +18,7 @@
</template>
<van-empty v-else description="账号未启用或未通过注册审核" />
</template>
<van-empty v-if="!has_login" description="请返回微信小程序完成登录" />
<course-package-store v-if="!has_login" />
</div>
</template>
@@ -44,6 +44,7 @@ import {
RadioGroup,
showConfirmDialog,
} from "vant";
import CoursePackageStore from "~/components/course-package-store.vue";
import { CurrentUserRole } from "~/utils";
import { sanitizeMiniProgramWebViewPathForRouter } from "~/utils/wxMiniProgramWebViewShare";
@@ -61,6 +62,7 @@ export default defineComponent({
[Form.name!]: Form,
[Popup.name!]: Popup,
[Loading.name!]: Loading,
[CoursePackageStore.name!]: CoursePackageStore,
},
props: ["params"],
setup({ params }) {
+20
View File
@@ -10,6 +10,10 @@
</div>
<div v-else class="org-homepage-md" v-html="homepageHtml" />
</div>
<div class="course-package-section" v-if="orgStore.currentOrgId">
<div class="section-header">课包商店</div>
<course-package-store :org-id="orgStore.currentOrgId" compact />
</div>
</div>
</template>
@@ -18,10 +22,14 @@ import { computedAsync } from "@vueuse/core";
import { computed, defineComponent, onActivated, onMounted, ref } from "vue";
import useUser from "~/store/user";
import useOrg from "~/store/org";
import CoursePackageStore from "~/components/course-package-store.vue";
import { renderOrgHomepageMarkdown } from "~/utils/orgHomepageMarkdown";
export default defineComponent({
name: "student-home",
components: {
[CoursePackageStore.name!]: CoursePackageStore,
},
setup() {
const usingUser = useUser();
const { store: orgStore, getHomepage } = useOrg();
@@ -140,5 +148,17 @@ export default defineComponent({
padding-left: 0.4rem;
margin: 0.16rem 0;
}
.course-package-section {
margin: 0 0.24rem 0.24rem;
.section-header {
font-weight: 600;
font-size: 0.32rem;
color: #333;
margin-bottom: 0.16rem;
padding: 0.24rem 0 0.16rem;
}
}
}
</style>
+20
View File
@@ -10,6 +10,10 @@
</div>
<div v-else class="org-homepage-md" v-html="homepageHtml" />
</div>
<div class="course-package-section" v-if="orgStore.currentOrgId">
<div class="section-header">课包商店</div>
<course-package-store :org-id="orgStore.currentOrgId" compact />
</div>
</div>
</template>
@@ -18,10 +22,14 @@ import { computedAsync } from "@vueuse/core";
import { computed, defineComponent, onActivated, onMounted, ref } from "vue";
import useUser from "~/store/user";
import useOrg from "~/store/org";
import CoursePackageStore from "~/components/course-package-store.vue";
import { renderOrgHomepageMarkdown } from "~/utils/orgHomepageMarkdown";
export default defineComponent({
name: "teacher-home",
components: {
[CoursePackageStore.name!]: CoursePackageStore,
},
setup() {
const usingUser = useUser();
const { store: orgStore, getHomepage } = useOrg();
@@ -140,5 +148,17 @@ export default defineComponent({
padding-left: 0.4rem;
margin: 0.16rem 0;
}
.course-package-section {
margin: 0 0.24rem 0.24rem;
.section-header {
font-weight: 600;
font-size: 0.32rem;
color: #333;
margin-bottom: 0.16rem;
padding: 0.24rem 0 0.16rem;
}
}
}
</style>
+17
View File
@@ -37,6 +37,22 @@ export default function useCoursePackage() {
return r;
}
async function queryPublicPackages(orgId: string, keyword?: string, page = 1, pageSize = 20): Promise<boolean> {
load_start();
const params = new URLSearchParams({org_id: orgId, page: String(page), page_size: String(pageSize)});
if (keyword) params.set('keyword', keyword);
const {r, d, e} = await request({url: `/api/v1/clazz/course-package/public-packages?${params}`});
load_done();
if (r) {
const [list, totalPage, totalCount] = d as [CoursePackage[], number, number];
state.list = Array.isArray(list) ? list : [];
state.total = totalPage;
} else {
showFailToast(e);
}
return r;
}
async function queryOrgPackages(keyword?: string, page = 1, pageSize = 20): Promise<boolean> {
load_start();
const params = new URLSearchParams({page: String(page), page_size: String(pageSize)});
@@ -178,6 +194,7 @@ export default function useCoursePackage() {
store: state,
queryMyPackages,
queryOrgPackages,
queryPublicPackages,
findById,
create,
update,