feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "我的"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user