feat:前端做了一些改善,以及主页demo
This commit is contained in:
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
BIN
frontend/src/assets/feature-ai.png
Normal file
BIN
frontend/src/assets/feature-ai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 515 KiB |
BIN
frontend/src/assets/feature-schedule.png
Normal file
BIN
frontend/src/assets/feature-schedule.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
BIN
frontend/src/assets/feature-tools.png
Normal file
BIN
frontend/src/assets/feature-tools.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
BIN
frontend/src/assets/hero-dashboard.png
Normal file
BIN
frontend/src/assets/hero-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
@@ -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)`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -109,9 +109,13 @@ 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 v-for="n in 8" :key="n" class="skeleton-pill" />
|
<div
|
||||||
|
v-for="slot in slotBlueprint"
|
||||||
|
:key="slot.key"
|
||||||
|
class="skeleton-pill"
|
||||||
|
:class="{ 'is-pause': slot.kind === 'pause' }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else key="content" class="pastel-grid">
|
<div v-else key="content" class="pastel-grid">
|
||||||
@@ -137,7 +141,6 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
</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); }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
967
frontend/src/views/ForumView.vue
Normal file
967
frontend/src/views/ForumView.vue
Normal 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>
|
||||||
610
frontend/src/views/HomeView.vue
Normal file
610
frontend/src/views/HomeView.vue
Normal 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>
|
||||||
868
frontend/src/views/PlanDetailView.vue
Normal file
868
frontend/src/views/PlanDetailView.vue
Normal 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>
|
||||||
672
frontend/src/views/StoreView.vue
Normal file
672
frontend/src/views/StoreView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user