538de50bb1
- Prisma User 模型新增 username 字段(唯一索引) - 注册先查重复再创建,返回友好中文提示(非 500) - 登录支持用户名/手机号/邮箱三种方式 - 前端注册表单增加用户名输入框,预校验 2-20 位格式 - 新增 scripts/deploy.sh:一键构建并部署主站+镜像站+重启后端+重载 Nginx - 镜像站 www.yuzhiran.com.cn Nginx 配置与主站同步 - AI 助手架构升级:用户端/管理后台均采用完整 Tool Calling 架构 - 新增 UserAiAssistantService(18 工具)+ AiAssistantController - admin 助手新增 search + mark-all-notifications-read 工具 - 修复注册 500 错误:catch Prisma P2002 → BadRequestException - Baidu Analytics Script 注入 root layout
99 lines
3.6 KiB
Vue
Executable File
99 lines
3.6 KiB
Vue
Executable File
<template>
|
|
<view class="courses">
|
|
<view class="filter-bar">
|
|
<scroll-view scroll-x class="category-scroll">
|
|
<view class="category-list">
|
|
<text class="category-tag" :class="{ active: currentCategory === 0 }" @tap="currentCategory = 0">全部</text>
|
|
<text class="category-tag" v-for="cat in categories" :key="cat.id" :class="{ active: currentCategory === cat.id }"
|
|
@tap="currentCategory = cat.id">{{ cat.name }}</text>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
<view class="course-list">
|
|
<view class="course-card" v-for="item in list" :key="item.id" @tap="goDetail(item.id)">
|
|
<image class="cover" :src="item.cover || '/static/default-course.png'" mode="aspectFill" />
|
|
<view class="info">
|
|
<text class="title">{{ item.title }}</text>
|
|
<text class="desc">{{ item.description }}</text>
|
|
<view class="meta">
|
|
<text class="price" v-if="item.isFree">免费</text>
|
|
<text class="price paid" v-else>¥{{ item.price }}</text>
|
|
<text class="count">{{ item._count?.lessons || 0 }} 课时</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view class="loading-more" v-if="loading">加载中...</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
import { api } from '../../api/index'
|
|
|
|
const list = ref<any[]>([])
|
|
const categories = ref<any[]>([])
|
|
const currentCategory = ref(0)
|
|
const page = ref(1)
|
|
const loading = ref(false)
|
|
const hasMore = ref(true)
|
|
|
|
async function loadData() {
|
|
if (loading.value || !hasMore.value) return
|
|
loading.value = true
|
|
try {
|
|
const params: any = { page: page.value, pageSize: 10 }
|
|
if (currentCategory.value) params.categoryId = currentCategory.value
|
|
const res: any = await api.courses.list(params)
|
|
const items = res.items || []
|
|
list.value = page.value === 1 ? items : [...list.value, ...items]
|
|
hasMore.value = items.length === 10
|
|
} catch (e) {
|
|
console.error(e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
watch(currentCategory, () => {
|
|
page.value = 1
|
|
hasMore.value = true
|
|
loadData()
|
|
})
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
categories.value = (await api.categories.list()) as any[] || []
|
|
} catch (e) { /* ignore */ }
|
|
loadData()
|
|
})
|
|
|
|
const goDetail = (id: number) => uni.navigateTo({ url: `/pages/course-detail/index?id=${id}` })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.courses { }
|
|
.filter-bar { background: var(--card); padding: 20rpx 30rpx; border-bottom: 1rpx solid var(--border); }
|
|
.category-scroll { white-space: nowrap; }
|
|
.category-list { display: flex; gap: 16rpx; }
|
|
.category-tag {
|
|
display: inline-block; padding: 12rpx 28rpx; border-radius: 30rpx; font-size: 26rpx;
|
|
background: var(--muted); color: var(--muted-foreground);
|
|
}
|
|
.category-tag.active { background: var(--brand); color: var(--card); }
|
|
.course-list { padding: 20rpx 30rpx; }
|
|
.course-card {
|
|
display: flex; background: var(--card); border-radius: 16rpx; overflow: hidden;
|
|
margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
|
}
|
|
.cover { width: 200rpx; height: 150rpx; }
|
|
.info { flex: 1; padding: 20rpx; }
|
|
.title { font-size: 28rpx; font-weight: 500; display: block; }
|
|
.desc { font-size: 24rpx; color: var(--muted-foreground); margin-top: 8rpx; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.meta { display: flex; align-items: center; justify-content: space-between; margin-top: 12rpx; }
|
|
.price { font-size: 26rpx; color: var(--brand); }
|
|
.price.paid { color: #ff6b6b; }
|
|
.count { font-size: 22rpx; color: var(--muted-foreground); }
|
|
.loading-more { text-align: center; padding: 30rpx; color: var(--muted-foreground); font-size: 26rpx; }
|
|
</style>
|