feat:前端做了一些改善,以及主页demo

This commit is contained in:
Losita
2026-05-05 23:31:24 +08:00
parent 2204fac84e
commit 816a29c062
16 changed files with 3281 additions and 60 deletions

View File

@@ -1,13 +1,14 @@
{ {
"name": "smartflow-frontend", "name": "smartmate-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "smartflow-frontend", "name": "smartmate-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@vue/shared": "^3.5.0", "@vue/shared": "^3.5.0",
"axios": "^1.8.0", "axios": "^1.8.0",
"element-plus": "^2.9.0", "element-plus": "^2.9.0",

View File

@@ -12,6 +12,7 @@
"@vue/shared": "^3.5.0", "@vue/shared": "^3.5.0",
"axios": "^1.8.0", "axios": "^1.8.0",
"element-plus": "^2.9.0", "element-plus": "^2.9.0",
"@element-plus/icons-vue": "^2.3.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",

View File

@@ -1,16 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import MainSidebar from '@/components/common/MainSidebar.vue' import MainSidebar from '@/components/common/MainSidebar.vue'
const route = useRoute() const route = useRoute()
const showLayout = computed(() => { const showLayout = computed(() => {
return ['dashboard', 'assistant', 'schedule'].includes(route.name as string) return ['dashboard', 'assistant', 'schedule', 'forum', 'store', 'plan-detail'].includes(route.name as string)
})
// 全局加载进度条逻辑
const isLoading = ref(false)
const progress = ref(0)
let progressTimer: any = null
const startLoading = () => {
isLoading.value = true
progress.value = 0
if (progressTimer) clearInterval(progressTimer)
progressTimer = setInterval(() => {
if (progress.value < 90) {
progress.value += Math.random() * 10
}
}, 200)
}
const finishLoading = () => {
progress.value = 100
setTimeout(() => {
isLoading.value = false
progress.value = 0
if (progressTimer) clearInterval(progressTimer)
}, 300)
}
// 监听路由变化模拟进度条
import { useRouter } from 'vue-router'
const router = useRouter()
router.beforeEach((to, from, next) => {
if (to.path !== from.path) startLoading()
next()
})
router.afterEach(() => {
finishLoading()
}) })
</script> </script>
<template> <template>
<div v-if="isLoading" class="global-progress-bar" :style="{ width: progress + '%' }"></div>
<div v-if="showLayout" class="smartmate-layout"> <div v-if="showLayout" class="smartmate-layout">
<MainSidebar /> <MainSidebar />
<div class="smartmate-content"> <div class="smartmate-content">
@@ -28,6 +65,17 @@ body {
margin: 0; margin: 0;
} }
.global-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(to right, #3b82f6, #60a5fa);
z-index: 9999;
transition: width 0.3s ease;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
}
.smartmate-layout { .smartmate-layout {
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
@@ -41,6 +89,32 @@ body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
/* 全局自定义滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(15, 23, 42, 0.08);
border-radius: 10px;
transition: background 0.3s;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(15, 23, 42, 0.15);
}
/* Firefox 兼容性 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(15, 23, 42, 0.08) transparent;
}
.smartmate-content { .smartmate-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

View File

@@ -4,16 +4,18 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
interface SidebarItem { interface SidebarItem {
key: 'home' | 'task' | 'calendar' | 'ai' key: 'home' | 'task' | 'calendar' | 'ai' | 'forum' | 'store'
label: string label: string
short: string short: string
to?: '/dashboard' | '/assistant' | '/schedule' to?: '/dashboard' | '/assistant' | '/schedule' | '/forum' | '/store'
} }
const sidebarItems: SidebarItem[] = [ const sidebarItems: SidebarItem[] = [
{ key: 'home', label: '总览', short: '总', to: '/dashboard' }, { key: 'home', label: '总览', short: '总', to: '/dashboard' },
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' }, { key: 'calendar', label: '日程', short: '程', to: '/schedule' },
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' }, { key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
{ key: 'forum', label: '社区', short: '区', to: '/forum' },
{ key: 'store', label: '商店', short: '商', to: '/store' },
] ]
const route = useRoute() const route = useRoute()
@@ -22,6 +24,8 @@ const router = useRouter()
const activeSidebarKey = computed<SidebarItem['key']>(() => { const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) return 'ai' if (route.path.startsWith('/assistant')) return 'ai'
if (route.path.startsWith('/schedule')) return 'calendar' if (route.path.startsWith('/schedule')) return 'calendar'
if (route.path.startsWith('/forum')) return 'forum'
if (route.path.startsWith('/store')) return 'store'
return 'home' return 'home'
}) })
@@ -30,6 +34,7 @@ const activeSidebarIndex = computed(() => {
}) })
const activeIndicatorStyle = computed(() => { const activeIndicatorStyle = computed(() => {
// 每个项高度 60px + 间隔 12px = 72px
return { return {
transform: `translateY(${activeSidebarIndex.value * 72}px)` transform: `translateY(${activeSidebarIndex.value * 72}px)`
} }

View File

@@ -333,6 +333,11 @@ const visibleTasks = computed(() => props.tasks)
.action-btn.delete:hover { background: #fee2e2; transform: scale(1.1); } .action-btn.delete:hover { background: #fee2e2; transform: scale(1.1); }
/* --- 骨架屏 --- */ /* --- 骨架屏 --- */
.quadrant-card__skeleton {
display: grid;
gap: 12px;
}
.quadrant-card__skeleton-item { .quadrant-card__skeleton-item {
border-radius: 18px; border-radius: 18px;
min-height: 72px; min-height: 72px;

View File

@@ -109,35 +109,38 @@ const renderSlots = computed<RenderSlot[]>(() =>
</div> </div>
</header> </header>
<transition name="grid-pop" mode="out-in"> <div v-if="loading" key="loading" class="pastel-grid">
<div v-if="loading" key="loading" class="pastel-grid"> <div
<div v-for="n in 8" :key="n" class="skeleton-pill" /> v-for="slot in slotBlueprint"
</div> :key="slot.key"
class="skeleton-pill"
:class="{ 'is-pause': slot.kind === 'pause' }"
/>
</div>
<div v-else key="content" class="pastel-grid"> <div v-else key="content" class="pastel-grid">
<template v-for="slot in renderSlots" :key="slot.key"> <template v-for="slot in renderSlots" :key="slot.key">
<article <article
v-if="slot.kind === 'event'" v-if="slot.kind === 'event'"
class="pastel-item" class="pastel-item"
:class="[`tone--${slot.tone}`]" :class="[`tone--${slot.tone}`]"
> >
<div class="item-time">{{ slot.timeText }}</div> <div class="item-time">{{ slot.timeText }}</div>
<strong class="item-title">{{ slot.title }}</strong> <strong class="item-title">{{ slot.title }}</strong>
<div class="item-footer"> <div class="item-footer">
<svg class="location-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg class="location-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"></path> <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"></path>
<circle cx="12" cy="10" r="3"></circle> <circle cx="12" cy="10" r="3"></circle>
</svg> </svg>
<span class="location-text">{{ slot.locationText }}</span> <span class="location-text">{{ slot.locationText }}</span>
</div> </div>
</article> </article>
<article v-else class="pause-item"> <article v-else class="pause-item">
<span class="pause-tag">{{ slot.title }}</span> <span class="pause-tag">{{ slot.title }}</span>
</article> </article>
</template> </template>
</div> </div>
</transition>
</section> </section>
</template> </template>
@@ -187,16 +190,9 @@ const renderSlots = computed<RenderSlot[]>(() =>
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
min-height: 140px; min-height: 140px;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: default; cursor: default;
} }
.pastel-item:hover {
transform: scale(1.03) translateY(-4px);
box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.item-time { .item-time {
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -266,20 +262,16 @@ const renderSlots = computed<RenderSlot[]>(() =>
animation: pill-shimmer 1.5s infinite linear; animation: pill-shimmer 1.5s infinite linear;
} }
.skeleton-pill.is-pause {
min-height: 80px;
}
@keyframes pill-shimmer { @keyframes pill-shimmer {
0% { opacity: 0.5; } 0% { opacity: 0.5; }
50% { opacity: 1; } 50% { opacity: 1; }
100% { opacity: 0.5; } 100% { opacity: 0.5; }
} }
/* 动画效果 */
.grid-pop-enter-active {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.grid-pop-enter-from {
opacity: 0;
transform: scale(0.9);
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.pastel-grid { grid-template-columns: repeat(4, 1fr); } .pastel-grid { grid-template-columns: repeat(4, 1fr); }

View File

@@ -7,12 +7,15 @@ import DashboardView from '@/views/DashboardView.vue'
import ScheduleView from '@/views/ScheduleView.vue' import ScheduleView from '@/views/ScheduleView.vue'
import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue' import AssistantReasoningDebug from '@/views/debug/AssistantReasoningDebug.vue'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ {
path: '/', path: '/',
redirect: '/dashboard', name: 'home',
component: HomeView,
}, },
{ {
path: '/auth', path: '/auth',
@@ -46,6 +49,30 @@ const router = createRouter({
requiresAuth: true, requiresAuth: true,
}, },
}, },
{
path: '/forum',
name: 'forum',
component: () => import('@/views/ForumView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/forum/:id',
name: 'plan-detail',
component: () => import('@/views/PlanDetailView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/store',
name: 'store',
component: () => import('@/views/StoreView.vue'),
meta: {
requiresAuth: true,
},
},
{ {
path: '/debug/tool-card', path: '/debug/tool-card',
name: 'debug-tool-card', name: 'debug-tool-card',

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue' import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue' import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
@@ -277,7 +277,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
</header> </header>
<div ref="dashboardContentRef" class="dashboard-content page-shell"> <div ref="dashboardContentRef" class="dashboard-content page-shell">
<TodayTimeline class="dashboard-item-pop" :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading || pageLoading" /> <TodayTimeline :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading || pageLoading" />
<div class="dashboard-actions dashboard-item-pop" :style="{ '--anim-delay': '0.08s' }"> <div class="dashboard-actions dashboard-item-pop" :style="{ '--anim-delay': '0.08s' }">
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">添加任务</button> <button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">添加任务</button>
@@ -375,15 +375,15 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, 0.08); border-radius: 10px; } ::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, 0.08); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(15, 23, 42, 0.15); } ::-webkit-scrollbar-thumb:hover { background: rgba(15, 23, 42, 0.15); }
@keyframes dashboard-item-spring { @keyframes dashboard-item-fade-in {
0% { opacity: 0; transform: scale(0.9) translateY(20px); } 0% { opacity: 0; transform: translateY(10px); }
60% { opacity: 1; transform: scale(1.02) translateY(-2px); } 100% { opacity: 1; transform: translateY(0); }
100% { opacity: 1; transform: scale(1) translateY(0); }
} }
.dashboard-item-pop { .dashboard-item-pop {
animation: dashboard-item-spring 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both; animation: dashboard-item-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: var(--anim-delay, 0s); animation-delay: var(--anim-delay, 0s);
--anim-delay: 0s;
transform-origin: center center; transform-origin: center center;
} }
@@ -543,12 +543,11 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
/* 弹出动画覆写 */ /* 弹出动画覆写 */
:global(.dialog-fade-enter-active .premium-dialog) { :global(.dialog-fade-enter-active .premium-dialog) {
animation: premium-dialog-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both; animation: premium-dialog-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
} }
@keyframes premium-dialog-pop { @keyframes premium-dialog-fade-in {
0% { opacity: 0; transform: scale(0.92) translateY(20px); } 0% { opacity: 0; transform: scale(0.98) translateY(10px); }
60% { opacity: 1; transform: scale(1.02) translateY(-2px); }
100% { opacity: 1; transform: scale(1) translateY(0); } 100% { opacity: 1; transform: scale(1) translateY(0); }
} }

View File

@@ -0,0 +1,967 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
Plus,
Connection,
ChatDotRound,
Star,
ArrowRight,
Filter,
Sort,
Check,
Delete
} from '@element-plus/icons-vue'
// --- 类型定义 ---
interface UserBrief {
user_id: number
nickname: string
avatar_url: string
}
interface PlanSquarePost {
post_id: number
title: string
summary: string
tags: string[]
author: UserBrief
template_summary: {
task_count: number
mode: string
start_date: string
end_date: string
strategy_labels: string[]
}
counters: {
like_count: number
comment_count: number
import_count: number
}
viewer_state: {
liked: boolean
imported_once: boolean
}
status: 'published'
created_at: string
}
interface CommentNode {
comment_id: number
post_id: number
parent_comment_id: number | null
content: string
status: 'visible' | 'deleted'
author: UserBrief
can_delete: boolean
created_at: string
deleted_at: string | null
children: CommentNode[]
}
// --- Mock 数据 ---
const mockTags = ['全部', '考研', '高数', '期末', '30天', '英语', '雅思', '自律']
const activeTag = ref('全部')
const searchQuery = ref('')
const sortBy = ref('latest')
const mockPosts = ref<PlanSquarePost[]>([
{
post_id: 10001,
title: "30 天高数强化复习计划",
summary: "适合期末前一个月快速过完重点题型。本计划涵盖了极限、导数、积分等核心考点,配合历年真题演练,助你高分过关。",
tags: ["高数", "期末", "30天"],
author: { user_id: 88, nickname: "小鹿同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felix" },
template_summary: {
task_count: 24,
mode: "date_range",
start_date: "2026-05-05",
end_date: "2026-06-04",
strategy_labels: ["每日推进", "错题复盘"]
},
counters: { like_count: 128, comment_count: 32, import_count: 45 },
viewer_state: { liked: false, imported_once: true },
status: "published",
created_at: "2026-05-04T20:30:00+08:00"
},
{
post_id: 10002,
title: "雅思口语 7.5 分冲刺手册",
summary: "重点攻克 Part 2 和 Part 3。精选 50 个高频话题,包含地道表达和逻辑连接词,适合短期提分。",
tags: ["英语", "雅思", "口语"],
author: { user_id: 89, nickname: "杰森英语", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka" },
template_summary: {
task_count: 15,
mode: "quantity",
start_date: "",
end_date: "",
strategy_labels: ["录音回听", "范文精读"]
},
counters: { like_count: 256, comment_count: 48, import_count: 89 },
viewer_state: { liked: true, imported_once: false },
status: "published",
created_at: "2026-05-03T10:15:00+08:00"
},
{
post_id: 10003,
title: "程序员减脂健康餐计划",
summary: "针对久坐人群设计的营养方案。简单易做,控制热量的同时保证脑力输出。包含详细的买菜清单和烹饪步骤。",
tags: ["自律", "健康", "减脂"],
author: { user_id: 90, nickname: "代码养生家", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=James" },
template_summary: {
task_count: 21,
mode: "daily",
start_date: "",
end_date: "",
strategy_labels: ["控糖", "轻断食"]
},
counters: { like_count: 64, comment_count: 12, import_count: 28 },
viewer_state: { liked: false, imported_once: false },
status: "published",
created_at: "2026-05-02T15:20:00+08:00"
}
])
const mockComments = ref<CommentNode[]>([
{
comment_id: 50001,
post_id: 10001,
parent_comment_id: null,
content: "这个计划很适合期末冲刺,我已经导入了,感谢分享!",
status: "visible",
author: { user_id: 91, nickname: "西瓜同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lily" },
can_delete: true,
created_at: "2026-05-04T20:40:00+08:00",
deleted_at: null,
children: [
{
comment_id: 50002,
post_id: 10001,
parent_comment_id: 50001,
content: "同感,特别是错题复盘那个环节设置得很好。",
status: "visible",
author: { user_id: 92, nickname: "青柠同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Jack" },
can_delete: false,
created_at: "2026-05-04T20:42:00+08:00",
deleted_at: null,
children: []
}
]
},
{
comment_id: 50003,
post_id: 10001,
parent_comment_id: null,
content: "博主能分享一下具体的参考书目吗?",
status: "visible",
author: { user_id: 93, nickname: "爱学习的橘子", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bella" },
can_delete: false,
created_at: "2026-05-04T21:00:00+08:00",
deleted_at: null,
children: []
}
])
import { useRouter } from 'vue-router'
const router = useRouter()
// --- 状态变量 ---
const selectedPost = ref<PlanSquarePost | null>(null)
const publishDialogVisible = ref(false)
const isSubmitting = ref(false)
const newComment = ref('')
// --- 计算属性 ---
const filteredPosts = computed(() => {
let result = [...mockPosts.value]
if (activeTag.value !== '全部') {
result = result.filter(p => p.tags.includes(activeTag.value))
}
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(p => p.title.toLowerCase().includes(q) || p.summary.toLowerCase().includes(q))
}
if (sortBy.value === 'likes') {
result.sort((a, b) => b.counters.like_count - a.counters.like_count)
} else if (sortBy.value === 'imports') {
result.sort((a, b) => b.counters.import_count - a.counters.import_count)
} else {
result.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
}
return result
})
// --- 方法 ---
function openDetails(post: PlanSquarePost) {
router.push(`/forum/${post.post_id}`)
}
function handleLike(post: PlanSquarePost) {
if (post.viewer_state.liked) {
post.viewer_state.liked = false
post.counters.like_count--
ElMessage.info('已取消点赞')
} else {
post.viewer_state.liked = true
post.counters.like_count++
ElMessage.success('点赞成功')
}
}
async function handleImport(post: PlanSquarePost) {
try {
await ElMessageBox.confirm(
`确定要将《${post.title}》导入到你的计划中吗?`,
'导入确认',
{ confirmButtonText: '立即导入', cancelButtonText: '取消', type: 'info' }
)
isSubmitting.value = true
// 模拟 API 调用延迟
await new Promise(r => setTimeout(r, 1000))
post.viewer_state.imported_once = true
post.counters.import_count++
ElMessage.success({
message: '导入成功!已为你创建新的任务计划。',
duration: 3000
})
} catch {
// 用户取消
} finally {
isSubmitting.value = false
}
}
function submitComment() {
if (!newComment.value.trim()) return
const comment: CommentNode = {
comment_id: Date.now(),
post_id: selectedPost.value!.post_id,
parent_comment_id: null,
content: newComment.value,
status: 'visible',
author: { user_id: 1, nickname: "我 (Me)", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" },
can_delete: true,
created_at: new Date().toISOString(),
deleted_at: null,
children: []
}
mockComments.value.unshift(comment)
newComment.value = ''
ElMessage.success('评论发表成功')
}
function deleteComment(comment: CommentNode) {
comment.status = 'deleted'
comment.content = '该评论已删除'
ElMessage.info('评论已删除')
}
function formatDate(iso: string) {
const date = new Date(iso)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
</script>
<template>
<div class="forum-container">
<!-- Header -->
<header class="forum-header">
<div class="header-left">
<h1>计划广场</h1>
<p>发现并分享优质的任务计划模板</p>
</div>
<div class="header-actions">
<div class="search-box">
<el-input
v-model="searchQuery"
placeholder="搜索计划、关键词..."
:prefix-icon="Search"
clearable
/>
</div>
<el-button type="primary" :icon="Plus" round @click="publishDialogVisible = true">
发布计划
</el-button>
</div>
</header>
<!-- Filters & Tabs -->
<div class="forum-filters">
<div class="tags-scroller">
<button
v-for="tag in mockTags"
:key="tag"
class="tag-chip"
:class="{ active: activeTag === tag }"
@click="activeTag = tag"
>
{{ tag }}
</button>
</div>
<div class="sort-dropdown">
<el-select v-model="sortBy" placeholder="排序方式" style="width: 120px">
<el-option label="最新发布" value="latest" />
<el-option label="最多点赞" value="likes" />
<el-option label="最多导入" value="imports" />
</el-select>
</div>
</div>
<!-- Post Grid -->
<main class="forum-grid">
<transition-group name="post-list">
<div
v-for="post in filteredPosts"
:key="post.post_id"
class="post-card"
@click="openDetails(post)"
>
<div class="post-card__header">
<h3 class="post-title">{{ post.title }}</h3>
<div class="post-tags">
<span v-for="tag in post.tags.slice(0, 3)" :key="tag" class="small-tag">{{ tag }}</span>
</div>
</div>
<p class="post-summary">{{ post.summary }}</p>
<div class="post-card__footer">
<div class="author-info">
<img :src="post.author.avatar_url" class="author-avatar" />
<span class="author-name">{{ post.author.nickname }}</span>
</div>
<div class="post-stats">
<span class="stat-item" :class="{ active: post.viewer_state.liked }" @click.stop="handleLike(post)">
<el-icon><Star /></el-icon> {{ post.counters.like_count }}
</span>
<span class="stat-item">
<el-icon><ChatDotRound /></el-icon> {{ post.counters.comment_count }}
</span>
<span class="stat-item" :class="{ imported: post.viewer_state.imported_once }" @click.stop="handleImport(post)">
<el-icon><Connection /></el-icon> {{ post.counters.import_count }}
</span>
</div>
</div>
</div>
</transition-group>
<!-- Empty State -->
<div v-if="filteredPosts.length === 0" class="empty-state">
<el-empty description="暂无符合条件的计划" />
</div>
</main>
<!-- Publish Dialog -->
<el-dialog
v-model="publishDialogVisible"
title="发布新计划"
width="500px"
append-to-body
>
<el-form label-position="top">
<el-form-item label="选择现有计划模板" required>
<el-select placeholder="请选择你的 TaskClass" style="width: 100%">
<el-option label="我的 2026 高数笔记" value="1" />
<el-option label="每日算法 100 题" value="2" />
</el-select>
</el-form-item>
<el-form-item label="标题" required>
<el-input placeholder="给你的计划起个吸引人的名字 (4-40字)" />
</el-form-item>
<el-form-item label="简介">
<el-input type="textarea" :rows="3" placeholder="详细描述一下这个计划的适用人群和优势..." />
</el-form-item>
<el-form-item label="标签">
<el-select multiple filterable allow-create default-first-option placeholder="添加标签 (最多5个)">
<el-option v-for="t in mockTags.slice(1)" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="publishDialogVisible = false">取消</el-button>
<el-button type="primary" @click="publishDialogVisible = false; ElMessage.success('发布成功!审核通过后将展示在广场。')">
确认发布
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.forum-container {
height: 100%;
display: flex;
flex-direction: column;
background: #f8fafc;
overflow-y: auto;
}
/* Header */
.forum-header {
background: #fff;
padding: 24px 40px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 28px;
font-weight: 800;
margin: 0;
background: linear-gradient(135deg, #0f172a 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-left p {
color: #64748b;
margin: 4px 0 0 0;
font-size: 14px;
}
.header-actions {
display: flex;
gap: 16px;
align-items: center;
}
.search-box {
width: 280px;
}
/* Filters */
.forum-filters {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 0 40px;
}
.tags-scroller {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
.tags-scroller::-webkit-scrollbar {
height: 4px;
}
.tag-chip {
padding: 6px 16px;
border-radius: 20px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tag-chip:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.tag-chip.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.2);
}
/* Dialog Styling */
:deep(.el-dialog) {
border-radius: 24px !important;
padding: 32px !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
}
:deep(.el-dialog__header) {
padding: 0 0 24px 0 !important;
margin: 0 !important;
}
:deep(.el-dialog__title) {
font-size: 24px !important;
font-weight: 800 !important;
color: #0f172a !important;
}
:deep(.el-dialog__body) {
padding: 0 !important;
}
.publish-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 32px;
}
.publish-btn {
padding: 12px 32px !important;
font-weight: 700 !important;
letter-spacing: 0.5px;
}
/* Post Grid */
.forum-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
padding: 0 40px 40px;
}
.post-card {
background: #fff;
border-radius: 20px;
padding: 24px;
border: 1px solid #f1f5f9;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
overflow: hidden;
}
.post-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.04);
border-color: #dbeafe;
}
.post-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1e293b;
line-height: 1.4;
}
.post-tags {
display: flex;
gap: 6px;
margin-top: 6px;
}
.small-tag {
font-size: 11px;
color: #3b82f6;
background: rgba(59, 130, 246, 0.08);
padding: 2px 8px;
border-radius: 4px;
}
/* --- Global Form Overrides (Flat & Clean) --- */
:deep(.el-input__inner) {
border-radius: 12px !important;
background-color: #f1f5f9 !important;
border: 1px solid transparent !important;
transition: all 0.2s ease !important;
color: #1e293b !important;
}
:deep(.el-input__inner:focus) {
background-color: #fff !important;
border-color: #3b82f6 !important;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1) !important;
}
:deep(.el-textarea__inner) {
border-radius: 12px !important;
background-color: #f1f5f9 !important;
border: 1px solid transparent !important;
padding: 12px 16px !important;
transition: all 0.2s ease !important;
}
:deep(.el-textarea__inner:focus) {
background-color: #fff !important;
border-color: #3b82f6 !important;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1) !important;
}
:deep(.el-select .el-input__inner) {
background-color: #fff !important;
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
}
/* Removed duplicate .forum-container and merged .forum-header */
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-wrapper {
width: 400px;
}
.search-input :deep(.el-input__wrapper) {
box-shadow: none !important;
background: #f1f5f9 !important;
border-radius: 14px !important;
padding: 4px 16px !important;
}
.header-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.category-bar {
display: flex;
gap: 12px;
}
.category-tag {
padding: 8px 18px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.category-tag:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.category-tag.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
}
.sort-select {
width: 140px;
}
.post-summary {
color: #64748b;
font-size: 14px;
line-height: 1.6;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-card__footer {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f8fafc;
}
.author-info {
display: flex;
align-items: center;
gap: 8px;
}
.author-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: #f1f5f9;
}
.author-name {
font-size: 13px;
color: #475569;
font-weight: 500;
}
.post-stats {
display: flex;
gap: 12px;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #94a3b8;
transition: all 0.2s;
}
.stat-item.active {
color: #f43f5e;
}
.stat-item.active .el-icon {
fill: #f43f5e;
}
.stat-item.imported {
color: #10b981;
}
/* Drawer Styles */
.drawer-content {
padding: 0 4px;
display: flex;
flex-direction: column;
gap: 32px;
}
.author-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.large-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: #f1f5f9;
}
.author-meta {
flex: 1;
}
.author-meta .name {
font-weight: 700;
color: #1e293b;
}
.author-meta .time {
font-size: 12px;
color: #94a3b8;
}
.detail-title {
font-size: 24px;
font-weight: 800;
margin: 0 0 12px 0;
line-height: 1.3;
}
.detail-tags {
margin-bottom: 16px;
}
.detail-summary {
color: #475569;
line-height: 1.7;
font-size: 15px;
}
.detail-section h4 {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 700;
margin: 0 0 16px 0;
color: #1e293b;
}
.template-preview {
background: #f8fafc;
border-radius: 12px;
padding: 16px;
border: 1px solid #f1f5f9;
}
.preview-meta {
display: flex;
gap: 20px;
font-size: 13px;
color: #64748b;
margin-bottom: 12px;
}
.strategy-labels {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.strategy-badge {
font-size: 11px;
background: #fff;
border: 1px solid #e2e8f0;
padding: 2px 8px;
border-radius: 4px;
color: #475569;
}
.preview-items {
list-style: none;
padding: 0;
margin: 0;
font-size: 13px;
color: #475569;
}
.preview-items li {
padding: 4px 0;
}
.preview-items li.more {
color: #94a3b8;
font-style: italic;
margin-top: 4px;
}
/* Comments Section */
.comment-input-box {
margin-bottom: 24px;
}
.input-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.comment-item {
display: flex;
flex-direction: column;
gap: 12px;
}
.comment-main {
display: flex;
gap: 12px;
}
.comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f1f5f9;
}
.comment-avatar.small {
width: 24px;
height: 24px;
}
.comment-body {
flex: 1;
}
.comment-user {
font-size: 13px;
font-weight: 700;
color: #475569;
display: flex;
align-items: center;
gap: 8px;
}
.comment-time {
font-weight: 400;
font-size: 11px;
color: #94a3b8;
}
.comment-content {
font-size: 14px;
color: #1e293b;
margin: 4px 0;
line-height: 1.5;
}
.comment-content.deleted {
color: #cbd5e1;
font-style: italic;
}
.comment-actions {
display: flex;
gap: 12px;
}
.action-btn {
font-size: 12px;
color: #3b82f6;
cursor: pointer;
opacity: 0.8;
}
.action-btn:hover {
opacity: 1;
}
.action-btn.delete {
color: #ef4444;
}
.comment-children {
margin-left: 44px;
padding-left: 12px;
border-left: 2px solid #f1f5f9;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Animations */
.post-list-enter-active,
.post-list-leave-active {
transition: all 0.5s ease;
}
.post-list-enter-from,
.post-list-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>

View File

@@ -0,0 +1,610 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
// 导入资产
import heroImg from '@/assets/hero-dashboard.png'
import scheduleImg from '@/assets/feature-schedule.png'
import aiImg from '@/assets/feature-ai.png'
import toolsImg from '@/assets/feature-tools.png'
const router = useRouter()
const authStore = useAuthStore()
const ctaLink = computed(() => authStore.isAuthenticated ? '/dashboard' : '/auth')
const ctaText = computed(() => authStore.isAuthenticated ? '进入工作台' : '开始使用')
const handleCta = () => {
router.push(ctaLink.value)
}
const features = [
{
title: 'AI 随口记',
desc: '一句话记录作业、DDL、生活小事。AI 自动提取关键时间与内容。',
img: toolsImg
},
{
title: '四象限任务池',
desc: '系统化承接日常待办,清晰标注优先级,告别混乱。',
img: heroImg // 复用首页截图展示任务池部分,或在此处根据需要调整
},
{
title: '课表智能编排',
desc: '不仅仅是列出任务,而是智能寻找课间或空闲时段,把任务真正排进日程。',
img: scheduleImg
},
{
title: '长期记忆',
desc: 'AI 会记住你的课程节奏、个人习惯、偏好和长期目标,越用越懂你。',
img: aiImg
}
]
</script>
<template>
<div class="home-container">
<!-- Navigation -->
<nav class="home-nav">
<div class="nav-content">
<div class="brand">时伴 SmartMate</div>
<button class="nav-cta" @click="handleCta">{{ ctaText }}</button>
</div>
</nav>
<!-- Hero Section -->
<header class="hero-section">
<div class="hero-content">
<p class="hero-eyebrow">成长型 AI 排程伙伴</p>
<h1 class="hero-title gradient-text">越用越懂你的<br/>成长型 AI 排程伙伴</h1>
<p class="hero-desc">
不仅仅是待办清单我们将课表DDL个人习惯与长期记忆深度融合<br/>
通过 AI 为你打造一张真正能落地的日程表
</p>
<div class="hero-actions">
<button class="primary-cta" @click="handleCta">{{ ctaText }}</button>
</div>
</div>
<div class="hero-visual">
<img :src="heroImg" alt="时伴工作台" class="main-screenshot" />
</div>
</header>
<!-- Trust/Stats Section -->
<section class="trust-section">
<div class="section-container">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">12,000+</span>
<span class="stat-label">活跃大学生用户</span>
</div>
<div class="stat-item">
<span class="stat-value">850,000+</span>
<span class="stat-label">AI 编排日程任务</span>
</div>
<div class="stat-item">
<span class="stat-value">98.2%</span>
<span class="stat-label">计划执行达成率</span>
</div>
</div>
</div>
</section>
<!-- Problem vs Solution -->
<section class="comparison-section">
<div class="section-container">
<div class="comparison-header">
<h2 class="section-title">告别碎片化与混乱</h2>
<p class="section-subtitle">为了解决大学生真实的计划难题而生</p>
</div>
<div class="comparison-grid">
<div class="comparison-column problem">
<h4>传统工具的困境</h4>
<ul>
<li>
<strong>日历太</strong>
只有格子不知道课间 20 分钟能干什么
</li>
<li>
<strong>清单太</strong>
列了一堆 DDL却不知道该从哪一个开始
</li>
<li>
<strong>AI </strong>
直接改乱日程让人感到失去掌控
</li>
</ul>
</div>
<div class="comparison-column solution">
<h4>时伴的解决之道</h4>
<ul>
<li>
<strong>感知课表缝隙</strong>
自动识别课间与空课填入最适合的任务
</li>
<li>
<strong>智能排序优先级</strong>
基于 DDL 与个人状态自动给出当日最优解
</li>
<li>
<strong>预览确认机制</strong>
AI 负责提案你负责最终决定完美平衡
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Core Loop -->
<section class="loop-section">
<div class="section-container">
<div class="loop-header">
<h2 class="section-title">极简操作闭环</h2>
</div>
<div class="loop-grid">
<div class="loop-item">
<div class="loop-icon-box">💬</div>
<span class="step-num">01</span>
<strong>随口说需求</strong>
</div>
<div class="loop-arrow"></div>
<div class="loop-item">
<div class="loop-icon-box">🧠</div>
<span class="step-num">02</span>
<strong>AI 识别任务</strong>
</div>
<div class="loop-arrow"></div>
<div class="loop-item">
<div class="loop-icon-box">🗓</div>
<span class="step-num">03</span>
<strong>基于课表编排</strong>
</div>
<div class="loop-arrow"></div>
<div class="loop-item">
<div class="loop-icon-box">👀</div>
<span class="step-num">04</span>
<strong>预览微调</strong>
</div>
<div class="loop-arrow"></div>
<div class="loop-item">
<div class="loop-icon-box"></div>
<span class="step-num">05</span>
<strong>确认应用</strong>
</div>
</div>
</div>
</section>
<!-- Key Capabilities -->
<section class="features-section">
<div class="section-container">
<h2 class="section-title">核心能力</h2>
<div class="features-grid">
<div v-for="feature in features" :key="feature.title" class="feature-item">
<div class="feature-text">
<h3>{{ feature.title }}</h3>
<p>{{ feature.desc }}</p>
</div>
<div class="feature-media">
<img :src="feature.img" :alt="feature.title" />
</div>
</div>
</div>
</div>
</section>
<!-- Final CTA -->
<section class="final-cta-section">
<div class="section-container">
<div class="final-content">
<h2 class="gradient-text">让计划不再停在待办列表里</h2>
<p>加入数万名大学生开启高效的智能校园生活</p>
<button class="primary-cta" @click="handleCta">{{ ctaText }}</button>
</div>
</div>
</section>
<!-- Footer -->
<footer class="home-footer">
<div class="footer-content">
<p>© 2026 时伴 SmartMate · 大学生的智能排程伙伴</p>
</div>
</footer>
</div>
</template>
<style scoped>
.home-container {
min-height: 100vh;
background: #ffffff;
color: #1e293b;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow-x: hidden;
}
.section-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* Utils */
.gradient-text {
background: linear-gradient(135deg, #0f172a 0%, #2563eb 50%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Nav */
.home-nav {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.nav-content {
max-width: 1200px;
margin: 0 auto;
height: 72px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
font-size: 20px;
font-weight: 800;
color: #2563eb;
letter-spacing: -0.02em;
}
.nav-cta {
padding: 8px 20px;
border-radius: 99px;
background: #1e293b;
color: #fff;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.nav-cta:hover {
background: #0f172a;
transform: translateY(-1px);
}
/* Hero */
.hero-section {
padding: 120px 0 80px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 80px;
}
.hero-content {
max-width: 900px;
padding: 0 24px;
}
.hero-eyebrow {
font-size: 14px;
font-weight: 700;
color: #3b82f6;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 20px;
}
.hero-title {
font-size: 72px;
font-weight: 850;
line-height: 1.05;
letter-spacing: -0.04em;
margin-bottom: 32px;
}
.hero-desc {
font-size: 22px;
color: #64748b;
line-height: 1.6;
margin-bottom: 48px;
}
.primary-cta {
padding: 18px 48px;
border-radius: 14px;
background: #2563eb;
color: #fff;
border: none;
font-size: 18px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 25px -5px rgba(37, 99, 235, 0.4);
}
.primary-cta:hover {
background: #1d4ed8;
transform: translateY(-2px);
box-shadow: 0 15px 30px -5px rgba(37, 99, 235, 0.5);
}
.hero-visual {
width: 100%;
max-width: 1100px;
padding: 0 24px;
}
.main-screenshot {
width: 100%;
border-radius: 24px;
box-shadow: 0 40px 80px -12px rgba(15, 23, 42, 0.18), 0 20px 40px -20px rgba(15, 23, 42, 0.12);
border: 1px solid rgba(0, 0, 0, 0.05);
}
/* Stats */
.trust-section {
padding: 60px 0;
border-top: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
}
.stats-grid {
display: flex;
justify-content: space-around;
text-align: center;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-value {
font-size: 32px;
font-weight: 800;
color: #0f172a;
}
.stat-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
/* Comparison */
.comparison-section {
padding: 120px 0;
background: #fcfdfe;
}
.comparison-header {
text-align: center;
margin-bottom: 80px;
}
.section-subtitle {
font-size: 18px;
color: #64748b;
margin-top: 12px;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
}
.comparison-column {
padding: 40px;
border-radius: 24px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.comparison-column h4 {
font-size: 20px;
font-weight: 800;
margin-bottom: 24px;
}
.comparison-column.problem {
border-left: 4px solid #cbd5e1;
}
.comparison-column.solution {
border-left: 4px solid #3b82f6;
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
}
.comparison-column ul {
list-style: none;
padding: 0;
margin: 0;
}
.comparison-column li {
margin-bottom: 20px;
font-size: 16px;
line-height: 1.6;
color: #475569;
}
.comparison-column li strong {
display: block;
color: #1e293b;
margin-bottom: 4px;
}
/* Loop */
.loop-section {
padding: 100px 0;
background: #f8fafc;
}
.loop-header {
text-align: center;
margin-bottom: 60px;
}
.loop-grid {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.loop-icon-box {
width: 64px;
height: 64px;
border-radius: 18px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 16px;
}
.loop-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.step-num {
font-size: 12px;
font-weight: 800;
color: #94a3b8;
margin-bottom: 4px;
}
.loop-item strong {
font-size: 16px;
color: #334155;
}
.loop-arrow {
color: #cbd5e1;
font-size: 24px;
margin-bottom: 40px;
}
/* Features */
.features-section {
padding: 120px 0;
background: #ffffff;
}
.section-title {
font-size: 40px;
font-weight: 850;
text-align: center;
margin-bottom: 80px;
letter-spacing: -0.03em;
}
.features-grid {
display: grid;
gap: 140px;
}
.feature-item {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 80px;
align-items: center;
}
.feature-item:nth-child(even) {
grid-template-columns: 1.5fr 1fr;
}
.feature-item:nth-child(even) .feature-text {
order: 2;
}
.feature-text h3 {
font-size: 32px;
font-weight: 800;
margin-bottom: 24px;
color: #0f172a;
}
.feature-text p {
font-size: 18px;
line-height: 1.7;
color: #64748b;
}
.feature-media img {
width: 100%;
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.03);
}
/* Final CTA */
.final-cta-section {
padding: 160px 0;
text-align: center;
background: linear-gradient(180deg, #ffffff 0%, #f8faff 100%);
}
.final-content h2 {
font-size: 48px;
font-weight: 850;
margin-bottom: 24px;
}
.final-content p {
font-size: 18px;
color: #64748b;
margin-bottom: 48px;
}
/* Footer */
.home-footer {
padding: 60px 0;
border-top: 1px solid #f1f5f9;
text-align: center;
}
.footer-content p {
font-size: 14px;
color: #94a3b8;
}
/* Responsive */
@media (max-width: 1024px) {
.hero-title { font-size: 56px; }
.feature-item { grid-template-columns: 1fr !important; gap: 40px; text-align: center; }
.feature-item .feature-text { order: 1 !important; }
.feature-media { order: 2; }
.comparison-grid { grid-template-columns: 1fr; gap: 32px; }
.loop-grid { flex-wrap: wrap; justify-content: center; gap: 32px; }
.loop-arrow { display: none; }
.stats-grid { flex-wrap: wrap; gap: 40px; }
}
@media (max-width: 640px) {
.hero-title { font-size: 40px; }
.hero-desc { font-size: 18px; }
.section-title { font-size: 32px; }
.final-content h2 { font-size: 36px; }
}
</style>

View File

@@ -0,0 +1,868 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
Connection,
ChatDotRound,
Star,
Filter,
Check
} from '@element-plus/icons-vue'
// --- 类型定义 ---
interface UserBrief {
user_id: number
nickname: string
avatar_url: string
}
interface PlanSquarePost {
post_id: number
title: string
summary: string
tags: string[]
author: UserBrief
template_summary: {
task_count: number
mode: string
start_date: string
end_date: string
strategy_labels: string[]
}
counters: {
like_count: number
comment_count: number
import_count: number
}
viewer_state: {
liked: boolean
imported_once: boolean
}
status: 'published'
created_at: string
}
interface CommentNode {
comment_id: number
post_id: number
parent_comment_id: number | null
content: string
status: 'visible' | 'deleted'
author: UserBrief
can_delete: boolean
created_at: string
deleted_at: string | null
children: CommentNode[]
}
const route = useRoute()
const router = useRouter()
const isLoading = ref(true)
const selectedPost = ref<PlanSquarePost | null>(null)
const mockComments = ref<CommentNode[]>([])
const newComment = ref('')
const replyingToId = ref<number | null>(null)
const replyText = ref('')
// --- 初始化 Mock 数据 ---
onMounted(async () => {
// 模拟加载延迟
await new Promise(r => setTimeout(r, 600))
const postId = Number(route.params.id)
// 模拟从后端获取详情
selectedPost.value = {
post_id: postId,
title: postId === 10001 ? "30 天高数强化复习计划 (深度进阶版)" : "雅思口语 7.5 分冲刺手册",
summary: `这是一份经过验证的高质量计划,帮助你快速达成目标。
本计划不仅涵盖了基础知识点,还深入探讨了高数中最为棘手的证明题与综合题型。
在接下来的30天里我们将通过系统的拆解将复杂的微积分问题简化为可执行的每日任务。
无论你是为了考研冲刺,还是期末突击,这份计划都将是你最坚实的后盾。
我们将重点关注以下几个模块:
1. 函数、极限与连续的深度理解
2. 一元函数微分学的应用技巧
3. 积分学的各种变换与计算模型
4. 空间解析几何与向量代数
5. 多元函数微分与积分学
6. 常微分方程的特殊解法
每个模块都配备了精选的例题和课后练习,确保你能学以致用。
请务必严格按照计划执行,不要遗漏任何一个复盘环节。
祝你在数学的海洋中乘风破浪,取得优异成绩!`,
tags: ["高数", "复习", "冲刺", "考研", "干货"],
author: { user_id: 88, nickname: "小鹿同学", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felix" },
template_summary: {
task_count: 30,
mode: "date_range",
start_date: "2026-05-05",
end_date: "2026-06-04",
strategy_labels: ["每日推进", "错题复盘", "阶段测试", "脑图总结"]
},
counters: { like_count: 128, comment_count: 32, import_count: 45 },
viewer_state: { liked: false, imported_once: false },
status: "published",
created_at: "2026-05-04T20:30:00+08:00"
}
mockComments.value = Array.from({ length: 15 }).map((_, i) => ({
comment_id: 50000 + i,
post_id: postId,
parent_comment_id: null,
content: `这是第 ${i + 1} 条测试评论。这份计划真的太详细了,特别是关于${['微积分', '中值定理', '泰勒公式', '多重积分'][i % 4]}的部分,讲得非常透彻。`,
status: "visible",
author: {
user_id: 100 + i,
nickname: `学霸${i + 1}`,
avatar_url: `https://api.dicebear.com/7.x/avataaars/svg?seed=User${i}`
},
can_delete: true, // 全部允许删除以便测试
created_at: "2026-05-04T20:40:00+08:00",
deleted_at: null,
children: i === 0 ? [
{
comment_id: 60001,
post_id: postId,
parent_comment_id: 50000,
content: "我也这么觉得,博主太用心了!",
status: "visible",
author: { user_id: 201, nickname: "回复人A", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=A" },
can_delete: true,
created_at: "2026-05-04T21:00:00+08:00",
deleted_at: null,
children: []
}
] : []
}))
isLoading.value = false
})
function goBack() {
router.push('/forum')
}
function handleLike() {
if (!selectedPost.value) return
if (selectedPost.value.viewer_state.liked) {
selectedPost.value.viewer_state.liked = false
selectedPost.value.counters.like_count--
} else {
selectedPost.value.viewer_state.liked = true
selectedPost.value.counters.like_count++
ElMessage.success('点赞成功')
}
}
async function handleImport() {
if (!selectedPost.value) return
isLoading.value = true
await new Promise(r => setTimeout(r, 800))
selectedPost.value.viewer_state.imported_once = true
selectedPost.value.counters.import_count++
ElMessage.success('导入成功')
isLoading.value = false
}
function submitComment() {
if (!newComment.value.trim()) return
const comment: CommentNode = {
comment_id: Date.now(),
post_id: selectedPost.value!.post_id,
parent_comment_id: null,
content: newComment.value,
status: 'visible',
author: { user_id: 1, nickname: "我 (Me)", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" },
can_delete: true,
created_at: new Date().toISOString(),
deleted_at: null,
children: []
}
mockComments.value.unshift(comment)
newComment.value = ''
ElMessage.success('发表成功')
}
function startReply(commentId: number) {
replyingToId.value = commentId
replyText.value = ''
}
function submitReply(parentComment: CommentNode) {
if (!replyText.value.trim()) return
const reply: CommentNode = {
comment_id: Date.now(),
post_id: selectedPost.value!.post_id,
parent_comment_id: parentComment.comment_id,
content: replyText.value,
status: 'visible',
author: { user_id: 1, nickname: "我 (Me)", avatar_url: "https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" },
can_delete: true,
created_at: new Date().toISOString(),
deleted_at: null,
children: []
}
parentComment.children.push(reply)
replyingToId.value = null
replyText.value = ''
ElMessage.success('回复成功')
}
function deleteComment(commentId: number) {
// 递归删除逻辑
const removeRecursive = (list: CommentNode[], id: number): boolean => {
for (let i = 0; i < list.length; i++) {
if (list[i].comment_id === id) {
list.splice(i, 1)
return true
}
if (list[i].children && removeRecursive(list[i].children, id)) {
return true
}
}
return false
}
if (removeRecursive(mockComments.value, commentId)) {
ElMessage.success('评论已删除')
}
}
function formatDate(iso: string) {
const date = new Date(iso)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
</script>
<template>
<div class="detail-page-container" v-loading="isLoading">
<div v-if="selectedPost" class="detail-wrapper">
<!-- Top Navigation -->
<nav class="detail-nav">
<el-button :icon="ArrowLeft" circle @click="goBack" />
<span class="nav-title">计划详情</span>
<div class="nav-actions">
<el-button
type="primary"
round
:icon="selectedPost.viewer_state.imported_once ? Check : Connection"
@click="handleImport"
>
{{ selectedPost.viewer_state.imported_once ? '已导入' : '立即导入' }}
</el-button>
</div>
</nav>
<!-- Main Content -->
<div class="detail-main-layout">
<aside class="detail-sidebar">
<div class="author-card">
<img :src="selectedPost.author.avatar_url" class="author-avatar" />
<div class="author-name">{{ selectedPost.author.nickname }}</div>
<div class="publish-time">发布于 {{ formatDate(selectedPost.created_at) }}</div>
<div class="author-stats">
<div class="stat-box">
<span class="num">{{ selectedPost.counters.like_count }}</span>
<span class="lbl">获赞</span>
</div>
<div class="stat-box">
<span class="num">{{ selectedPost.counters.import_count }}</span>
<span class="lbl">导入</span>
</div>
</div>
</div>
<div class="template-summary-card">
<h4><el-icon><Filter /></el-icon> 计划概览</h4>
<div class="summary-info">
<div class="info-row">
<span class="label">任务总数</span>
<span class="value">{{ selectedPost.template_summary.task_count }}</span>
</div>
<div class="info-row">
<span class="label">排程模式</span>
<span class="value">{{ selectedPost.template_summary.mode === 'date_range' ? '日期范围' : '固定天数' }}</span>
</div>
</div>
<div class="strategy-list">
<span v-for="tag in selectedPost.template_summary.strategy_labels" :key="tag" class="strategy-tag">
{{ tag }}
</span>
</div>
</div>
<div class="tasks-overview-card">
<h4><el-icon><Filter /></el-icon> 任务预览</h4>
<div class="items-list">
<div v-for="i in 10" :key="i" class="item-node">
<div class="node-idx">{{ String(i).padStart(2, '0') }}</div>
<div class="node-content">
{{ [
'复习极限与连续的基础概念,完成课后习题。',
'导数与微分的中值定理深度解析,配合真题演练。',
'泰勒展开式及其在近似计算中的应用技巧。',
'不定积分的换元法与分部积分法专项突破。',
'定积分的几何意义与物理应用案例分析。',
'多元函数偏导数与全微分的计算模型。',
'二重积分在极坐标下的变换与计算方法。',
'常微分方程的一阶线性方程求解步骤。',
'向量代数与空间解析几何的综合练习。',
'全书重点难点回顾与思维导图梳理。'
][(i-1) % 10] }}
</div>
</div>
<div class="item-node more">
... 更多 {{ selectedPost.template_summary.task_count - 10 }} 个任务项 ...
</div>
</div>
</div>
</aside>
<main class="detail-content-area">
<section class="content-header">
<h1 class="plan-title">{{ selectedPost.title }}</h1>
<div class="plan-tags">
<el-tag v-for="tag in selectedPost.tags" :key="tag" class="mx-1" effect="plain">{{ tag }}</el-tag>
</div>
<p class="plan-description">{{ selectedPost.summary }}</p>
</section>
<section class="comments-section">
<h3><el-icon><ChatDotRound /></el-icon> 互动区 ({{ selectedPost.counters.comment_count }})</h3>
<div class="comment-post-box">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Lucky" class="current-user-avatar" />
<div class="input-container">
<el-input
v-model="newComment"
type="textarea"
:rows="2"
placeholder="写下你的想法,与大家交流..."
resize="none"
class="premium-input"
/>
<div class="post-actions">
<el-button type="primary" round size="default" @click="submitComment">发布评论</el-button>
</div>
</div>
</div>
<div class="comments-list">
<div v-for="comment in mockComments" :key="comment.comment_id" class="comment-item">
<img :src="comment.author.avatar_url" class="c-avatar" />
<div class="c-body">
<div class="c-user">
{{ comment.author.nickname }}
<span class="c-time">{{ formatDate(comment.created_at) }}</span>
</div>
<div class="c-content">{{ comment.content }}</div>
<div class="c-actions">
<span class="btn" @click="startReply(comment.comment_id)">回复</span>
<span v-if="comment.can_delete" class="btn del" @click="deleteComment(comment.comment_id)">删除</span>
</div>
<!-- Reply Input -->
<transition name="el-zoom-in-top">
<div v-if="replyingToId === comment.comment_id" class="reply-input-wrapper">
<el-input
v-model="replyText"
type="textarea"
:rows="1"
auto-grow
placeholder="写下你的回复..."
class="reply-field"
@keyup.enter.ctrl="submitReply(comment)"
/>
<div class="reply-btns">
<el-button size="small" link @click="replyingToId = null">取消</el-button>
<el-button size="small" type="primary" round @click="submitReply(comment)">提交回复</el-button>
</div>
</div>
</transition>
<!-- Child Comments -->
<div v-if="comment.children.length > 0" class="comment-children">
<div v-for="child in comment.children" :key="child.comment_id" class="comment-item child">
<img :src="child.author.avatar_url" class="c-avatar small" />
<div class="c-body">
<div class="c-user">
{{ child.author.nickname }}
<span class="c-time">{{ formatDate(child.created_at) }}</span>
</div>
<div class="c-content">{{ child.content }}</div>
<div class="c-actions">
<span class="btn" @click="startReply(child.comment_id)">回复</span>
<span v-if="child.can_delete" class="btn del" @click="deleteComment(child.comment_id)">删除</span>
</div>
<!-- Reply Input for child -->
<transition name="el-zoom-in-top">
<div v-if="replyingToId === child.comment_id" class="reply-input-wrapper">
<el-input
v-model="replyText"
type="textarea"
:rows="1"
placeholder="写下你的回复..."
class="reply-field"
@keyup.enter.ctrl="submitReply(child)"
/>
<div class="reply-btns">
<el-button size="small" link @click="replyingToId = null">取消</el-button>
<el-button size="small" type="primary" round @click="submitReply(child)">提交回复</el-button>
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
</div>
</template>
<style scoped>
.detail-page-container {
height: 100%;
background: #f8fafc;
display: flex;
flex-direction: column;
overflow: hidden; /* 彻底禁止整页滚动 */
}
.detail-wrapper {
width: 100%;
height: 100%;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
.detail-nav {
display: flex;
align-items: center;
gap: 16px;
background: #fff; /* 独立滚动后不再需要毛玻璃,纯白更稳重 */
padding: 16px 24px;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
flex-shrink: 0;
z-index: 1000;
}
.nav-title {
font-weight: 700;
font-size: 18px;
color: #1e293b;
flex: 1;
}
.detail-main-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 24px;
align-items: stretch; /* 占满高度 */
flex: 1;
min-height: 0; /* 关键:允许 flex 子项缩小 */
overflow: hidden;
}
/* Sidebar */
.detail-sidebar {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
overflow-y: auto;
padding-right: 4px; /* 为侧边栏滚动留出空间 */
}
/* 隐藏侧边栏总滚动条,仅内部卡片滚动或整体滚动 */
.detail-sidebar::-webkit-scrollbar {
width: 0;
}
.author-card {
background: #fff;
border-radius: 24px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
border: 1px solid #f1f5f9;
}
.author-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: #f1f5f9;
margin-bottom: 12px;
border: 3px solid #f8fafc;
}
.author-name {
font-weight: 800;
font-size: 16px;
color: #0f172a;
}
.publish-time {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
}
.author-stats {
display: flex;
width: 100%;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
}
.stat-box {
flex: 1;
display: flex;
flex-direction: column;
}
.stat-box .num {
font-weight: 800;
font-size: 16px;
color: #1e293b;
}
.stat-box .lbl {
font-size: 11px;
color: #64748b;
}
.template-summary-card, .tasks-overview-card {
background: #fff;
border-radius: 24px;
padding: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
border: 1px solid #f1f5f9;
}
.tasks-overview-card {
display: flex;
flex-direction: column;
max-height: 400px;
overflow: hidden;
}
.tasks-overview-card h4 {
margin: 0 0 16px 0;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tasks-overview-card .items-list {
overflow-y: auto;
padding-right: 4px;
}
.tasks-overview-card .items-list::-webkit-scrollbar {
width: 4px;
}
.tasks-overview-card .items-list::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
.template-summary-card h4 {
margin: 0 0 16px 0;
display: flex;
align-items: center;
gap: 8px;
}
.summary-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 14px;
}
.info-row .label { color: #64748b; }
.info-row .value { font-weight: 600; color: #1e293b; }
.strategy-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.strategy-tag {
font-size: 11px;
background: #f1f5f9;
color: #475569;
padding: 4px 10px;
border-radius: 6px;
}
/* Content Area */
.detail-content-area {
background: #fff;
border-radius: 24px;
padding: 40px 60px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
gap: 40px;
height: 100%;
overflow-y: auto;
}
.detail-content-area::-webkit-scrollbar {
width: 6px;
}
.detail-content-area::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
.detail-content-area::-webkit-scrollbar-track {
background: transparent;
}
.plan-title {
font-size: 32px;
font-weight: 800;
margin: 0 0 16px 0;
color: #0f172a;
line-height: 1.3;
}
.plan-tags { margin-bottom: 24px; }
.plan-description {
font-size: 18px;
line-height: 1.8;
color: #475569;
white-space: pre-wrap;
}
.content-body h3, .comments-section h3 {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 700;
margin: 0 0 24px 0;
}
.items-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.item-node {
display: flex;
gap: 12px;
background: #f8fafc;
padding: 12px;
border-radius: 12px;
border: 1px solid #f1f5f9;
margin-bottom: 8px;
}
.node-idx {
font-weight: 800;
color: #3b82f6;
font-family: monospace;
font-size: 14px;
}
.node-content {
color: #1e293b;
font-size: 13px;
line-height: 1.4;
}
.item-node.more {
justify-content: center;
background: transparent;
border: 1px dashed #e2e8f0;
color: #94a3b8;
font-size: 12px;
font-style: italic;
padding: 8px;
}
/* Comments */
.comment-post-box {
background: #fff;
padding: 24px;
border-radius: 20px;
margin-bottom: 40px;
display: flex;
gap: 16px;
border: 1px solid #f1f5f9;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02);
transition: all 0.3s ease;
}
.comment-post-box:focus-within {
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.08);
border-color: #dbeafe;
}
.current-user-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.input-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.premium-input :deep(.el-textarea__inner) {
border: none;
background: #f8fafc;
padding: 12px 16px;
border-radius: 12px;
font-size: 15px;
color: #1e293b;
transition: all 0.2s ease;
}
.premium-input :deep(.el-textarea__inner:focus) {
background: #fff;
box-shadow: inset 0 0 0 1px #3b82f6;
}
.post-actions {
display: flex;
justify-content: flex-end;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 32px;
}
.comment-item {
display: flex;
gap: 16px;
}
.c-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
background: #f1f5f9;
}
.c-body { flex: 1; }
.c-user {
font-weight: 700;
font-size: 15px;
color: #0f172a;
margin-bottom: 6px;
}
.c-time {
font-weight: 400;
font-size: 12px;
color: #94a3b8;
margin-left: 10px;
}
.c-content {
font-size: 15px;
color: #334155;
line-height: 1.6;
white-space: pre-wrap;
}
.c-actions {
margin-top: 10px;
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.btn {
font-size: 13px;
font-weight: 600;
color: #64748b;
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
.btn:hover { color: #3b82f6; }
.btn.del:hover { color: #ef4444; }
.reply-input-wrapper {
margin: 12px 0 20px 0;
background: #f8fafc;
padding: 16px;
border-radius: 16px;
border: 1px solid #f1f5f9;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
}
.reply-field :deep(.el-textarea__inner) {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 8px 12px;
background: #fff;
font-size: 14px;
}
.reply-btns {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}
.comment-children {
margin-top: 16px;
padding-left: 20px;
border-left: 2px solid #f1f5f9;
display: flex;
flex-direction: column;
gap: 24px;
}
.c-avatar.small {
width: 32px;
height: 32px;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,672 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Coin,
ShoppingCart,
Timer,
TrendCharts,
Wallet,
Check,
Refresh,
InfoFilled
} from '@element-plus/icons-vue'
// --- 类型定义 ---
interface CreditSummary {
recorded_credit_total: number
applied_credit_total: number
pending_apply_credit_total: number
valid_until: string | null // 有效期至
quota_sync_status: 'not_connected' | 'partial' | 'synced'
tip: string
}
interface CreditProduct {
product_id: number
name: string
description: string
credit_amount: number
price_cent: number
price_text: string
currency: 'CNY'
badge: string
status: 'active' | 'inactive'
}
interface CreditGrant {
grant_id: number
source_label: string
amount: number
status: 'recorded' | 'applied' | 'skipped' | 'failed'
description: string
created_at: string
}
// --- Mock 数据 ---
const summary = ref<CreditSummary>({
recorded_credit_total: 120,
applied_credit_total: 0,
pending_apply_credit_total: 120,
valid_until: "2026-06-05", // 默认显示一个月后
quota_sync_status: 'not_connected',
tip: '当前为 Credit 获取记录,后续会切换到 user/auth 权威额度。'
})
const products = ref<CreditProduct[]>([
{
product_id: 0,
name: "Free",
description: "每日免费发放,适合基础功能体验。",
credit_amount: 100,
price_cent: 0,
price_text: "免费",
currency: "CNY",
badge: "每日",
status: "active"
},
{
product_id: 1,
name: "Starter",
description: "入门级额度,有效期 1 个月。续费时时间和额度均可累加。",
credit_amount: 1000,
price_cent: 990,
price_text: "¥9.9",
currency: "CNY",
badge: "入门",
status: "active"
},
{
product_id: 2,
name: "Lite",
description: "经济型套餐,有效期 1 个月。适合日常轻度规划。",
credit_amount: 3000,
price_cent: 1990,
price_text: "¥19.9",
currency: "CNY",
badge: "经济",
status: "active"
},
{
product_id: 3,
name: "Pro",
description: "专业版套餐,有效期 1 个月。最受深度规划用户欢迎。",
credit_amount: 10000,
price_cent: 3990,
price_text: "¥39.9",
currency: "CNY",
badge: "Most Popular",
status: "active"
},
{
product_id: 4,
name: "Max",
description: "旗舰级套餐,有效期 1 个月。极致体验,额度充沛。",
credit_amount: 40000,
price_cent: 9990,
price_text: "¥99.9",
currency: "CNY",
badge: "旗舰",
status: "active"
}
])
const grants = ref<CreditGrant[]>([
{
grant_id: 90002,
source_label: "计划被点赞",
amount: 1,
status: "recorded",
description: "你的计划《30 天高数强化复习计划》获得点赞",
created_at: "2026-05-04T21:05:00+08:00"
},
{
grant_id: 90001,
source_label: "购买 Credit 包",
amount: 1000,
status: "recorded",
description: "购买 Starter Credit 包",
created_at: "2026-05-04T21:00:01+08:00"
},
{
grant_id: 90000,
source_label: "导入奖励",
amount: 2,
status: "recorded",
description: "成功导入《雅思口语冲刺手册》",
created_at: "2026-05-04T10:00:00+08:00"
}
])
// --- 状态变量 ---
const isBuying = ref(false)
const historyLoading = ref(false)
// --- 方法 ---
async function handlePurchase(product: CreditProduct) {
try {
const isFree = product.price_cent === 0
await ElMessageBox.confirm(
isFree
? `确定要领取每日免费的 ${product.credit_amount} Credit 吗?`
: `确定要花费 ${product.price_text} 购买 ${product.credit_amount} Credit 吗?\n有效期一个月续费可累加。`,
isFree ? '领取确认' : '支付确认',
{
confirmButtonText: isFree ? '立即领取' : '确认支付',
cancelButtonText: '取消',
type: 'info',
center: true
}
)
isBuying.value = true
// 模拟订单创建与支付流程
await new Promise(r => setTimeout(r, 1500))
// 更新本地状态
summary.value.recorded_credit_total += product.credit_amount
summary.value.pending_apply_credit_total += product.credit_amount
const newGrant: CreditGrant = {
grant_id: Date.now(),
source_label: isFree ? "每日领取" : "购买 Credit 包",
amount: product.credit_amount,
status: "recorded",
description: isFree ? "每日免费额度领取" : `购买${product.name}`,
created_at: new Date().toISOString()
}
grants.value.unshift(newGrant)
ElMessage({
type: 'success',
message: isFree ? `领取成功!已获得 ${product.credit_amount} Credit` : `支付成功!已充值 ${product.credit_amount} Credit`,
duration: 3000
})
} catch {
// 用户取消
} finally {
isBuying.value = false
}
}
function formatDate(iso: string) {
const date = new Date(iso)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
function refreshGrants() {
historyLoading.value = true
setTimeout(() => {
historyLoading.value = false
ElMessage.success('记录已更新')
}, 800)
}
</script>
<template>
<div class="store-container" v-loading="isBuying">
<!-- Header -->
<header class="store-header">
<div class="header-left">
<h1>Credit 商店</h1>
<p>获取更多 Credit解锁 AI 增强规划能力</p>
</div>
<div class="header-right">
<el-button :icon="Refresh" circle @click="refreshGrants" />
</div>
</header>
<!-- Balance Summary Card -->
<div class="balance-card">
<div class="balance-content">
<div class="balance-main">
<div class="label">累计获取 Credit</div>
<div class="value">
<el-icon><Coin /></el-icon>
<span>{{ summary.recorded_credit_total }}</span>
</div>
</div>
<div class="balance-details">
<div class="detail-item" v-if="summary.valid_until">
<el-icon><Timer /></el-icon>
<span class="text">有效期至: {{ summary.valid_until }}</span>
</div>
<div class="detail-item">
<span class="dot warning"></span>
<span class="text">待同步: {{ summary.pending_apply_credit_total }}</span>
</div>
<div class="detail-item">
<span class="dot success"></span>
<span class="text">已同步: {{ summary.applied_credit_total }}</span>
</div>
</div>
</div>
<div class="balance-info">
<el-alert
:title="summary.tip"
type="info"
:closable="false"
show-icon
/>
</div>
<!-- Background Ornament -->
<div class="card-glow"></div>
</div>
<!-- Product Grid -->
<section class="store-section">
<h3 class="section-title"><el-icon><ShoppingCart /></el-icon> Credit 套餐</h3>
<div class="product-grid">
<div
v-for="product in products"
:key="product.product_id"
class="product-card"
:class="{ 'is-popular': product.badge === 'Most Popular' }"
>
<div v-if="product.badge" class="product-badge">{{ product.badge }}</div>
<div class="product-info">
<h4 class="product-name">{{ product.name }}</h4>
<p class="product-desc">{{ product.description }}</p>
</div>
<div class="product-amount">
<el-icon><Coin /></el-icon>
<span>{{ product.credit_amount }}</span>
</div>
<div class="product-footer">
<div class="price">{{ product.price_text }}</div>
<el-button
:type="product.badge === 'Most Popular' ? 'warning' : 'primary'"
round
@click="handlePurchase(product)"
>
{{ product.price_cent === 0 ? '立即领取' : '立即购买' }}
</el-button>
</div>
</div>
</div>
</section>
<!-- History -->
<section class="store-section">
<h3 class="section-title"><el-icon><TrendCharts /></el-icon> 获取记录</h3>
<div class="history-list" v-loading="historyLoading">
<div v-for="grant in grants" :key="grant.grant_id" class="history-item">
<div class="history-icon" :class="grant.status">
<el-icon v-if="grant.status === 'recorded'"><Check /></el-icon>
<el-icon v-else><InfoFilled /></el-icon>
</div>
<div class="history-content">
<div class="history-top">
<span class="source">{{ grant.source_label }}</span>
<span class="amount">+{{ grant.amount }} Credit</span>
</div>
<div class="history-bottom">
<span class="desc">{{ grant.description }}</span>
<span class="time">{{ formatDate(grant.created_at) }}</span>
</div>
</div>
</div>
<!-- Load More -->
<div class="history-footer">
<el-button link>查看更多记录 <el-icon><Timer /></el-icon></el-button>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.store-container {
padding: 24px;
height: 100%;
overflow-y: auto;
overflow-x: hidden; /* 强制禁用水平滚动 */
display: flex;
flex-direction: column;
gap: 32px;
background: #f8fafc;
scrollbar-width: thin; /* Firefox */
scrollbar-color: rgba(15, 23, 42, 0.1) transparent;
}
/* 自定义滚动条样式 */
.store-container::-webkit-scrollbar {
width: 6px;
}
.store-container::-webkit-scrollbar-track {
background: transparent;
}
.store-container::-webkit-scrollbar-thumb {
background: rgba(15, 23, 42, 0.08);
border-radius: 10px;
transition: background 0.3s;
}
.store-container::-webkit-scrollbar-thumb:hover {
background: rgba(15, 23, 42, 0.15);
}
/* Header */
.store-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 28px;
font-weight: 800;
margin: 0;
background: linear-gradient(135deg, #0f172a 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-left p {
color: #64748b;
margin: 4px 0 0 0;
font-size: 14px;
}
/* Balance Card */
.balance-card {
position: relative;
background: #0f172a;
border-radius: 24px;
padding: 32px;
color: #fff;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.15);
display: flex;
flex-direction: column;
gap: 28px; /* 统一内部垂直间距 */
}
.balance-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 20px;
}
.balance-main {
/* 允许自然撑开 */
}
.balance-main .label {
font-size: 14px;
color: #94a3b8;
font-weight: 500;
margin-bottom: 12px;
display: block;
}
.balance-main .value {
font-size: 40px;
font-weight: 800;
display: flex;
align-items: center;
gap: 12px;
color: #fff;
line-height: 1.2;
flex-wrap: wrap; /* 允许图标和数字在极窄屏下换行 */
}
.balance-main .value span {
word-break: break-all;
}
.balance-info {
/* 移除 margin-top改由父容器 gap 控制 */
width: 100%;
}
.balance-main .value .el-icon {
color: #fbbf24;
font-size: 0.8em; /* 随字号缩放 */
}
.balance-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #cbd5e1;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot.warning { background: #fbbf24; }
.dot.success { background: #10b981; }
.balance-info {
position: relative;
z-index: 1;
}
.balance-info :deep(.el-alert) {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #94a3b8;
}
.card-glow {
position: absolute;
top: -50%;
right: -10%;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%);
pointer-events: none;
}
/* Section */
.store-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
/* Product Grid */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.product-card {
background: #fff;
border-radius: 24px;
padding: 28px;
border: 1px solid #e2e8f0;
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);
}
.product-card:hover {
transform: translateY(-8px);
box-shadow: 0 20px 30px -10px rgba(0, 0, 0, 0.15);
border-color: #3b82f6;
}
.product-badge {
position: absolute;
top: 16px;
right: 16px;
background: #3b82f6;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 4px 12px;
border-radius: 20px;
text-transform: uppercase;
}
.product-card.is-popular {
border-width: 2px;
border-color: #f59e0b;
background: linear-gradient(180deg, #fff 0%, #fffbeb 100%);
}
.product-card.is-popular .product-badge {
background: #f59e0b;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
}
.product-name {
font-size: 18px;
font-weight: 700;
margin: 0 0 8px 0;
color: #1e293b;
}
.product-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0;
}
.product-amount {
font-size: 32px;
font-weight: 800;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
}
.product-amount .el-icon {
color: #fbbf24;
font-size: 28px;
}
.product-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
}
.price {
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
/* History List */
.history-list {
background: #fff;
border-radius: 20px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.history-item {
display: flex;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
transition: background 0.2s;
}
.history-item:hover {
background: #f8fafc;
}
.history-item:last-child {
border-bottom: none;
}
.history-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.history-icon.recorded {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.history-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.history-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.history-top .source {
font-weight: 700;
color: #1e293b;
font-size: 14px;
}
.history-top .amount {
font-weight: 700;
color: #10b981;
font-size: 15px;
}
.history-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.history-bottom .desc {
font-size: 13px;
color: #64748b;
}
.history-bottom .time {
font-size: 11px;
color: #94a3b8;
}
.history-footer {
padding: 12px;
display: flex;
justify-content: center;
background: #fcfdfe;
}
</style>