feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径

This commit is contained in:
yuzhiran-dev
2026-05-18 09:48:51 +08:00
commit 11bb86854c
277 changed files with 37755 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>宇之然 AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "yuzhiran-mobile",
"version": "1.0.0",
"private": true,
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"dev:h5": "uni",
"build:mp-weixin": "uni build -p mp-weixin",
"build:h5": "uni build"
},
"dependencies": {
"vue": "^3.4.0",
"pinia": "^2.1.0",
"@dcloudio/uni-app": "^3.0.0",
"@dcloudio/uni-ui": "^1.5.0"
},
"devDependencies": {
"@dcloudio/uni-cli-shared": "^3.0.0",
"@dcloudio/uni-mp-weixin": "^3.0.0",
"@dcloudio/uni-automator": "^3.0.0",
"@dcloudio/vite-plugin-uni": "^3.0.0",
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"vue-tsc": "^2.0.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
<template>
<view class="app">
<tab-bar v-if="showTabBar" />
<slot />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { onPageScroll, onReachBottom } from '@dcloudio/uni-app'
const tabBarPages = ['pages/index/index', 'pages/courses/index', 'pages/community/index', 'pages/sandbox/index', 'pages/profile/index']
const pages = getCurrentPages()
const showTabBar = computed(() => {
return pages.length > 0 && tabBarPages.includes(pages[pages.length - 1]?.route || '')
})
</script>
<style>
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
export const api = {
auth: {
login: (data: { account: string; password: string }) => http.post('/auth/login', data),
register: (data: { phone?: string; email?: string; password: string; nickname?: string }) => http.post('/auth/register', data),
refresh: (accessToken: string) => http.post('/auth/refresh', { accessToken }),
profile: () => http.get('/auth/profile'),
},
courses: {
list: (params?: any) => http.get('/courses', params),
detail: (id: number) => http.get(`/courses/${id}`),
myLearning: () => http.get('/courses/my-learning'),
updateProgress: (courseId: number, lessonId: number, data: any) =>
http.post(`/courses/${courseId}/lessons/${lessonId}/progress`, data),
},
prompts: {
list: (params?: any) => http.get('/prompts', params),
detail: (id: number) => http.get(`/prompts/${id}`),
create: (data: any) => http.post('/prompts', data),
toggleFavorite: (id: number) => http.post(`/prompts/${id}/favorite`),
favorites: () => http.get('/prompts/favorites'),
},
community: {
posts: (params?: any) => http.get('/community/posts', params),
postDetail: (id: number) => http.get(`/community/posts/${id}`),
createPost: (data: any) => http.post('/community/posts', data),
addComment: (postId: number, content: string) =>
http.post(`/community/posts/${postId}/comments`, { content }),
toggleLike: (postId: number) => http.post(`/community/posts/${postId}/like`),
checkLike: (postId: number) => http.get(`/community/posts/${postId}/like`),
},
circles: {
list: (params?: any) => http.get('/circles', params),
detail: (id: number) => http.get(`/circles/${id}`),
posts: (id: number, params?: any) => http.get(`/circles/${id}/posts`, params),
join: (id: number) => http.post(`/circles/${id}/join`),
leave: (id: number) => http.post(`/circles/${id}/leave`),
create: (data: any) => http.post('/circles', data),
},
sandbox: {
chat: (data: { model: string; message: string; sessionId?: number }) =>
http.post('/sandbox/chat', data),
history: (params?: any) => http.get('/sandbox/history', params),
quota: () => http.get('/sandbox/quota'),
},
search: {
query: (params: { q: string; type?: string; page?: number; pageSize?: number }) =>
http.get('/search', params),
},
orders: {
create: (data: { planType: string; amount: number }) => http.post('/orders/create', data),
list: () => http.get('/orders'),
detail: (orderNo: string) => http.get(`/orders/${orderNo}`),
},
subscriptions: {
current: () => http.get('/subscriptions/current'),
list: () => http.get('/subscriptions'),
},
dashboard: {
stats: () => http.get('/dashboard/stats'),
progress: () => http.get('/dashboard/progress'),
favorites: () => http.get('/dashboard/favorites'),
},
categories: {
list: () => http.get('/categories'),
},
models: {
list: (params?: any) => http.get('/models', params),
},
tools: {
list: (params?: any) => http.get('/tools', params),
},
contents: {
list: (params?: any) => http.get('/contents', params),
},
}
import { http } from '../utils/request'
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+9
View File
@@ -0,0 +1,9 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
app.use(createPinia())
return { app }
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "宇之然 AI",
"appid": "__UNI__XXXXXXX",
"versionName": "1.0.0",
"versionCode": "100",
"description": "让每个人都能用好 AI",
"uni-app": {
"hbuilder": {
"launch_path": "pages/index/index"
}
},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"h5": {
"title": "宇之然 AI",
"router": {
"mode": "hash"
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"pages": [
{"path": "pages/index/index", "style": {"navigationBarTitleText": "宇之然 AI"}},
{"path": "pages/login/index", "style": {"navigationBarTitleText": "登录"}},
{"path": "pages/register/index", "style": {"navigationBarTitleText": "注册"}},
{"path": "pages/courses/index", "style": {"navigationBarTitleText": "课程"}},
{"path": "pages/course-detail/index", "style": {"navigationBarTitleText": "课程详情"}},
{"path": "pages/lesson/index", "style": {"navigationBarTitleText": "学习"}},
{"path": "pages/prompts/index", "style": {"navigationBarTitleText": "提示词"}},
{"path": "pages/prompt-detail/index", "style": {"navigationBarTitleText": "提示词详情"}},
{"path": "pages/community/index", "style": {"navigationBarTitleText": "社区"}},
{"path": "pages/post-detail/index", "style": {"navigationBarTitleText": "帖子详情"}},
{"path": "pages/create-post/index", "style": {"navigationBarTitleText": "发布帖子"}},
{"path": "pages/sandbox/index", "style": {"navigationBarTitleText": "AI 沙箱"}},
{"path": "pages/circles/index", "style": {"navigationBarTitleText": "圈子"}},
{"path": "pages/circle-detail/index", "style": {"navigationBarTitleText": "圈子详情"}},
{"path": "pages/search/index", "style": {"navigationBarTitleText": "搜索"}},
{"path": "pages/profile/index", "style": {"navigationBarTitleText": "我的"}},
{"path": "pages/membership/index", "style": {"navigationBarTitleText": "会员中心"}},
{"path": "pages/favorites/index", "style": {"navigationBarTitleText": "我的收藏"}},
{"path": "pages/learning-progress/index", "style": {"navigationBarTitleText": "学习进度"}}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "宇之然 AI",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f5f5"
},
"tabBar": {
"color": "#999",
"selectedColor": "#4f8cff",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{"pagePath": "pages/index/index", "text": "首页"},
{"pagePath": "pages/courses/index", "text": "课程"},
{"pagePath": "pages/community/index", "text": "社区"},
{"pagePath": "pages/sandbox/index", "text": "沙箱"},
{"pagePath": "pages/profile/index", "text": "我的"}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
<template>
<view class="circle-detail">
<view class="header" v-if="circle.id">
<text class="name">#{{ circle.name }}</text>
<text class="desc">{{ circle.description }}</text>
<button class="btn-join" v-if="!isMember" @tap="joinCircle">加入圈子</button>
<button class="btn-leave" v-else @tap="leaveCircle">退出圈子</button>
</view>
<view class="post-list">
<view class="post-item" v-for="item in posts" :key="item.id" @tap="goPost(item.id)">
<text class="post-title">{{ item.title }}</text>
<text class="post-meta">{{ item.user?.nickname }} · {{ item.likeCount }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
const circle = ref<any>({})
const posts = ref<any[]>([])
const isMember = ref(false)
onMounted(async () => {
const id = Number((uni as any).getCurrentInstance()?.router?.params?.id || '')
try {
const [c, p]: any = await Promise.all([
api.circles.detail(id),
api.circles.posts(id, { pageSize: 20 }),
])
circle.value = c
posts.value = p.items || []
} catch (e) { console.error(e) }
})
async function joinCircle() {
if (!userStore.isLoggedIn) { uni.navigateTo({ url: '/pages/login/index' }); return }
try {
await api.circles.join(circle.value.id)
isMember.value = true
uni.showToast({ title: '已加入', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
async function leaveCircle() {
try {
await api.circles.leave(circle.value.id)
isMember.value = false
uni.showToast({ title: '已退出', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
const goPost = (id: number) => uni.navigateTo({ url: `/pages/post-detail/index?id=${id}` })
</script>
<style scoped>
.circle-detail { }
.header { padding: 40rpx 30rpx; background: #fff; border-bottom: 1rpx solid #f0f0f0; }
.name { font-size: 40rpx; font-weight: bold; color: #4f8cff; display: block; }
.desc { font-size: 26rpx; color: #666; margin-top: 16rpx; display: block; }
.btn-join { background: #4f8cff; color: #fff; border-radius: 10rpx; padding: 16rpx 40rpx; font-size: 26rpx; margin-top: 20rpx; border: none; }
.btn-leave { background: #fff; color: #ff4d4f; border-radius: 10rpx; padding: 16rpx 40rpx; font-size: 26rpx; margin-top: 20rpx; border: 1rpx solid #ff4d4f; }
.post-list { padding: 20rpx 30rpx; }
.post-item { background: #fff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx; }
.post-title { font-size: 28rpx; font-weight: 500; display: block; }
.post-meta { font-size: 22rpx; color: #999; margin-top: 8rpx; display: block; }
</style>
+43
View File
@@ -0,0 +1,43 @@
<template>
<view class="circles">
<view class="circle-list">
<view class="circle-card" v-for="item in list" :key="item.id" @tap="goDetail(item.id)">
<text class="circle-name">#{{ item.name }}</text>
<text class="circle-desc">{{ item.description }}</text>
<view class="circle-meta">
<text class="circle-count">{{ item._count?.members || 0 }} 人加入</text>
<text class="circle-tags">{{ item.tags }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
const list = ref<any[]>([])
onMounted(async () => {
try {
const res: any = await api.circles.list({ pageSize: 20 })
list.value = res.items || []
} catch (e) { console.error(e) }
})
const goDetail = (id: number) => uni.navigateTo({ url: `/pages/circle-detail/index?id=${id}` })
</script>
<style scoped>
.circles { padding: 30rpx; }
.circle-card {
background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.circle-name { font-size: 32rpx; font-weight: bold; color: #4f8cff; display: block; }
.circle-desc { font-size: 26rpx; color: #666; margin-top: 12rpx; display: block; }
.circle-meta { display: flex; justify-content: space-between; margin-top: 16rpx; }
.circle-count { font-size: 22rpx; color: #999; }
.circle-tags { font-size: 22rpx; color: #4f8cff; }
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<view class="community">
<view class="tabs">
<text class="tab" :class="{ active: currentTab === 'latest' }" @tap="currentTab = 'latest'">最新</text>
<text class="tab" :class="{ active: currentTab === 'hot' }" @tap="currentTab = 'hot'">热门</text>
</view>
<view class="post-list">
<view class="post-card" v-for="item in posts" :key="item.id" @tap="goDetail(item.id)">
<view class="post-header">
<text class="author">{{ item.user?.nickname || '匿名' }}</text>
<text class="time">{{ item.createdAt?.slice(0, 10) }}</text>
</view>
<text class="post-title">{{ item.title }}</text>
<view class="post-footer">
<text class="stat"> {{ item.likeCount }}</text>
<text class="stat">💬 {{ item.commentCount }}</text>
<text class="stat">👁 {{ item.viewCount }}</text>
</view>
</view>
</view>
<view class="fab" @tap="goCreate">
<text class="fab-icon">+</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { api } from '../../api/index'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
const posts = ref<any[]>([])
const currentTab = ref('latest')
async function loadPosts() {
try {
const params: any = { pageSize: 20 }
if (currentTab.value === 'hot') params.sort = 'likes'
const res: any = await api.community.posts(params)
posts.value = res.items || []
} catch (e) {
console.error(e)
}
}
watch(currentTab, loadPosts)
onMounted(loadPosts)
const goDetail = (id: number) => uni.navigateTo({ url: `/pages/post-detail/index?id=${id}` })
const goCreate = () => {
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
uni.navigateTo({ url: '/pages/create-post/index' })
}
</script>
<style scoped>
.community { }
.tabs { display: flex; background: #fff; padding: 20rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.tab { font-size: 28rpx; padding: 8rpx 24rpx; margin-right: 16rpx; color: #666; border-radius: 6rpx; }
.tab.active { color: #4f8cff; font-weight: bold; background: #eef3ff; }
.post-list { padding: 20rpx 30rpx; }
.post-card {
background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
}
.post-header { display: flex; justify-content: space-between; margin-bottom: 12rpx; }
.author { font-size: 24rpx; color: #4f8cff; }
.time { font-size: 22rpx; color: #ccc; }
.post-title { font-size: 30rpx; font-weight: 500; display: block; margin-bottom: 16rpx; }
.post-footer { display: flex; gap: 24rpx; }
.stat { font-size: 22rpx; color: #999; }
.fab {
position: fixed; right: 40rpx; bottom: 100rpx;
width: 100rpx; height: 100rpx; background: #4f8cff; border-radius: 50%;
display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 20rpx rgba(79,140,255,0.4);
}
.fab-icon { font-size: 48rpx; color: #fff; }
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<view class="course-detail">
<view class="header">
<image class="cover" :src="course.cover || '/static/default-course.png'" mode="aspectFill" />
<view class="overlay">
<text class="title">{{ course.title }}</text>
<text class="meta">{{ course._count?.lessons || 0 }} 课时 · {{ course.chapters?.length || 0 }} 章节</text>
<text class="price" v-if="course.isFree">免费</text>
<text class="price paid" v-else>¥{{ course.price }}</text>
</view>
</view>
<view class="body">
<view class="section">
<text class="section-title">课程简介</text>
<text class="desc">{{ course.description }}</text>
</view>
<view class="section">
<text class="section-title">课程目录</text>
<view class="chapter-list">
<view class="chapter" v-for="ch in course.chapters" :key="ch.id">
<text class="chapter-title">{{ ch.title }}</text>
<view class="lesson-list">
<view class="lesson" v-for="les in ch.lessons" :key="les.id" @tap="goLesson(les.id)">
<text class="lesson-title">{{ les.title }}</text>
<text class="lesson-status" v-if="les.completed"></text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
const course = ref<any>({})
onMounted(async () => {
const id = Number((uni as any).getCurrentInstance()?.router?.params?.id || '')
try {
course.value = await api.courses.detail(id)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
})
const goLesson = (lessonId: number) => {
uni.navigateTo({ url: `/pages/lesson/index?id=${lessonId}&courseId=${course.value.id}` })
}
</script>
<style scoped>
.course-detail { }
.header { position: relative; }
.cover { width: 100%; height: 400rpx; }
.overlay {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
padding: 40rpx 30rpx;
}
.title { font-size: 36rpx; font-weight: bold; color: #fff; display: block; }
.meta { font-size: 24rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; display: block; }
.price { font-size: 28rpx; color: #4f8cff; margin-top: 12rpx; display: inline-block; }
.price.paid { color: #ff6b6b; }
.body { padding: 30rpx; }
.section { margin-bottom: 40rpx; }
.section-title { font-size: 32rpx; font-weight: bold; margin-bottom: 20rpx; display: block; }
.desc { font-size: 26rpx; color: #666; line-height: 1.6; }
.chapter { margin-bottom: 20rpx; background: #fff; border-radius: 12rpx; overflow: hidden; }
.chapter-title { font-size: 28rpx; font-weight: 500; padding: 20rpx 24rpx; background: #f8f9ff; display: block; }
.lesson { display: flex; align-items: center; justify-content: space-between; padding: 18rpx 24rpx; border-bottom: 1rpx solid #f5f5f5; }
.lesson:last-child { border-bottom: none; }
.lesson-title { font-size: 26rpx; color: #333; }
.lesson-status { font-size: 24rpx; }
</style>
+98
View File
@@ -0,0 +1,98 @@
<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: #fff; padding: 20rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.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: #f5f5f5; color: #666;
}
.category-tag.active { background: #4f8cff; color: #fff; }
.course-list { padding: 20rpx 30rpx; }
.course-card {
display: flex; background: #fff; 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: #999; 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: #4f8cff; }
.price.paid { color: #ff6b6b; }
.count { font-size: 22rpx; color: #ccc; }
.loading-more { text-align: center; padding: 30rpx; color: #999; font-size: 26rpx; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<view class="create-post">
<view class="form">
<input class="input" v-model="title" placeholder="标题" />
<textarea class="textarea" v-model="content" placeholder="内容..." />
<input class="input" v-model="tags" placeholder="标签(逗号分隔)" />
<button class="btn-primary" @tap="submit" :loading="loading">发布</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '../../api/index'
const title = ref('')
const content = ref('')
const tags = ref('')
const loading = ref(false)
async function submit() {
if (!title.value || !content.value) {
uni.showToast({ title: '请填写标题和内容', icon: 'none' })
return
}
loading.value = true
try {
await api.community.createPost({
title: title.value,
content: content.value,
tags: tags.value,
})
uni.showToast({ title: '发布成功', icon: 'success' })
uni.navigateBack()
} catch (e: any) {
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style scoped>
.create-post { padding: 30rpx; }
.form { }
.input {
background: #fff; border-radius: 12rpx; padding: 24rpx 30rpx; font-size: 28rpx;
margin-bottom: 24rpx; border: 1rpx solid #e8e8e8;
}
.textarea {
background: #fff; border-radius: 12rpx; padding: 24rpx 30rpx; font-size: 28rpx;
margin-bottom: 24rpx; border: 1rpx solid #e8e8e8; min-height: 300rpx;
}
.btn-primary {
background: #4f8cff; color: #fff; border-radius: 12rpx; padding: 24rpx;
font-size: 30rpx; text-align: center; margin-top: 30rpx; border: none;
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="favorites">
<view class="tabs">
<text class="tab" :class="{ active: tab === 'prompts' }" @tap="tab = 'prompts'">提示词</text>
<text class="tab" :class="{ active: tab === 'posts' }" @tap="tab = 'posts'">帖子</text>
</view>
<view class="list">
<view class="item" v-for="item in list" :key="item.id" @tap="goDetail(item)">
<text class="item-title">{{ item.prompt?.title || item.post?.title }}</text>
</view>
</view>
<view class="empty" v-if="list.length === 0 && loaded">
<text>暂无收藏</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { api } from '../../api/index'
const tab = ref('prompts')
const list = ref<any[]>([])
const loaded = ref(false)
async function loadData() {
loaded.value = false
try {
if (tab.value === 'prompts') {
const res: any = await api.prompts.favorites()
list.value = res.items || []
} else {
list.value = []
}
} catch (e) {
console.error(e)
} finally {
loaded.value = true
}
}
watch(tab, loadData)
onMounted(loadData)
function goDetail(item: any) {
if (tab.value === 'prompts' && item.prompt) {
uni.navigateTo({ url: `/pages/prompt-detail/index?id=${item.prompt.id}` })
}
}
</script>
<style scoped>
.favorites { }
.tabs { display: flex; background: #fff; padding: 20rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.tab { font-size: 28rpx; padding: 8rpx 24rpx; margin-right: 16rpx; color: #666; }
.tab.active { color: #4f8cff; font-weight: bold; border-bottom: 3rpx solid #4f8cff; }
.list { padding: 20rpx 30rpx; }
.item { background: #fff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx; }
.item-title { font-size: 28rpx; }
.empty { text-align: center; padding: 100rpx; font-size: 28rpx; color: #999; }
</style>
+145
View File
@@ -0,0 +1,145 @@
<template>
<view class="home">
<view class="header-banner">
<text class="slogan">让每个人都能用好 AI</text>
<text class="sub-slogan">宇之然 AI 学习与实践平台</text>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">推荐课程</text>
<text class="section-more" @tap="goCourses">更多 ></text>
</view>
<scroll-view class="course-scroll" scroll-x>
<view class="course-list">
<view class="course-card" v-for="item in courses" :key="item.id" @tap="goCourse(item.id)">
<image class="course-cover" :src="item.cover || '/static/default-course.png'" mode="aspectFill" />
<view class="course-info">
<text class="course-title">{{ item.title }}</text>
<text class="course-price" v-if="item.isFree">免费</text>
<text class="course-price price-pay" v-else>¥{{ item.price }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">热门提示词</text>
<text class="section-more" @tap="goPrompts">更多 ></text>
</view>
<view class="prompt-list">
<view class="prompt-item" v-for="item in prompts" :key="item.id" @tap="goPrompt(item.id)">
<text class="prompt-title">{{ item.title }}</text>
<text class="prompt-desc">{{ item.description }}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">热门圈子</text>
<text class="section-more" @tap="goCircles">更多 ></text>
</view>
<view class="circle-list">
<view class="circle-item" v-for="item in circles" :key="item.id" @tap="goCircle(item.id)">
<text class="circle-name">#{{ item.name }}</text>
<text class="circle-count">{{ item._count?.members || 0 }} 人加入</text>
</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">社区动态</text>
<text class="section-more" @tap="goCommunity">更多 ></text>
</view>
<view class="post-list">
<view class="post-item" v-for="item in posts" :key="item.id" @tap="goPost(item.id)">
<text class="post-title">{{ item.title }}</text>
<text class="post-meta">{{ item.user?.nickname }} · {{ item.likeCount }} </text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
const courses = ref<any[]>([])
const prompts = ref<any[]>([])
const circles = ref<any[]>([])
const posts = ref<any[]>([])
onMounted(async () => {
try {
const [c, p, cr, po] = await Promise.all([
api.courses.list({ pageSize: 5 }),
api.prompts.list({ pageSize: 5 }),
api.circles.list({ pageSize: 5 }),
api.community.posts({ pageSize: 5 }),
])
courses.value = (c as any)?.items || []
prompts.value = (p as any)?.items || []
circles.value = (cr as any)?.items || []
posts.value = (po as any)?.items || []
} catch (e) {
console.error('Home load error:', e)
}
})
const goCourses = () => uni.switchTab({ url: '/pages/courses/index' })
const goPrompts = () => uni.navigateTo({ url: '/pages/prompts/index' })
const goCircles = () => uni.navigateTo({ url: '/pages/circles/index' })
const goCommunity = () => uni.switchTab({ url: '/pages/community/index' })
const goCourse = (id: number) => uni.navigateTo({ url: `/pages/course-detail/index?id=${id}` })
const goPrompt = (id: number) => uni.navigateTo({ url: `/pages/prompt-detail/index?id=${id}` })
const goCircle = (id: number) => uni.navigateTo({ url: `/pages/circle-detail/index?id=${id}` })
const goPost = (id: number) => uni.navigateTo({ url: `/pages/post-detail/index?id=${id}` })
</script>
<style scoped>
.home { padding-bottom: 20rpx; }
.header-banner {
background: linear-gradient(135deg, #4f8cff, #6c5ce7);
padding: 60rpx 40rpx;
text-align: center;
}
.slogan { font-size: 44rpx; font-weight: bold; color: #fff; display: block; }
.sub-slogan { font-size: 28rpx; color: rgba(255,255,255,0.8); margin-top: 16rpx; display: block; }
.section { padding: 30rpx 30rpx 0; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.section-title { font-size: 32rpx; font-weight: bold; }
.section-more { font-size: 26rpx; color: #4f8cff; }
.course-scroll { white-space: nowrap; }
.course-list { display: flex; gap: 20rpx; }
.course-card {
width: 280rpx; background: #fff; border-radius: 16rpx; overflow: hidden; display: inline-block;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.course-cover { width: 280rpx; height: 160rpx; }
.course-info { padding: 16rpx; }
.course-title { font-size: 26rpx; font-weight: 500; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.course-price { font-size: 24rpx; color: #4f8cff; margin-top: 8rpx; display: inline-block; }
.price-pay { color: #ff6b6b; }
.prompt-list { background: #fff; border-radius: 16rpx; overflow: hidden; }
.prompt-item { padding: 24rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.prompt-item:last-child { border-bottom: none; }
.prompt-title { font-size: 28rpx; font-weight: 500; display: block; }
.prompt-desc { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.circle-list { display: flex; flex-wrap: wrap; gap: 16rpx; }
.circle-item {
background: #fff; border-radius: 12rpx; padding: 20rpx; flex: 1; min-width: 200rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
}
.circle-name { font-size: 28rpx; font-weight: 500; color: #4f8cff; display: block; }
.circle-count { font-size: 22rpx; color: #999; margin-top: 8rpx; display: block; }
.post-list { background: #fff; border-radius: 16rpx; overflow: hidden; }
.post-item { padding: 24rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.post-item:last-child { border-bottom: none; }
.post-title { font-size: 28rpx; font-weight: 500; display: block; }
.post-meta { font-size: 22rpx; color: #999; margin-top: 8rpx; display: block; }
</style>
@@ -0,0 +1,68 @@
<template>
<view class="learning-progress">
<view class="stats" v-if="stats">
<view class="stat-item">
<text class="stat-value">{{ stats.totalCourses || 0 }}</text>
<text class="stat-label">总课程</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ stats.completedLessons || 0 }}</text>
<text class="stat-label">已完成课时</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ stats.progress || 0 }}%</text>
<text class="stat-label">完成度</text>
</view>
</view>
<view class="course-list">
<view class="course-item" v-for="item in records" :key="item.courseId">
<text class="course-name">{{ item.course?.title }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: item.progress + '%' }"></view>
</view>
<text class="progress-text">{{ item.progress }}%</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
const stats = ref<any>(null)
const records = ref<any[]>([])
onMounted(async () => {
try {
const [s, r]: any = await Promise.all([
api.dashboard.stats(),
api.courses.myLearning(),
])
stats.value = s
records.value = r.items || r || []
} catch (e) {
console.error(e)
}
})
</script>
<style scoped>
.learning-progress { padding: 30rpx; }
.stats { display: flex; gap: 20rpx; margin-bottom: 40rpx; }
.stat-item {
flex: 1; background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.stat-value { font-size: 40rpx; font-weight: bold; color: #4f8cff; display: block; }
.stat-label { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
.course-list { }
.course-item {
display: flex; align-items: center; background: #fff; border-radius: 12rpx;
padding: 24rpx; margin-bottom: 16rpx;
}
.course-name { width: 200rpx; font-size: 26rpx; font-weight: 500; }
.progress-bar { flex: 1; height: 16rpx; background: #f0f0f0; border-radius: 8rpx; margin: 0 20rpx; overflow: hidden; }
.progress-fill { height: 100%; background: #4f8cff; border-radius: 8rpx; }
.progress-text { font-size: 24rpx; color: #4f8cff; width: 80rpx; text-align: right; }
</style>
+60
View File
@@ -0,0 +1,60 @@
<template>
<view class="lesson">
<view class="content-area">
<text class="lesson-title">{{ lesson.title }}</text>
<view class="lesson-content">
<text>{{ lesson.content }}</text>
</view>
<button class="btn-complete" @tap="markComplete" v-if="!lesson.completed">标记完成</button>
<text class="completed-text" v-else> 已完成</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
const lesson = ref<any>({})
onMounted(async () => {
const instance = (uni as any).getCurrentInstance()
const lessonId = Number(instance?.router?.params?.id || '')
const courseId = Number(instance?.router?.params?.courseId || '')
try {
const course: any = await api.courses.detail(courseId)
for (const ch of course.chapters || []) {
for (const les of ch.lessons || []) {
if (les.id === lessonId) {
lesson.value = { ...les, courseId }
return
}
}
}
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
})
async function markComplete() {
try {
await api.courses.updateProgress(lesson.value.courseId, lesson.value.id, { completed: true })
lesson.value.completed = true
uni.showToast({ title: '已完成', icon: 'success' })
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
</script>
<style scoped>
.lesson { padding: 30rpx; }
.content-area { background: #fff; border-radius: 16rpx; padding: 40rpx; }
.lesson-title { font-size: 36rpx; font-weight: bold; display: block; margin-bottom: 30rpx; }
.lesson-content { font-size: 28rpx; color: #333; line-height: 1.8; }
.btn-complete {
background: #4f8cff; color: #fff; border-radius: 12rpx; padding: 24rpx;
font-size: 28rpx; text-align: center; margin-top: 40rpx; border: none;
}
.completed-text { display: block; text-align: center; font-size: 28rpx; color: #52c41a; margin-top: 40rpx; }
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<view class="login">
<view class="logo-area">
<text class="logo-text">宇之然 AI</text>
<text class="logo-sub">让每个人都能用好 AI</text>
</view>
<view class="form">
<input class="input" v-model="account" placeholder="手机号 / 邮箱" type="text" />
<input class="input" v-model="password" placeholder="密码" type="password" password />
<button class="btn-primary" @tap="handleLogin" :loading="loading">登录</button>
<view class="links">
<text class="link" @tap="goRegister">没有账号去注册</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
const account = ref('')
const password = ref('')
const loading = ref(false)
async function handleLogin() {
if (!account.value || !password.value) {
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
loading.value = true
try {
await userStore.login(account.value, password.value)
uni.showToast({ title: '登录成功', icon: 'success' })
uni.switchTab({ url: '/pages/index/index' })
} catch (e: any) {
uni.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
loading.value = false
}
}
const goRegister = () => uni.navigateTo({ url: '/pages/register/index' })
</script>
<style scoped>
.login { padding: 100rpx 40rpx; }
.logo-area { text-align: center; margin-bottom: 80rpx; }
.logo-text { font-size: 52rpx; font-weight: bold; color: #4f8cff; display: block; }
.logo-sub { font-size: 28rpx; color: #999; margin-top: 16rpx; display: block; }
.form { }
.input {
background: #fff; border-radius: 12rpx; padding: 24rpx 30rpx; font-size: 28rpx;
margin-bottom: 24rpx; border: 1rpx solid #e8e8e8;
}
.btn-primary {
background: #4f8cff; color: #fff; border-radius: 12rpx; padding: 24rpx;
font-size: 30rpx; text-align: center; margin-top: 30rpx; border: none;
}
.links { text-align: center; margin-top: 40rpx; }
.link { font-size: 26rpx; color: #4f8cff; }
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="membership">
<view class="current-plan" v-if="subscription">
<text class="plan-label">当前套餐</text>
<text class="plan-name">{{ subscription.plan === 'MONTHLY' ? '月卡会员' : '年卡会员' }}</text>
<text class="plan-expire">到期时间{{ subscription.endDate?.slice(0, 10) }}</text>
</view>
<view class="plans">
<view class="plan-card" v-for="plan in plans" :key="plan.type" @tap="createOrder(plan)">
<text class="plan-name">{{ plan.name }}</text>
<text class="plan-price">¥{{ plan.price }}</text>
<text class="plan-desc">{{ plan.desc }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
const subscription = ref<any>(null)
const plans = ref([
{ type: 'MONTHLY', name: '月卡会员', price: 29.9, desc: '30天无限使用AI沙箱' },
{ type: 'YEARLY', name: '年卡会员', price: 299, desc: '365天无限使用,送2个月' },
])
onMounted(async () => {
try {
subscription.value = await api.subscriptions.current()
} catch (e) { /* ignore */ }
})
async function createOrder(plan: any) {
try {
const order: any = await api.orders.create({ planType: plan.type, amount: plan.price })
uni.showToast({ title: '订单创建成功', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
}
}
</script>
<style scoped>
.membership { padding: 30rpx; }
.current-plan {
background: linear-gradient(135deg, #f5a623, #f7c948);
border-radius: 20rpx; padding: 40rpx; text-align: center; margin-bottom: 40rpx;
}
.plan-label { font-size: 24rpx; color: rgba(255,255,255,0.8); display: block; }
.plan-name { font-size: 40rpx; font-weight: bold; color: #fff; margin: 16rpx 0; display: block; }
.plan-expire { font-size: 24rpx; color: rgba(255,255,255,0.8); display: block; }
.plans { display: flex; gap: 24rpx; }
.plan-card {
flex: 1; background: #fff; border-radius: 16rpx; padding: 40rpx 30rpx; text-align: center;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
}
.plan-name { font-size: 30rpx; font-weight: 500; display: block; }
.plan-price { font-size: 44rpx; font-weight: bold; color: #ff6b6b; margin: 20rpx 0; display: block; }
.plan-desc { font-size: 24rpx; color: #999; }
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<view class="post-detail">
<view class="post" v-if="post.id">
<text class="title">{{ post.title }}</text>
<view class="meta">
<text class="author">{{ post.user?.nickname || '匿名' }}</text>
<text class="time">{{ post.createdAt?.slice(0, 10) }}</text>
</view>
<view class="content">
<text>{{ post.content }}</text>
</view>
<view class="actions">
<text class="action" @tap="toggleLike">{{ liked ? '' : '🤍' }} {{ post.likeCount }}</text>
</view>
</view>
<view class="comments-section">
<text class="section-title">评论 ({{ post.comments?.length || 0 }})</text>
<view class="comment-list">
<view class="comment" v-for="c in post.comments" :key="c.id">
<text class="comment-author">{{ c.user?.nickname || '匿名' }}</text>
<text class="comment-content">{{ c.content }}</text>
</view>
</view>
<view class="comment-input-area">
<input class="comment-input" v-model="commentText" placeholder="写评论..." />
<button class="btn-submit" @tap="submitComment">发送</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
const post = ref<any>({})
const liked = ref(false)
const commentText = ref('')
onMounted(async () => {
const id = Number((uni as any).getCurrentInstance()?.router?.params?.id || '')
try {
const [detail, likeStatus] = await Promise.all([
api.community.postDetail(id),
userStore.isLoggedIn ? api.community.checkLike(id) : Promise.resolve({ liked: false }),
])
post.value = detail
liked.value = (likeStatus as any)?.liked || false
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
})
async function toggleLike() {
if (!userStore.isLoggedIn) { uni.navigateTo({ url: '/pages/login/index' }); return }
try {
await api.community.toggleLike(post.value.id)
liked.value = !liked.value
post.value.likeCount += liked.value ? 1 : -1
} catch (e) { /* ignore */ }
}
async function submitComment() {
if (!userStore.isLoggedIn) { uni.navigateTo({ url: '/pages/login/index' }); return }
if (!commentText.value) { uni.showToast({ title: '请输入评论内容', icon: 'none' }); return }
try {
await api.community.addComment(post.value.id, commentText.value)
commentText.value = ''
uni.showToast({ title: '评论成功', icon: 'success' })
const detail: any = await api.community.postDetail(post.value.id)
post.value = detail
} catch (e: any) {
uni.showToast({ title: e.message || '评论失败', icon: 'none' })
}
}
</script>
<style scoped>
.post-detail { padding: 30rpx; }
.post { background: #fff; border-radius: 16rpx; padding: 30rpx; }
.title { font-size: 36rpx; font-weight: bold; display: block; margin-bottom: 16rpx; }
.meta { display: flex; justify-content: space-between; margin-bottom: 24rpx; }
.author { font-size: 24rpx; color: #4f8cff; }
.time { font-size: 22rpx; color: #ccc; }
.content { font-size: 28rpx; color: #333; line-height: 1.8; margin-bottom: 24rpx; }
.actions { }
.action { font-size: 26rpx; color: #666; }
.comments-section { margin-top: 30rpx; }
.section-title { font-size: 30rpx; font-weight: bold; display: block; margin-bottom: 20rpx; }
.comment-list { background: #fff; border-radius: 16rpx; padding: 20rpx; }
.comment { padding: 16rpx 0; border-bottom: 1rpx solid #f5f5f5; }
.comment:last-child { border-bottom: none; }
.comment-author { font-size: 24rpx; color: #4f8cff; display: block; margin-bottom: 8rpx; }
.comment-content { font-size: 26rpx; color: #333; }
.comment-input-area { display: flex; align-items: center; gap: 16rpx; margin-top: 24rpx; }
.comment-input { flex: 1; background: #fff; border-radius: 12rpx; padding: 20rpx 24rpx; font-size: 26rpx; border: 1rpx solid #e8e8e8; }
.btn-submit { background: #4f8cff; color: #fff; border-radius: 10rpx; padding: 16rpx 32rpx; font-size: 26rpx; border: none; }
</style>
+108
View File
@@ -0,0 +1,108 @@
<template>
<view class="profile">
<view class="user-card" v-if="userStore.isLoggedIn">
<view class="avatar">
<text class="avatar-text">{{ (userStore.profile?.nickname || '?')[0] }}</text>
</view>
<view class="user-info">
<text class="nickname">{{ userStore.profile?.nickname || '用户' }}</text>
<text class="member" v-if="userStore.profile?.memberPlan !== 'FREE'">{{ userStore.profile?.memberPlan }} 会员</text>
<text class="member free" v-else>免费用户</text>
</view>
</view>
<view class="user-card" v-else>
<view class="login-prompt" @tap="goLogin">
<text class="login-text">点击登录 / 注册</text>
</view>
</view>
<view class="menu-list">
<view class="menu-item" @tap="goLearning">
<text class="menu-icon">📚</text>
<text class="menu-text">学习进度</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @tap="goFavorites">
<text class="menu-icon"></text>
<text class="menu-text">我的收藏</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @tap="goMembership">
<text class="menu-icon">💎</text>
<text class="menu-text">会员中心</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @tap="goDashboard">
<text class="menu-icon">📊</text>
<text class="menu-text">数据看板</text>
<text class="menu-arrow">></text>
</view>
</view>
<button class="btn-logout" v-if="userStore.isLoggedIn" @tap="handleLogout">退出登录</button>
</view>
</template>
<script setup lang="ts">
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
onShow(() => {
if (userStore.isLoggedIn && !userStore.profile) {
userStore.fetchProfile()
}
})
const goLogin = () => uni.navigateTo({ url: '/pages/login/index' })
const goLearning = () => uni.navigateTo({ url: '/pages/learning-progress/index' })
const goFavorites = () => uni.navigateTo({ url: '/pages/favorites/index' })
const goMembership = () => uni.navigateTo({ url: '/pages/membership/index' })
const goDashboard = () => {
if (!userStore.isLoggedIn) { goLogin(); return }
uni.navigateTo({ url: '/pages/profile/index?tab=dashboard' })
}
function handleLogout() {
uni.showModal({
title: '提示',
content: '确定退出登录?',
success: (res) => {
if (res.confirm) userStore.logout()
},
})
}
</script>
<style scoped>
.profile { padding: 30rpx; }
.user-card {
background: linear-gradient(135deg, #4f8cff, #6c5ce7);
border-radius: 20rpx; padding: 40rpx; display: flex; align-items: center; margin-bottom: 30rpx;
}
.avatar {
width: 100rpx; height: 100rpx; border-radius: 50%; background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center; margin-right: 24rpx;
}
.avatar-text { font-size: 40rpx; color: #fff; font-weight: bold; }
.user-info { flex: 1; }
.nickname { font-size: 32rpx; font-weight: bold; color: #fff; display: block; }
.member { font-size: 22rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; display: inline-block; background: rgba(255,255,255,0.15); padding: 4rpx 16rpx; border-radius: 20rpx; }
.member.free { background: transparent; }
.login-prompt { padding: 40rpx; text-align: center; }
.login-text { font-size: 32rpx; color: #fff; }
.menu-list { background: #fff; border-radius: 16rpx; overflow: hidden; }
.menu-item {
display: flex; align-items: center; padding: 28rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.menu-item:last-child { border-bottom: none; }
.menu-icon { font-size: 32rpx; margin-right: 20rpx; }
.menu-text { flex: 1; font-size: 28rpx; }
.menu-arrow { font-size: 28rpx; color: #ccc; }
.btn-logout {
background: #fff; color: #ff4d4f; border-radius: 12rpx; padding: 24rpx;
font-size: 28rpx; text-align: center; margin-top: 40rpx; border: 1rpx solid #ff4d4f;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<template>
<view class="prompt-detail">
<view class="card" v-if="prompt.id">
<text class="title">{{ prompt.title }}</text>
<text class="desc">{{ prompt.description }}</text>
<view class="content-box">
<text class="content-label">提示词内容</text>
<text class="content-text">{{ prompt.content }}</text>
</view>
<view class="actions">
<button class="btn-copy" @tap="copyContent">复制内容</button>
<button class="btn-like" @tap="toggleFavorite">{{ favorited ? ' 已收藏' : '🤍 收藏' }}</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../../api/index'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
const prompt = ref<any>({})
const favorited = ref(false)
onMounted(async () => {
const id = Number((uni as any).getCurrentInstance()?.router?.params?.id || '')
try {
prompt.value = await api.prompts.detail(id)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
})
function copyContent() {
uni.setClipboardData({
data: prompt.value.content,
success: () => uni.showToast({ title: '已复制', icon: 'success' }),
})
}
async function toggleFavorite() {
if (!userStore.isLoggedIn) { uni.navigateTo({ url: '/pages/login/index' }); return }
try {
await api.prompts.toggleFavorite(prompt.value.id)
favorited.value = !favorited.value
uni.showToast({ title: favorited.value ? '已收藏' : '已取消收藏', icon: 'success' })
} catch (e) { /* ignore */ }
}
</script>
<style scoped>
.prompt-detail { padding: 30rpx; }
.card { background: #fff; border-radius: 16rpx; padding: 30rpx; }
.title { font-size: 36rpx; font-weight: bold; display: block; margin-bottom: 12rpx; }
.desc { font-size: 26rpx; color: #666; display: block; margin-bottom: 30rpx; }
.content-box { background: #f8f9ff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 30rpx; }
.content-label { font-size: 24rpx; color: #4f8cff; display: block; margin-bottom: 12rpx; }
.content-text { font-size: 26rpx; color: #333; line-height: 1.7; }
.actions { display: flex; gap: 20rpx; }
.btn-copy { flex: 1; background: #4f8cff; color: #fff; border-radius: 10rpx; padding: 20rpx; font-size: 26rpx; text-align: center; border: none; }
.btn-like { flex: 1; background: #fff; color: #ff6b6b; border-radius: 10rpx; padding: 20rpx; font-size: 26rpx; text-align: center; border: 1rpx solid #ff6b6b; }
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<view class="prompts">
<view class="filter-bar">
<scroll-view scroll-x class="category-scroll">
<view class="category-list">
<text class="tag" :class="{ active: currentCat === 0 }" @tap="currentCat = 0">全部</text>
<text class="tag" v-for="cat in categories" :key="cat.id" :class="{ active: currentCat === cat.id }"
@tap="currentCat = cat.id">{{ cat.name }}</text>
</view>
</scroll-view>
</view>
<view class="prompt-list">
<view class="prompt-card" v-for="item in list" :key="item.id" @tap="goDetail(item.id)">
<text class="title">{{ item.title }}</text>
<text class="desc">{{ item.description }}</text>
<view class="meta">
<text class="tag-label">{{ item.tags }}</text>
<text class="views">👁 {{ item.viewCount }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { api } from '../../api/index'
const list = ref<any[]>([])
const categories = ref<any[]>([])
const currentCat = ref(0)
async function loadData() {
try {
const params: any = { pageSize: 20 }
if (currentCat.value) params.categoryId = currentCat.value
const res: any = await api.prompts.list(params)
list.value = res.items || []
} catch (e) { console.error(e) }
}
watch(currentCat, loadData)
onMounted(async () => {
try { categories.value = (await api.categories.list()) as any[] || [] } catch (e) { /* ignore */ }
loadData()
})
const goDetail = (id: number) => uni.navigateTo({ url: `/pages/prompt-detail/index?id=${id}` })
</script>
<style scoped>
.prompts { }
.filter-bar { background: #fff; padding: 20rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.category-scroll { white-space: nowrap; }
.category-list { display: flex; gap: 16rpx; }
.tag {
display: inline-block; padding: 12rpx 28rpx; border-radius: 30rpx; font-size: 26rpx;
background: #f5f5f5; color: #666;
}
.tag.active { background: #4f8cff; color: #fff; }
.prompt-list { padding: 20rpx 30rpx; }
.prompt-card {
background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.title { font-size: 28rpx; font-weight: 500; display: block; }
.desc { font-size: 24rpx; color: #999; margin-top: 12rpx; display: block; }
.meta { display: flex; justify-content: space-between; align-items: center; margin-top: 16rpx; }
.tag-label { font-size: 22rpx; color: #4f8cff; background: #eef3ff; padding: 4rpx 16rpx; border-radius: 20rpx; }
.views { font-size: 22rpx; color: #ccc; }
</style>
+64
View File
@@ -0,0 +1,64 @@
<template>
<view class="register">
<view class="form">
<input class="input" v-model="phone" placeholder="手机号(选填)" type="text" />
<input class="input" v-model="email" placeholder="邮箱(选填)" type="text" />
<input class="input" v-model="nickname" placeholder="昵称" type="text" />
<input class="input" v-model="password" placeholder="密码" type="password" password />
<button class="btn-primary" @tap="handleRegister" :loading="loading">注册</button>
<view class="links">
<text class="link" @tap="goLogin">已有账号去登录</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
const phone = ref('')
const email = ref('')
const nickname = ref('')
const password = ref('')
const loading = ref(false)
async function handleRegister() {
if (!password.value || (!phone.value && !email.value)) {
uni.showToast({ title: '请填写手机号/邮箱和密码', icon: 'none' })
return
}
loading.value = true
try {
await userStore.register({
phone: phone.value || undefined,
email: email.value || undefined,
password: password.value,
nickname: nickname.value || undefined,
})
uni.showToast({ title: '注册成功', icon: 'success' })
uni.switchTab({ url: '/pages/index/index' })
} catch (e: any) {
uni.showToast({ title: e.message || '注册失败', icon: 'none' })
} finally {
loading.value = false
}
}
const goLogin = () => uni.navigateTo({ url: '/pages/login/index' })
</script>
<style scoped>
.register { padding: 100rpx 40rpx; }
.input {
background: #fff; border-radius: 12rpx; padding: 24rpx 30rpx; font-size: 28rpx;
margin-bottom: 24rpx; border: 1rpx solid #e8e8e8;
}
.btn-primary {
background: #4f8cff; color: #fff; border-radius: 12rpx; padding: 24rpx;
font-size: 30rpx; text-align: center; margin-top: 30rpx; border: none;
}
.links { text-align: center; margin-top: 40rpx; }
.link { font-size: 26rpx; color: #4f8cff; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<view class="sandbox">
<view class="model-selector">
<text class="label">选择模型</text>
<picker :value="modelIndex" :range="models" range-key="name" @change="onModelChange">
<text class="picker-value">{{ models[modelIndex]?.name || '选择模型' }}</text>
</picker>
</view>
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollId">
<view class="message" v-for="(msg, i) in messages" :key="i" :class="{ user: msg.role === 'user', ai: msg.role === 'assistant' }">
<text class="role">{{ msg.role === 'user' ? '我' : 'AI' }}</text>
<text class="content">{{ msg.content }}</text>
</view>
<view id="bottom"></view>
</scroll-view>
<view class="input-area">
<input class="input" v-model="inputText" placeholder="输入消息..." @confirm="sendMessage" />
<button class="btn-send" @tap="sendMessage" :loading="sending">发送</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { api } from '../../api/index'
import { useUserStore } from '../../store/user'
const userStore = useUserStore()
const models = ref<any[]>([])
const modelIndex = ref(0)
const messages = ref<{ role: string; content: string }[]>([])
const inputText = ref('')
const sending = ref(false)
const scrollId = ref('')
onMounted(async () => {
try {
models.value = (await api.models.list({ featured: true })) as any[] || []
} catch (e) { /* ignore */ }
loadHistory()
})
async function loadHistory() {
try {
const history: any = await api.sandbox.history({ pageSize: 50 })
const sessions = history.items || []
if (sessions.length > 0) {
const last = sessions[sessions.length - 1]
messages.value = (last.messages || [])
}
} catch (e) { /* ignore */ }
}
async function sendMessage() {
if (!inputText.value || sending.value) return
if (!userStore.isLoggedIn) { uni.navigateTo({ url: '/pages/login/index' }); return }
const userMsg = inputText.value
messages.value.push({ role: 'user', content: userMsg })
inputText.value = ''
sending.value = true
nextTick(() => { scrollId.value = 'bottom' })
try {
const res: any = await api.sandbox.chat({
model: models.value[modelIndex.value]?.name || 'meituan/longcat-flash-lite',
message: userMsg,
})
messages.value.push({ role: 'assistant', content: res.reply || res.content || '无回复' })
nextTick(() => { scrollId.value = 'bottom' })
} catch (e: any) {
messages.value.push({ role: 'assistant', content: '请求失败,请重试' })
} finally {
sending.value = false
}
}
function onModelChange(e: any) {
modelIndex.value = e.detail.value
}
</script>
<style scoped>
.sandbox { display: flex; flex-direction: column; height: 100vh; }
.model-selector {
display: flex; align-items: center; padding: 16rpx 30rpx; background: #fff;
border-bottom: 1rpx solid #f0f0f0;
}
.label { font-size: 26rpx; color: #666; }
.picker-value { font-size: 26rpx; color: #4f8cff; margin-left: 8rpx; }
.chat-area { flex: 1; padding: 20rpx 30rpx; overflow-y: auto; }
.message { margin-bottom: 20rpx; max-width: 80%; }
.message.user { align-self: flex-end; margin-left: auto; }
.message.ai { align-self: flex-start; }
.role { font-size: 22rpx; color: #999; display: block; margin-bottom: 6rpx; }
.content {
display: inline-block; padding: 16rpx 20rpx; border-radius: 12rpx; font-size: 28rpx; line-height: 1.6;
}
.message.user .content { background: #4f8cff; color: #fff; border-radius: 12rpx 0 12rpx 12rpx; }
.message.ai .content { background: #fff; color: #333; border-radius: 0 12rpx 12rpx 12rpx; }
.input-area {
display: flex; align-items: center; padding: 16rpx 30rpx; background: #fff;
border-top: 1rpx solid #f0f0f0;
}
.input { flex: 1; background: #f5f5f5; border-radius: 12rpx; padding: 16rpx 24rpx; font-size: 28rpx; }
.btn-send {
background: #4f8cff; color: #fff; border-radius: 10rpx; padding: 14rpx 28rpx;
font-size: 26rpx; margin-left: 16rpx; border: none;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<template>
<view class="search">
<view class="search-bar">
<input class="input" v-model="keyword" placeholder="搜索课程、提示词、文章..." @confirm="doSearch" confirm-type="search" />
<button class="btn-search" @tap="doSearch">搜索</button>
</view>
<view class="tabs" v-if="results.length > 0">
<text class="tab" :class="{ active: activeTab === 'courses' }" @tap="activeTab = 'courses'">课程</text>
<text class="tab" :class="{ active: activeTab === 'prompts' }" @tap="activeTab = 'prompts'">提示词</text>
<text class="tab" :class="{ active: activeTab === 'contents' }" @tap="activeTab = 'contents'">文章</text>
<text class="tab" :class="{ active: activeTab === 'tools' }" @tap="activeTab = 'tools'">工具</text>
</view>
<view class="result-list" v-if="filteredResults.length > 0">
<view class="result-item" v-for="item in filteredResults" :key="item.id" @tap="goDetail(item)">
<text class="result-title">{{ item.title }}</text>
<text class="result-desc">{{ item.description || item.summary || item.content?.slice(0, 80) }}</text>
</view>
</view>
<view class="empty" v-else-if="searched">
<text>未找到相关结果</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { api } from '../../api/index'
const keyword = ref('')
const results = ref<any[]>([])
const activeTab = ref('courses')
const searched = ref(false)
const filteredResults = computed(() => {
return results.value.filter((r: any) => {
if (activeTab.value === 'courses') return r.type === 'course'
if (activeTab.value === 'prompts') return r.type === 'prompt'
if (activeTab.value === 'contents') return r.type === 'content'
if (activeTab.value === 'tools') return r.type === 'tool'
return true
})
})
async function doSearch() {
if (!keyword.value) return
searched.value = true
try {
const res: any = await api.search.query({ q: keyword.value })
results.value = res.items || []
} catch (e) {
uni.showToast({ title: '搜索失败', icon: 'none' })
}
}
function goDetail(item: any) {
const type = item.type || 'course'
const id = item.id
const pages: Record<string, string> = {
course: `/pages/course-detail/index?id=${id}`,
prompt: `/pages/prompt-detail/index?id=${id}`,
content: `/pages/post-detail/index?id=${id}`,
}
const url = pages[type] || `/pages/course-detail/index?id=${id}`
uni.navigateTo({ url })
}
</script>
<style scoped>
.search { }
.search-bar { display: flex; gap: 16rpx; padding: 20rpx 30rpx; background: #fff; }
.input { flex: 1; background: #f5f5f5; border-radius: 12rpx; padding: 16rpx 24rpx; font-size: 28rpx; }
.btn-search { background: #4f8cff; color: #fff; border-radius: 10rpx; padding: 14rpx 28rpx; font-size: 26rpx; border: none; }
.tabs { display: flex; background: #fff; padding: 16rpx 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.tab { font-size: 26rpx; padding: 8rpx 20rpx; margin-right: 12rpx; color: #666; }
.tab.active { color: #4f8cff; font-weight: bold; border-bottom: 3rpx solid #4f8cff; }
.result-list { padding: 20rpx 30rpx; }
.result-item {
background: #fff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
}
.result-title { font-size: 28rpx; font-weight: 500; display: block; margin-bottom: 8rpx; }
.result-desc { font-size: 24rpx; color: #999; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { text-align: center; padding: 100rpx; font-size: 28rpx; color: #999; }
</style>
+51
View File
@@ -0,0 +1,51 @@
import { http } from '../utils/request'
import { storage } from '../utils/storage'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(storage.get('token') || '')
const profile = ref<any>(null)
const isLoggedIn = computed(() => !!token.value)
async function login(account: string, password: string) {
const res: any = await http.post('/auth/login', { account, password })
token.value = res.accessToken
http.setToken(res.accessToken)
storage.set('token', res.accessToken)
await fetchProfile()
return res
}
async function register(data: { phone?: string; email?: string; password: string; nickname?: string }) {
const res: any = await http.post('/auth/register', data)
token.value = res.accessToken
http.setToken(res.accessToken)
storage.set('token', res.accessToken)
await fetchProfile()
return res
}
async function fetchProfile() {
profile.value = await http.get('/auth/profile')
}
function logout() {
token.value = ''
profile.value = null
http.setToken('')
storage.remove('token')
uni.redirectTo({ url: '/pages/login/index' })
}
function initToken() {
const saved = storage.get('token')
if (saved) {
token.value = saved
http.setToken(saved)
}
}
return { token, profile, isLoggedIn, login, register, fetchProfile, logout, initToken }
})
+64
View File
@@ -0,0 +1,64 @@
const BASE_URL = 'http://localhost:4000/api/v1'
class HttpRequest {
private token: string = ''
setToken(token: string) {
this.token = token
}
getToken() {
return this.token
}
async request<T>(method: string, url: string, data?: any): Promise<T> {
return new Promise((resolve, reject) => {
const header: Record<string, string> = {
'Content-Type': 'application/json',
}
if (this.token) {
header['Authorization'] = `Bearer ${this.token}`
}
uni.request({
url: `${BASE_URL}${url}`,
method: method as any,
data,
header,
success: (res) => {
const result = res.data as any
if (result.code === 0 || result.code === undefined) {
resolve(result.data ?? result)
} else if (res.statusCode === 401) {
uni.removeStorageSync('token')
uni.redirectTo({ url: '/pages/login/index' })
reject(new Error(result.message || '未登录'))
} else {
reject(new Error(result.message || '请求失败'))
}
},
fail: (err) => {
reject(new Error(err.errMsg || '网络错误'))
},
})
})
}
get<T>(url: string, params?: any) {
return this.request<T>('GET', url, params)
}
post<T>(url: string, data?: any) {
return this.request<T>('POST', url, data)
}
put<T>(url: string, data?: any) {
return this.request<T>('PUT', url, data)
}
delete<T>(url: string, data?: any) {
return this.request<T>('DELETE', url, data)
}
}
export const http = new HttpRequest()
+25
View File
@@ -0,0 +1,25 @@
export const storage = {
get(key: string): string | null {
try {
return uni.getStorageSync(key)
} catch {
return null
}
},
set(key: string, value: string) {
try {
uni.setStorageSync(key, value)
} catch (e) {
console.error('Storage set error:', e)
}
},
remove(key: string) {
try {
uni.removeStorageSync(key)
} catch (e) {
console.error('Storage remove error:', e)
}
},
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2021", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["@dcloudio/types"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()],
server: {
port: 4001,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})