Sfoglia il codice sorgente

# feat:基础布局搭建;完成网站基础信息的动态展示;

yangyi 6 giorni fa
parent
commit
bb7c1f70e8

+ 22 - 0
src/api/meta-controller.ts

@@ -0,0 +1,22 @@
+/**
+ * Generated by orval v7.0.1 🍺
+ * Do not edit manually.
+ * Serve API
+ * Serve应用接口文档
+ * OpenAPI spec version: 0.0.1-SNAPSHOT
+ */
+import type { Response } from "./models";
+import { customAxiosInstance } from "../util/axios-instance";
+
+export const getMetaController = () => {
+  const getWebsiteMeta = () => {
+    return customAxiosInstance<Response>({
+      url: `/meta/getWebsiteMeta`,
+      method: "GET",
+    });
+  };
+  return { getWebsiteMeta };
+};
+export type GetWebsiteMetaResult = NonNullable<
+  Awaited<ReturnType<ReturnType<typeof getMetaController>["getWebsiteMeta"]>>
+>;

+ 95 - 0
src/components/BottomNav.vue

@@ -0,0 +1,95 @@
+<template>
+  <div class="bottom-nav">
+    <div
+      v-for="item in tabs"
+      :key="item.path"
+      class="nav-item"
+      :class="{ active: activeTab === item.tab }"
+      @click="navigate(item)"
+    >
+      <el-icon :size="24"><component :is="item.icon" /></el-icon>
+      <span class="nav-label">{{ item.label }}</span>
+      <el-badge v-if="item.tab === 'notifications' && badge" :value="badge" :hidden="badge === 0" class="nav-badge" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { HomeFilled, ShoppingCart, Bell, User } from '@element-plus/icons-vue'
+import { useNotificationStore } from '../store'
+
+const route = useRoute()
+const router = useRouter()
+const notificationStore = useNotificationStore()
+
+const badge = computed(() => notificationStore.unreadCount)
+
+interface TabItem {
+  path: string
+  tab: string
+  label: string
+  icon: object
+}
+
+const tabs: TabItem[] = [
+  { path: '/', tab: 'home', label: '首页', icon: HomeFilled },
+  { path: '/orders', tab: 'orders', label: '我的订单', icon: ShoppingCart },
+  { path: '/notifications', tab: 'notifications', label: '我的信息', icon: Bell },
+  { path: '/profile', tab: 'profile', label: '我的账户', icon: User },
+]
+
+const activeTab = computed(() => route.meta?.tab as string || '')
+
+function navigate(item: TabItem) {
+  if (route.path !== item.path) {
+    router.push(item.path)
+  }
+}
+</script>
+
+<style scoped>
+.bottom-nav {
+  position: fixed;
+  bottom: 0;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 100%;
+  max-width: var(--app-max-width, 750px);
+  height: var(--bottom-nav-height, 50px);
+  display: flex;
+  align-items: center;
+  justify-content: space-around;
+  background: #fff;
+  border-top: 1px solid var(--color-border, #ebebeb);
+  padding-bottom: env(safe-area-inset-bottom);
+  z-index: 100;
+}
+.nav-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 2px;
+  cursor: pointer;
+  color: var(--color-text-secondary, #999);
+  position: relative;
+  flex: 1;
+  height: 100%;
+  transition: color 0.2s;
+}
+.nav-item.active {
+  color: var(--color-primary, #db2777);
+}
+.nav-label {
+  font-size: 10px;
+  line-height: 1;
+}
+.nav-badge {
+  position: absolute;
+  top: 2px;
+  right: 50%;
+  margin-right: -18px;
+}
+</style>

+ 7 - 0
src/components/EmptyState.vue

@@ -0,0 +1,7 @@
+<template>
+  <el-empty :description="description" :image-size="120" />
+</template>
+
+<script setup lang="ts">
+defineProps<{ description?: string }>()
+</script>

+ 71 - 0
src/components/ExpertInfoCard.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="expert-card">
+    <el-avatar :size="48" :src="expert.avatar">
+      {{ expert.name[0] }}
+    </el-avatar>
+    <div class="expert-info">
+      <div class="expert-name-row">
+        <span class="expert-name">{{ expert.name }}</span>
+        <el-tag v-if="expert.realnameVerified" size="small" type="success" effect="dark">已认证</el-tag>
+      </div>
+      <div class="expert-level">{{ levelLabel }}</div>
+      <p class="expert-brief">{{ expert.brief }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+const props = defineProps<{
+  expert: {
+    name: string
+    avatar: string
+    level: number
+    realnameVerified: boolean
+    brief: string
+  }
+}>()
+
+const levelLabel = computed(() => {
+  if (props.expert.level >= 80) return '大师'
+  if (props.expert.level >= 50) return '钻石'
+  if (props.expert.level >= 20) return '黄金'
+  return '初级'
+})
+</script>
+
+<style scoped>
+.expert-card {
+  display: flex;
+  gap: 12px;
+  padding: 16px;
+  background: var(--color-card, #fff);
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+.expert-info {
+  flex: 1;
+  min-width: 0;
+}
+.expert-name-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+.expert-name {
+  font-size: 16px;
+  font-weight: 600;
+}
+.expert-level {
+  font-size: 12px;
+  color: var(--color-text-secondary, #999);
+  margin-bottom: 4px;
+}
+.expert-brief {
+  font-size: 13px;
+  color: var(--color-text, #333);
+  line-height: 1.5;
+}
+</style>

+ 27 - 0
src/components/GlassBanner.vue

@@ -0,0 +1,27 @@
+<template>
+  <div v-if="text" class="glass-banner">
+    <p class="glass-text">{{ text }}</p>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineProps<{ text: string }>()
+</script>
+
+<style scoped>
+.glass-banner {
+  padding: 20px 16px;
+  background: rgba(219, 39, 119, 0.08);
+  backdrop-filter: blur(12px);
+  -webkit-backdrop-filter: blur(12px);
+  text-align: center;
+  border-radius: 0;
+  margin-bottom: 12px;
+}
+.glass-text {
+  font-size: 18px;
+  font-weight: 500;
+  color: var(--color-primary, #db2777);
+  letter-spacing: 1px;
+}
+</style>

+ 66 - 0
src/components/MarqueeNotice.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="marquee-wrapper" @click="showDialog = true">
+    <el-icon class="horn"><Notification /></el-icon>
+    <div class="marquee-track">
+      <span class="marquee-text">{{ noticeText }}</span>
+    </div>
+  </div>
+  <el-dialog v-model="showDialog" title="论坛声明" width="85%" :close-on-click-modal="false">
+    <p class="declaration-text">{{ noticeText }}</p>
+    <template #footer>
+      <el-button type="primary" @click="acknowledge">我已知晓</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { Notification } from '@element-plus/icons-vue'
+
+defineProps<{ noticeText: string }>()
+
+const showDialog = ref(false)
+
+function acknowledge() {
+  localStorage.setItem('declaration_acknowledged', 'true')
+  showDialog.value = false
+}
+</script>
+
+<style scoped>
+.marquee-wrapper {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 16px;
+  background: var(--color-primary-light, #fdf2f8);
+  cursor: pointer;
+  overflow: hidden;
+  margin-bottom: 12px;
+}
+.horn {
+  flex-shrink: 0;
+  color: var(--color-primary, #db2777);
+  font-size: 16px;
+}
+.marquee-track {
+  flex: 1;
+  overflow: hidden;
+  white-space: nowrap;
+}
+.marquee-text {
+  display: inline-block;
+  font-size: 13px;
+  color: var(--color-text, #333);
+  animation: marquee 12s linear infinite;
+}
+@keyframes marquee {
+  0% { transform: translateX(100%); }
+  100% { transform: translateX(-100%); }
+}
+.declaration-text {
+  font-size: 14px;
+  line-height: 1.8;
+  color: var(--color-text, #333);
+}
+</style>

+ 69 - 0
src/components/PayConfirm.vue

@@ -0,0 +1,69 @@
+<template>
+  <el-dialog
+    :model-value="visible"
+    title="确认打赏"
+    width="85%"
+    :close-on-click-modal="false"
+    @update:model-value="$emit('cancel')"
+  >
+    <div class="pay-info">
+      <div class="info-row">
+        <span class="label">帖子:</span>
+        <span class="value">{{ postTitle }}</span>
+      </div>
+      <div class="info-row">
+        <span class="label">打赏金额:</span>
+        <span class="value price">¥{{ amount }}</span>
+      </div>
+      <div class="info-row">
+        <span class="label">当前余额:</span>
+        <span class="value">¥{{ balance }}</span>
+      </div>
+    </div>
+    <template #footer>
+      <el-button @click="$emit('cancel')">取消</el-button>
+      <el-button type="primary" :loading="loading" @click="$emit('confirm')">确认支付</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+defineProps<{
+  visible: boolean
+  postTitle: string
+  amount: number
+  balance: number
+  loading: boolean
+}>()
+
+defineEmits<{
+  confirm: []
+  cancel: []
+}>()
+</script>
+
+<style scoped>
+.pay-info {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.info-row {
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+}
+.label {
+  color: var(--color-text-secondary, #999);
+  width: 80px;
+  flex-shrink: 0;
+}
+.value {
+  flex: 1;
+}
+.price {
+  color: var(--color-primary, #db2777);
+  font-weight: 600;
+  font-size: 18px;
+}
+</style>

+ 114 - 0
src/components/PostCard.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="post-card" @click="router.push(`/post/${post.id}`)">
+    <div class="card-header">
+      <h3 class="card-title">{{ post.title }}</h3>
+      <el-tag v-if="post.isTodayNew" size="small" type="danger" effect="dark">最新</el-tag>
+    </div>
+    <div class="card-meta">
+      <span class="expert-name">{{ post.expertName }}</span>
+      <span class="view-count">
+        <el-icon><View /></el-icon>
+        {{ post.viewCount }}
+      </span>
+    </div>
+    <div class="card-tags">
+      <el-tag v-for="tag in post.tags" :key="tag" size="small" class="tag">{{ tag }}</el-tag>
+    </div>
+    <div class="card-footer">
+      <span class="price">{{ post.price ? `¥${post.price}` : '免费' }}</span>
+      <span class="time">{{ relativeTime(post.publishTime) }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { PostItem } from '../type'
+import { View } from '@element-plus/icons-vue'
+import { useRouter } from 'vue-router'
+
+defineProps<{ post: PostItem }>()
+const router = useRouter()
+
+function relativeTime(t: string): string {
+  const diff = Date.now() - new Date(t).getTime()
+  const minutes = Math.floor(diff / 60000)
+  if (minutes < 1) return '刚刚'
+  if (minutes < 60) return `${minutes}分钟前`
+  const hours = Math.floor(minutes / 60)
+  if (hours < 24) return `${hours}小时前`
+  const days = Math.floor(hours / 24)
+  if (days < 30) return `${days}天前`
+  return new Date(t).toLocaleDateString()
+}
+</script>
+
+<style scoped>
+.post-card {
+  background: var(--color-card, #fff);
+  border-radius: 8px;
+  padding: 12px 16px;
+  margin-bottom: 10px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  cursor: pointer;
+  transition: box-shadow 0.2s;
+}
+.post-card:active {
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
+}
+.card-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+.card-title {
+  font-size: 15px;
+  font-weight: 600;
+  line-height: 1.4;
+  flex: 1;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+.card-meta {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  font-size: 12px;
+  color: var(--color-text-secondary, #999);
+  margin-bottom: 6px;
+}
+.expert-name {
+  color: var(--color-primary, #db2777);
+}
+.view-count {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
+.card-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-bottom: 8px;
+}
+.tag {
+  font-size: 11px;
+}
+.card-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+}
+.price {
+  color: var(--color-primary, #db2777);
+  font-weight: 600;
+}
+.time {
+  color: var(--color-text-secondary, #999);
+  font-size: 12px;
+}
+</style>

+ 24 - 5
src/layout/AdminLayout.vue

@@ -1,10 +1,10 @@
 <script setup lang="ts">
-import {useLoginUserStore} from "../store";
-import {computed, type ComputedRef, ref} from "vue";
+import {useLoginUserStore, useMetaStore} from "../store";
+import {computed, type ComputedRef, onMounted, ref, watch} from "vue";
 import type {RouteRecordRaw} from "vue-router";
 import router, {adminRoutes} from "../router";
 
-const title = ref<string>("标题");
+const { websiteMeta,  fetchMeta } = useMetaStore()
 const loginUserStore = useLoginUserStore()
 const login = computed(()=>loginUserStore.loginUser.isLogin)
 const loginHandler = () => {
@@ -17,6 +17,25 @@ const menus:ComputedRef<RouteRecordRaw[] | undefined> = computed(()=>{
   return adminRoutes[0].children;
 })
 const isCollapse = ref(false)
+
+onMounted(() => {
+  fetchMeta()
+})
+
+watch(() => websiteMeta.title, (val) => {
+  document.title = val
+}, { immediate: true })
+
+watch(() => websiteMeta.logo, (val) => {
+  let link = document.querySelector<HTMLLinkElement>("link[rel*='icon']")
+  if (!link) {
+    link = document.createElement('link')
+    link.rel = 'icon'
+    document.head.appendChild(link)
+  }
+  link.href = val
+}, { immediate: true })
+
 const handleOpen = (key: string, keyPath: string[]) => {
   console.log(key, keyPath)
 }
@@ -29,8 +48,8 @@ const handleClose = (key: string, keyPath: string[]) => {
   <el-container>
     <el-header class="header">
       <div class="header-left" @click="router.push('/')">
-        <img src="/logo.svg" alt="logo" class="logo">
-        <h2 class="title">{{title}}</h2>
+        <img :src="websiteMeta.logo" alt="logo" class="logo">
+        <h2 class="title">{{ websiteMeta.title }}</h2>
       </div>
       <h1>后台管理</h1>
       <div class="header-right">

+ 78 - 96
src/layout/UserLayout.vue

@@ -1,143 +1,125 @@
 <template>
   <div class="user-layout">
-    <el-container>
-      <el-header class="header">
-        <div class="header-left" @click="router.push('/')">
-          <img :src="metaStore.logo" alt="logo" class="logo">
-          <h2 class="title">{{metaStore.title}}</h2>
-        </div>
-        <el-menu
-            :default-active="activeIndex"
-            mode="horizontal"
-            router
-            @select="handleSelect"
-            class="nav-menu"
-        >
-          <el-menu-item :index="menu.path" v-for="menu in menus" :key="menu.path">
-            <el-icon><component :is="menu.meta?.icon"/></el-icon>
-            <template #title>{{ menu.meta?.title }}</template>
-          </el-menu-item>
-          <el-menu-item v-if="isAdmin" index="/admin">
-            <el-icon><Tools/></el-icon>
-            <template #title>后台管理</template>
-          </el-menu-item>
-        </el-menu>
-        <div class="header-right">
-          <template v-if="login">
-            <el-dropdown placement="bottom">
-              <el-button class="user-btn" text>
-                <el-icon class="user-avatar"><UserFilled /></el-icon>
-                <span class="username">{{ loginUserStore.loginUser?.user?.name }}</span>
-              </el-button>
-              <template #dropdown>
-                <el-dropdown-menu>
-                  <el-dropdown-item>个人信息</el-dropdown-item>
-                  <el-dropdown-item divided @click="logoutHandler">退出登录</el-dropdown-item>
-                </el-dropdown-menu>
-              </template>
-            </el-dropdown>
-          </template>
-          <template v-else>
-            <el-button type="primary" size="small" @click="loginHandler">登录</el-button>
-            <el-button size="small" @click="router.push('/login')">注册</el-button>
-          </template>
-        </div>
-      </el-header>
+    <header class="app-header">
+      <div class="header-left" @click="router.push('/')">
+        <img :src="websiteMeta.logo" alt="logo" class="logo">
+        <h1 class="title">{{ websiteMeta.title }}</h1>
+      </div>
+      <div class="header-right">
+        <template v-if="login">
+          <el-dropdown placement="bottom">
+            <el-button class="user-btn" text>
+              <el-avatar :size="28" :src="loginUserStore.userInfo.avatar || undefined">
+                <el-icon><UserFilled /></el-icon>
+              </el-avatar>
+              <span class="username">{{ loginUserStore.loginUser.user?.name }}</span>
+            </el-button>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item @click="router.push('/profile')">个人信息</el-dropdown-item>
+                <el-dropdown-item divided @click="logoutHandler">退出登录</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </template>
+        <template v-else>
+          <el-button type="primary" size="small" @click="router.push('/login')">登录</el-button>
+        </template>
+      </div>
+    </header>
 
-      <el-main>
-        <router-view/>
-      </el-main>
+    <main class="app-main">
+      <router-view />
+    </main>
 
-      <el-footer>Footer</el-footer>
-    </el-container>
+    <BottomNav />
   </div>
 </template>
 
 <script setup lang="ts">
-import {computed, ref} from 'vue'
-import {useLoginUserStore, useMetaStore} from "../store";
-import router, {userRoutes} from "../router";
-import {
-  Tools,
-  UserFilled
-} from '@element-plus/icons-vue'
-const metaStore = useMetaStore()
+import { computed, onMounted, watch } from 'vue'
+import {useLoginUserStore, useMetaStore} from "../store"
+import router from "../router"
+import { UserFilled } from '@element-plus/icons-vue'
+import BottomNav from '../components/BottomNav.vue'
+
+const { websiteMeta,  fetchMeta } = useMetaStore()
 const loginUserStore = useLoginUserStore()
 const login = computed(() => loginUserStore.loginUser.isLogin)
-const isAdmin = computed(() => loginUserStore.loginUser.user?.role === 'admin')
-const loginHandler = () => {
-  router.push('/login')
-}
-let activeIndex = ref('/')
-const menus = computed(()=>{
-  return userRoutes[0].children;
+
+onMounted(() => {
+  fetchMeta()
 })
-const handleSelect = () => {
-  //todo: 处理菜单选择
-}
+
+watch(() => websiteMeta.title, (val) => {
+  document.title = val
+}, { immediate: true })
+
+watch(() => websiteMeta.logo, (val) => {
+  let link = document.querySelector<HTMLLinkElement>("link[rel*='icon']")
+  if (!link) {
+    link = document.createElement('link')
+    link.rel = 'icon'
+    document.head.appendChild(link)
+  }
+  link.href = val
+}, { immediate: true })
+
 const logoutHandler = () => {
   loginUserStore.logoutUser()
   router.push('/')
 }
 </script>
+
 <style scoped>
 .user-layout {
   display: flex;
   flex-direction: column;
   min-height: 100vh;
+  padding-bottom: var(--bottom-nav-height, 50px);
 }
-el-main{
-  flex: 1;
-}
-.header {
+.app-header {
+  position: sticky;
+  top: 0;
+  z-index: 50;
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 0 20px;
+  padding: 8px 16px;
+  background: #fff;
+  border-bottom: 1px solid var(--color-border, #ebebeb);
 }
 .header-left {
   display: flex;
   align-items: center;
-  gap: 10px;
+  gap: 8px;
+  cursor: pointer;
 }
 .logo {
-  width: 40px;
-  height: 40px;
+  width: 28px;
+  height: 28px;
 }
 .title {
   margin: 0;
-  font-size: 20px;
-}
-.nav-menu {
-  flex: 1;
-  display: flex;
-  justify-content: center;
-  border-bottom: none;
+  font-size: 16px;
+  font-weight: 600;
 }
 .header-right {
   display: flex;
   align-items: center;
-  gap: 10px;
+  gap: 8px;
 }
 .username {
-  font-size: 14px;
-  color: #606266;
+  font-size: 13px;
+  color: var(--color-text, #333);
+  margin-left: 4px;
 }
-
 .user-btn {
   display: flex;
   align-items: center;
-  gap: 6px;
-  cursor: pointer;
-  transition: color 0.2s;
-}
-
-.user-btn:hover {
-  color: #db2777;
 }
-
-.user-avatar {
-  font-size: 20px;
-  color: #be185d;
+.app-main {
+  flex: 1;
+  overflow-y: auto;
 }
 </style>

+ 114 - 88
src/router/index.ts

@@ -1,106 +1,132 @@
-import {createRouter, createWebHistory, type Router, type RouteRecordRaw} from 'vue-router'
+import { createRouter, createWebHistory, type Router, type RouteRecordRaw } from 'vue-router'
 
 import HomeView from '../view/HomeView.vue'
-import AboutView from '../view/AboutView.vue'
+import PostDetailView from '../view/PostDetailView.vue'
+import OrdersView from '../view/OrdersView.vue'
+import NotificationsView from '../view/NotificationsView.vue'
+import ProfileView from '../view/ProfileView.vue'
+import WalletView from '../view/WalletView.vue'
+import EditProfileView from '../view/EditProfileView.vue'
+import StaticPageView from '../view/StaticPageView.vue'
 import LoginView from '../view/LoginView.vue'
 import UserLayout from '../layout/UserLayout.vue'
-import AdminLayout from "../layout/AdminLayout.vue";
-import UserView from "../view/UserView.vue";
+import AdminLayout from "../layout/AdminLayout.vue"
+import UserView from "../view/UserView.vue"
 import {
-    HomeFilled,
-    Menu,
-    User,
+  HomeFilled,
+  Menu,
+  User,
+  ShoppingCart,
+  Bell,
 } from '@element-plus/icons-vue'
-import {useLoginUserStore} from "../store";
-import {ElMessage} from "element-plus";
+import { useLoginUserStore } from "../store"
+import { ElMessage } from "element-plus"
 
-export const userRoutes:RouteRecordRaw[] = [
-    {
+export const userRoutes: RouteRecordRaw[] = [
+  {
+    path: '/',
+    name: 'commonLayout',
+    component: UserLayout,
+    children: [
+      {
         path: '/',
-        name: 'commonLayout',
-        component: UserLayout,
-        children: [
-            {
-                path: '/',
-                name: 'home',
-                component: HomeView,
-                meta: {
-                    title: '首页',
-                    icon: HomeFilled
-                }
-            },
-            {
-                path: '/about',
-                name: 'about',
-                component: AboutView,
-                meta: {
-                    title: '关于',
-                    icon: Menu
-                }
-            },
-        ],
-    },
+        name: 'home',
+        component: HomeView,
+        meta: { title: '首页', icon: HomeFilled, tab: 'home' }
+      },
+      {
+        path: '/orders',
+        name: 'orders',
+        component: OrdersView,
+        meta: { title: '我的订单', icon: ShoppingCart, tab: 'orders' }
+      },
+      {
+        path: '/notifications',
+        name: 'notifications',
+        component: NotificationsView,
+        meta: { title: '我的信息', icon: Bell, tab: 'notifications' }
+      },
+      {
+        path: '/profile',
+        name: 'profile',
+        component: ProfileView,
+        meta: { title: '我的账户', icon: User, tab: 'profile' }
+      },
+      {
+        path: '/post/:id',
+        name: 'postDetail',
+        component: PostDetailView,
+        meta: { title: '帖子详情' }
+      },
+      {
+        path: '/wallet',
+        name: 'wallet',
+        component: WalletView,
+        meta: { title: '钱包' }
+      },
+      {
+        path: '/edit-profile',
+        name: 'editProfile',
+        component: EditProfileView,
+        meta: { title: '修改个人信息' }
+      },
+      {
+        path: '/page/:type',
+        name: 'staticPage',
+        component: StaticPageView,
+        meta: { title: '' }
+      },
+    ],
+  },
 ]
-export const adminRoutes:RouteRecordRaw[] = [
-    {
-        path: '/admin',
-        name: 'adminLayout',
+
+export const adminRoutes: RouteRecordRaw[] = [
+  {
+    path: '/admin',
+    name: 'adminLayout',
+    component: AdminLayout,
+    children: [
+      {
+        path: '/admin/users',
+        name: 'users',
+        component: UserView,
+        meta: { title: '用户管理', icon: User, requiresAdmin: true }
+      },
+      {
+        path: '/admin/other',
+        name: 'other',
         component: AdminLayout,
-        children:[
-            {
-                path: '/admin/users',
-                name: 'users',
-                component: UserView,
-                meta:{
-                    title: '用户管理',
-                    icon: User,
-                    requiresAdmin: true
-                }
-            },
-            {
-                path:'/admin/other',
-                name:'other',
-                component: AdminLayout,
-                meta:{
-                    title: '其他',
-                    icon: Menu,
-                    requiresAdmin: true
-                }
-            }
-        ],
-        meta: {
-            requiresAdmin: true
-        }
-    }
+        meta: { title: '其他', icon: Menu, requiresAdmin: true }
+      }
+    ],
+    meta: { requiresAdmin: true }
+  }
 ]
 
-const router : Router = createRouter({
-    history: createWebHistory(),
-    routes: [
-        {
-            path: '/login',
-            name: 'login',
-            component: LoginView
-        },
-        ...userRoutes,
-        ...adminRoutes
-    ],
-});
+const router: Router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    { path: '/login', name: 'login', component: LoginView },
+    ...userRoutes,
+    ...adminRoutes
+  ],
+})
+
 router.beforeEach((to, _from, next) => {
-    if (to.matched.some(record => record.meta.requiresAdmin)) {
-        const loginUserStore = useLoginUserStore()
-        if (!loginUserStore.loginUser.isLogin) {
-            ElMessage.warning('请先登录')
-            next({path: '/login', query: {redirect: to.fullPath}})
-        } else if (loginUserStore.loginUser.user?.role !== 'admin') {
-            ElMessage.error('无权访问')
-            next('/')
-        } else {
-            next()
-        }
+  if (to.matched.some(record => record.meta.requiresAdmin)) {
+    const loginUserStore = useLoginUserStore()
+    if (!loginUserStore.loginUser.isLogin) {
+      ElMessage.warning('请先登录')
+      next({ path: '/login', query: { redirect: to.fullPath } })
+    } else if (loginUserStore.loginUser.user?.role !== 'admin') {
+      ElMessage.error('无权访问')
+      next('/')
     } else {
-        next()
+      next()
     }
+  } else {
+    next()
+  }
 })
 
 export default router

+ 5 - 2
src/store/index.ts

@@ -1,2 +1,5 @@
-export  {useMetaStore} from "./meta.ts";
-export  {useLoginUserStore} from "./user.ts";
+export { useMetaStore } from "./meta"
+export { useLoginUserStore } from "./user"
+export { usePostStore } from "./post"
+export { useOrderStore } from "./order"
+export { useNotificationStore } from "./notification"

+ 53 - 15
src/store/meta.ts

@@ -1,16 +1,54 @@
-import {defineStore} from "pinia";
-import {ref} from "vue";
-// 网站配置元数据
-export const useMetaStore = defineStore("meta",()=>{
-    //网站标题
-    const title = ref('标题');
-    //网站logo
-    const logo = ref('/logo.svg');
-    //后端地址
-    const baseUrl = ref('http://localhost:8080/');
-    return{
-        title,
-        logo,
-        baseUrl,
+import { defineStore } from "pinia"
+import { reactive, ref } from "vue"
+import { getMetaController } from "../api/meta-controller"
+
+interface WebsiteMeta {
+  title: string
+  logo: string
+  baseUrl: string
+  statement: string
+  announcement: string
+}
+
+export const useMetaStore = defineStore("meta", () => {
+  const websiteMeta = reactive<WebsiteMeta>({
+    title: '标题',
+    logo: '/logo.svg',
+    baseUrl: 'http://localhost:8080/',
+    statement: '声明',
+    announcement: '公告'
+  })
+
+  const loaded = ref(false)
+
+  function applyMeta(raw: unknown): void {
+    let parsed = raw
+    if (typeof parsed === 'string') {
+      try {
+        parsed = JSON.parse(parsed)
+      } catch {
+        return
+      }
     }
-},{persist: true,})
+    if (typeof parsed !== 'object' || parsed === null) return
+    const data = parsed as Partial<WebsiteMeta>
+    if (typeof data.title === 'string') websiteMeta.title = data.title
+    if (typeof data.logo === 'string') websiteMeta.logo = data.logo
+    if (typeof data.baseUrl === 'string') websiteMeta.baseUrl = data.baseUrl
+    if (typeof data.statement === 'string') websiteMeta.statement = data.statement
+    if (typeof data.announcement === 'string') websiteMeta.announcement = data.announcement
+    loaded.value = true
+  }
+
+  async function fetchMeta(): Promise<void> {
+    try {
+      const res = await getMetaController().getWebsiteMeta()
+      applyMeta(res.data)
+    } catch (e) {
+      console.error('获取网站元数据失败:', e)
+      loaded.value = true
+    }
+  }
+
+  return { websiteMeta, loaded, fetchMeta, applyMeta }
+}, { persist: true })

+ 29 - 0
src/store/notification.ts

@@ -0,0 +1,29 @@
+import { defineStore } from "pinia"
+import { ref } from "vue"
+import type { NotificationItem } from "../type"
+
+export const useNotificationStore = defineStore('notification', () => {
+  const messages = ref<NotificationItem[]>([])
+  const unreadCount = ref(0)
+  const loading = ref(false)
+
+  function fetchNotifications(): void {
+    loading.value = true
+    // Placeholder
+    setTimeout(() => {
+      messages.value = []
+      unreadCount.value = 0
+      loading.value = false
+    }, 300)
+  }
+
+  function markRead(id: string): void {
+    const msg = messages.value.find(m => m.id === id)
+    if (msg && !msg.isRead) {
+      msg.isRead = true
+      unreadCount.value = Math.max(0, unreadCount.value - 1)
+    }
+  }
+
+  return { messages, unreadCount, loading, fetchNotifications, markRead }
+})

+ 32 - 0
src/store/order.ts

@@ -0,0 +1,32 @@
+import { defineStore } from "pinia"
+import { ref } from "vue"
+import type { OrderItem } from "../type"
+
+export const useOrderStore = defineStore('order', () => {
+  const orders = ref<OrderItem[]>([])
+  const activeFilter = ref('全部')
+  const loading = ref(false)
+
+  function fetchOrders(filter?: string): void {
+    if (filter !== undefined) activeFilter.value = filter
+    loading.value = true
+    // Placeholder
+    setTimeout(() => {
+      orders.value = []
+      loading.value = false
+    }, 300)
+  }
+
+  function createAndPayOrder(_postId: string): Promise<boolean> {
+    return new Promise((resolve) => {
+      // Placeholder
+      setTimeout(() => resolve(true), 500)
+    })
+  }
+
+  function cancelOrder(_id: string): void {
+    // Placeholder
+  }
+
+  return { orders, activeFilter, loading, fetchOrders, createAndPayOrder, cancelOrder }
+})

+ 40 - 0
src/store/post.ts

@@ -0,0 +1,40 @@
+import { defineStore } from "pinia"
+import { reactive, ref } from "vue"
+import type { PostItem, PostDetail, Pagination } from "../type"
+
+export const usePostStore = defineStore('post', () => {
+  const posts = ref<PostItem[]>([])
+  const currentDetail = ref<PostDetail | null>(null)
+  const currentStatus = ref('全部')
+  const searchKeyword = ref('')
+  const pagination = reactive<Pagination>({ page: 1, pageSize: 10, total: 0 })
+  const loading = ref(false)
+
+  function fetchPosts(status?: string, keyword?: string): void {
+    if (status !== undefined) currentStatus.value = status
+    if (keyword !== undefined) searchKeyword.value = keyword
+    loading.value = true
+    // Placeholder — replace with actual API call
+    setTimeout(() => {
+      posts.value = []
+      pagination.total = 0
+      loading.value = false
+    }, 300)
+  }
+
+  function fetchPostDetail(_id: string): void {
+    loading.value = true
+    // Placeholder
+    setTimeout(() => {
+      currentDetail.value = null
+      loading.value = false
+    }, 300)
+  }
+
+  function loadMore(): void {
+    pagination.page++
+    fetchPosts()
+  }
+
+  return { posts, currentDetail, currentStatus, searchKeyword, pagination, loading, fetchPosts, fetchPostDetail, loadMore }
+})

+ 86 - 46
src/store/user.ts

@@ -1,49 +1,89 @@
-import {defineStore} from "pinia";
-import type {LoginUser, User} from "../type";
-import {computed, reactive} from "vue";
-
-export const useLoginUserStore = defineStore('loginUser', ()=>{
-    //state
-    const loginUser = reactive<LoginUser>({
-        isLogin: false,
-        user:{
-            id:"",
-            name:"未登录",
-            role:"guest"
-        }
-    })
-    //getters
-    const userId = computed(()=>{
-        return loginUser.user?.id
-    })
-    //actions
-    function updateUser(user:User):void {
-        loginUser.user = {...user}
+import { defineStore } from "pinia"
+import { computed, reactive, ref } from "vue"
+import type { LoginUser, User, UserProfile } from "../type"
+
+export const useLoginUserStore = defineStore('loginUser', () => {
+  const loginUser = reactive<LoginUser>({
+    isLogin: false,
+    user: {
+      id: "",
+      name: "未登录",
+      role: "guest"
     }
-    function loginSuccess(user:User):void {
-        updateUser(user)
-        loginUser.isLogin = true;
+  })
+
+  const userInfo = reactive<UserProfile>({
+    id: '',
+    username: '',
+    avatar: '',
+    mobile: '',
+    role: 'guest',
+    level: 0,
+    balance: 0,
+    isRealname: false
+  })
+
+  const token = ref('')
+
+  const userId = computed(() => loginUser.user?.id)
+  const isLoggedIn = computed(() => loginUser.isLogin)
+  const levelBadge = computed(() => {
+    if (userInfo.level >= 80) return '大师'
+    if (userInfo.level >= 50) return '钻石'
+    if (userInfo.level >= 20) return '黄金'
+    return '初级'
+  })
+
+  function updateUser(user: User): void {
+    loginUser.user = { ...user }
+  }
+
+  function loginSuccess(user: User, t?: string): void {
+    updateUser(user)
+    loginUser.isLogin = true
+    if (t) {
+      token.value = t
+      localStorage.setItem('token', t)
     }
-    function logoutUser():void {
-        loginUser.isLogin = false
-        loginUser.user = {
-            id: '',
-            name: '未登录',
-            role: 'guest'
-        }
-        localStorage.removeItem('token')
+  }
+
+  function logoutUser(): void {
+    loginUser.isLogin = false
+    loginUser.user = { id: '', name: '未登录', role: 'guest' }
+    userInfo.id = ''
+    userInfo.username = ''
+    userInfo.avatar = ''
+    userInfo.role = 'guest'
+    userInfo.level = 0
+    userInfo.balance = 0
+    userInfo.mobile = ''
+    userInfo.isRealname = false
+    token.value = ''
+    localStorage.removeItem('token')
+  }
+
+  function fetchUserInfo(data: Partial<UserProfile>): void {
+    Object.assign(userInfo, data)
+    if (data.id) {
+      loginUser.user!.id = data.id
+      loginUser.user!.name = data.username || ''
+      loginUser.user!.avatar = data.avatar || ''
+      loginUser.user!.role = data.role as User['role']
+      loginUser.isLogin = true
     }
-   return{
-        //States
-       loginUser,
-       //Getters
-       userId,
-       //Actions
-       updateUser,
-       loginSuccess,
-       logoutUser,
-   }
-},{
-    // ✅ 启用持久化
-    persist: true
-})
+  }
+
+  function updateBalance(amount: number): void {
+    userInfo.balance += amount
+  }
+
+  function updateProfile(data: Partial<UserProfile>): void {
+    Object.assign(userInfo, data)
+  }
+
+  return {
+    loginUser, userInfo, token,
+    userId, isLoggedIn, levelBadge,
+    updateUser, loginSuccess, logoutUser, fetchUserInfo, updateBalance, updateProfile
+  }
+}, { persist: true })

+ 23 - 60
src/style.css

@@ -1,79 +1,42 @@
 :root {
+  --app-max-width: 750px;
+  --bottom-nav-height: 50px;
+  --color-primary: #db2777;
+  --color-primary-light: #fdf2f8;
+  --color-bg: #f5f5f5;
+  --color-card: #ffffff;
+  --color-text: #333333;
+  --color-text-secondary: #999999;
+  --color-border: #ebebeb;
   font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+  font-size: 14px;
   line-height: 1.5;
-  font-weight: 400;
-
-  color-scheme: light dark;
-  color: rgba(255, 255, 255, 0.87);
-  background-color: #242424;
-
-  font-synthesis: none;
-  text-rendering: optimizeLegibility;
+  color: var(--color-text);
+  background-color: var(--color-bg);
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
 }
 
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-a:hover {
-  color: #535bf2;
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
 }
 
 body {
   margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
   min-height: 100vh;
 }
 
-h1 {
-  font-size: 3.2em;
-  line-height: 1.1;
-}
-
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  background-color: #1a1a1a;
-  cursor: pointer;
-  transition: border-color 0.25s;
-}
-button:hover {
-  border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
-  outline: 4px auto -webkit-focus-ring-color;
-}
-
-.card {
-  padding: 2em;
-}
-
 #app {
-  max-width: 1280px;
+  max-width: var(--app-max-width);
   margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
+  min-height: 100vh;
+  background-color: var(--color-card);
+  position: relative;
 }
 
-@media (prefers-color-scheme: light) {
-  :root {
-    color: #213547;
-    background-color: #ffffff;
-  }
-  a:hover {
-    color: #747bff;
-  }
-  button {
-    background-color: #f9f9f9;
-  }
+a {
+  color: inherit;
+  text-decoration: none;
 }

+ 93 - 11
src/type/index.ts

@@ -1,11 +1,93 @@
-export interface User{
-    id:string,
-    name:string,
-    avatar?: string
-    role: 'admin' | 'user' | 'guest'
-}
-export interface LoginUser{
-    isLogin:boolean,
-    token?:string
-    user?:User
-}
+export interface User {
+  id: string
+  name: string
+  avatar?: string
+  role: 'admin' | 'expert' | 'user' | 'guest'
+}
+
+export interface LoginUser {
+  isLogin: boolean
+  token?: string
+  user?: User
+}
+
+export interface UserProfile {
+  id: string
+  username: string
+  avatar: string
+  mobile: string
+  role: string
+  level: number
+  balance: number
+  isRealname: boolean
+}
+
+export interface PostItem {
+  id: string
+  title: string
+  expertName: string
+  expertAvatar: string
+  expertLevel: number
+  tags: string[]
+  price: number
+  publishTime: string
+  viewCount: number
+  status: string
+  isTodayNew: boolean
+}
+
+export interface PostDetail {
+  id: string
+  title: string
+  content: string
+  disclaimer: string
+  payContent: string
+  price: number
+  isPaid: boolean
+  expert: {
+    name: string
+    avatar: string
+    level: number
+    realnameVerified: boolean
+    brief: string
+  }
+  tags: string[]
+  previousPosts: Array<{
+    id: string
+    title: string
+    date: string
+    hit: boolean
+  }>
+}
+
+export interface OrderItem {
+  id: string
+  postTitle: string
+  expertName: string
+  amount: number
+  status: string
+  createdAt: string
+  expireAt?: string
+}
+
+export interface NotificationItem {
+  id: string
+  type: string
+  title: string
+  summary: string
+  timestamp: string
+  isRead: boolean
+}
+
+export interface TransactionItem {
+  id: string
+  time: string
+  amount: number
+  type: string
+}
+
+export interface Pagination {
+  page: number
+  pageSize: number
+  total: number
+}

+ 107 - 0
src/view/EditProfileView.vue

@@ -0,0 +1,107 @@
+<template>
+  <div class="edit-profile-page">
+    <el-form ref="formRef" :model="form" label-width="100px" class="edit-form">
+      <el-form-item label="头像">
+        <el-upload
+          class="avatar-uploader"
+          :show-file-list="false"
+          accept="image/*"
+          :before-upload="beforeUpload"
+        >
+          <el-avatar :size="64" :src="form.avatar || undefined">
+            {{ userStore.loginUser.user?.name?.[0] || 'U' }}
+          </el-avatar>
+        </el-upload>
+      </el-form-item>
+
+      <el-form-item label="用户名称" prop="username">
+        <el-input v-model="form.username" placeholder="请输入名称" />
+      </el-form-item>
+
+      <el-form-item label="手机号码" prop="mobile">
+        <el-input v-model="form.mobile" placeholder="请输入手机号码" />
+      </el-form-item>
+
+      <el-form-item label="实名认证">
+        <el-tag v-if="userStore.userInfo.isRealname" type="success">已认证</el-tag>
+        <el-button v-else size="small" type="primary" plain>去认证</el-button>
+      </el-form-item>
+
+      <el-divider />
+
+      <h4 class="section-title">修改密码</h4>
+
+      <el-form-item label="原密码">
+        <el-input v-model="passwordForm.oldPassword" type="password" show-password />
+      </el-form-item>
+      <el-form-item label="新密码">
+        <el-input v-model="passwordForm.newPassword" type="password" show-password />
+      </el-form-item>
+      <el-form-item label="确认密码">
+        <el-input v-model="passwordForm.confirmPassword" type="password" show-password />
+      </el-form-item>
+
+      <el-form-item>
+        <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useLoginUserStore } from '../store'
+
+const userStore = useLoginUserStore()
+const formRef = ref()
+const saving = ref(false)
+
+const form = reactive({
+  username: '',
+  avatar: '',
+  mobile: ''
+})
+
+const passwordForm = reactive({
+  oldPassword: '',
+  newPassword: '',
+  confirmPassword: ''
+})
+
+function beforeUpload(_file: File): boolean {
+  return true
+}
+
+function handleSave() {
+  saving.value = true
+  setTimeout(() => {
+    userStore.updateProfile({ username: form.username, avatar: form.avatar, mobile: form.mobile })
+    if (userStore.loginUser.user) {
+      userStore.loginUser.user.name = form.username
+    }
+    ElMessage.success('保存成功')
+    saving.value = false
+  }, 500)
+}
+
+onMounted(() => {
+  form.username = userStore.userInfo.username || userStore.loginUser.user?.name || ''
+  form.avatar = userStore.userInfo.avatar
+  form.mobile = userStore.userInfo.mobile
+})
+</script>
+
+<style scoped>
+.edit-profile-page {
+  padding: 16px;
+}
+.edit-form {
+  max-width: 400px;
+}
+.section-title {
+  font-size: 14px;
+  font-weight: 600;
+  margin-bottom: 12px;
+}
+</style>

+ 103 - 4
src/view/HomeView.vue

@@ -1,12 +1,111 @@
 <template>
-<el-text>
-  这是基于Vite + Vue + TypeScript构建的前端模板
-</el-text>
+  <div class="home-page">
+    <GlassBanner :text="websiteMeta.announcement" />
+    <MarqueeNotice
+      v-if="noticeText"
+      :notice-text="noticeText"
+    />
+
+    <div class="search-bar">
+      <el-input
+        v-model="keyword"
+        placeholder="搜索帖子"
+        clearable
+        class="search-input"
+        @keyup.enter="doSearch"
+      >
+        <template #prefix>
+          <el-icon><Search /></el-icon>
+        </template>
+      </el-input>
+      <el-select v-model="statusFilter" class="status-select" @change="doSearch">
+        <el-option label="全部" value="全部" />
+        <el-option label="公开" value="公开" />
+        <el-option label="在售" value="在售" />
+        <el-option label="命中" value="命中" />
+        <el-option label="未命中" value="未命中" />
+      </el-select>
+    </div>
+
+    <div v-loading="postStore.loading" class="post-list">
+      <PostCard v-for="post in postStore.posts" :key="post.id" :post="post" />
+      <EmptyState v-if="!postStore.loading && postStore.posts.length === 0" description="暂无帖子" />
+      <div v-if="postStore.posts.length > 0 && hasMore" class="load-more" @click="postStore.loadMore()">
+        加载更多
+      </div>
+    </div>
+
+    <el-dialog v-model="showDeclaration" title="论坛声明" width="85%" :close-on-click-modal="false">
+      <p class="declaration-text">{{ noticeText || '欢迎使用咕咕嘎嘎论坛,请遵守相关法律法规。' }}</p>
+      <template #footer>
+        <el-button type="primary" @click="acknowledgeDeclaration">我已知晓</el-button>
+      </template>
+    </el-dialog>
+  </div>
 </template>
 
 <script setup lang="ts">
+import { ref, onMounted, computed } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+import {useMetaStore, usePostStore} from '../store'
+import PostCard from '../components/PostCard.vue'
+import GlassBanner from '../components/GlassBanner.vue'
+import MarqueeNotice from '../components/MarqueeNotice.vue'
+import EmptyState from '../components/EmptyState.vue'
+
+const postStore = usePostStore()
+const { websiteMeta } = useMetaStore()
+const keyword = ref('')
+const statusFilter = ref('全部')
+const showDeclaration = ref(false)
+const noticeText = computed(() => websiteMeta.statement ? `${websiteMeta.title} — ${websiteMeta.statement}` : '')
+const hasMore = computed(() => postStore.pagination.page * postStore.pagination.pageSize < postStore.pagination.total)
+
+function doSearch() {
+  postStore.fetchPosts(statusFilter.value, keyword.value)
+}
+
+function acknowledgeDeclaration() {
+  localStorage.setItem('declaration_acknowledged', 'true')
+  showDeclaration.value = false
+}
+
+onMounted(() => {
+  postStore.fetchPosts()
+  if (!localStorage.getItem('declaration_acknowledged')) {
+    showDeclaration.value = true
+  }
+})
 </script>
 
 <style scoped>
-
+.home-page {
+  padding: 0;
+}
+.search-bar {
+  display: flex;
+  gap: 8px;
+  padding: 0 16px 12px;
+}
+.search-input {
+  flex: 1;
+}
+.status-select {
+  width: 100px;
+}
+.post-list {
+  padding: 0 16px;
+  min-height: 200px;
+}
+.load-more {
+  text-align: center;
+  padding: 12px;
+  color: var(--color-primary, #db2777);
+  font-size: 13px;
+  cursor: pointer;
+}
+.declaration-text {
+  font-size: 14px;
+  line-height: 1.8;
+}
 </style>

+ 92 - 0
src/view/NotificationsView.vue

@@ -0,0 +1,92 @@
+<template>
+  <div class="notifications-page">
+    <div v-loading="notifStore.loading" class="notif-list">
+      <div
+        v-for="msg in notifStore.messages"
+        :key="msg.id"
+        class="notif-item"
+        :class="{ unread: !msg.isRead }"
+        @click="notifStore.markRead(msg.id)"
+      >
+        <div class="notif-dot" v-if="!msg.isRead" />
+        <div class="notif-content">
+          <div class="notif-header">
+            <span class="notif-title">{{ msg.title }}</span>
+            <span class="notif-time">{{ msg.timestamp }}</span>
+          </div>
+          <p class="notif-summary">{{ msg.summary }}</p>
+        </div>
+      </div>
+      <EmptyState v-if="!notifStore.loading && notifStore.messages.length === 0" description="暂无任何消息通知" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+import { useNotificationStore } from '../store'
+import EmptyState from '../components/EmptyState.vue'
+
+const notifStore = useNotificationStore()
+
+onMounted(() => {
+  notifStore.fetchNotifications()
+})
+</script>
+
+<style scoped>
+.notifications-page {
+  padding: 0;
+}
+.notif-list {
+  padding: 0 16px;
+}
+.notif-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  padding: 14px 0;
+  border-bottom: 1px solid var(--color-border, #ebebeb);
+  cursor: pointer;
+}
+.notif-item.unread {
+  background: var(--color-primary-light, #fdf2f8);
+  margin: 0 -16px;
+  padding: 14px 16px;
+}
+.notif-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--color-primary, #db2777);
+  flex-shrink: 0;
+  margin-top: 6px;
+}
+.notif-content {
+  flex: 1;
+  min-width: 0;
+}
+.notif-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 4px;
+}
+.notif-title {
+  font-size: 14px;
+  font-weight: 600;
+}
+.notif-time {
+  font-size: 11px;
+  color: var(--color-text-secondary, #999);
+}
+.notif-summary {
+  font-size: 13px;
+  color: var(--color-text-secondary, #666);
+  line-height: 1.4;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+</style>

+ 109 - 0
src/view/OrdersView.vue

@@ -0,0 +1,109 @@
+<template>
+  <div class="orders-page">
+    <div class="filter-bar">
+      <el-radio-group v-model="activeFilter" @change="doFilter">
+        <el-radio-button value="全部">全部</el-radio-button>
+        <el-radio-button value="未支付">未支付</el-radio-button>
+        <el-radio-button value="已取消">已取消</el-radio-button>
+        <el-radio-button value="已完成">已完成</el-radio-button>
+      </el-radio-group>
+    </div>
+
+    <div v-loading="orderStore.loading" class="order-list">
+      <div v-for="order in orderStore.orders" :key="order.id" class="order-card">
+        <div class="order-top">
+          <span class="order-title">{{ order.postTitle }}</span>
+          <el-tag :type="statusType(order.status)" size="small">{{ order.status }}</el-tag>
+        </div>
+        <div class="order-meta">
+          <span>{{ order.expertName }}</span>
+          <span class="order-amount">¥{{ order.amount }}</span>
+        </div>
+        <div class="order-bottom">
+          <span class="order-time">{{ order.createdAt }}</span>
+          <el-button v-if="order.status === '未支付'" size="small" type="primary">去支付</el-button>
+        </div>
+      </div>
+      <EmptyState v-if="!orderStore.loading && orderStore.orders.length === 0" description="暂无订单" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useOrderStore } from '../store'
+import EmptyState from '../components/EmptyState.vue'
+
+const orderStore = useOrderStore()
+const activeFilter = ref('全部')
+
+function doFilter() {
+  orderStore.fetchOrders(activeFilter.value)
+}
+
+function statusType(status: string): string {
+  if (status === '已完成') return 'success'
+  if (status === '未支付') return 'warning'
+  if (status === '已取消') return 'info'
+  return ''
+}
+
+onMounted(() => {
+  orderStore.fetchOrders()
+})
+</script>
+
+<style scoped>
+.orders-page {
+  padding: 0;
+}
+.filter-bar {
+  padding: 12px 16px;
+  overflow-x: auto;
+}
+.order-list {
+  padding: 0 16px;
+}
+.order-card {
+  background: var(--color-card, #fff);
+  border-radius: 8px;
+  padding: 12px 16px;
+  margin-bottom: 10px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+.order-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 6px;
+}
+.order-title {
+  font-size: 14px;
+  font-weight: 600;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-right: 8px;
+}
+.order-meta {
+  display: flex;
+  justify-content: space-between;
+  font-size: 13px;
+  color: var(--color-text-secondary, #999);
+  margin-bottom: 6px;
+}
+.order-amount {
+  color: var(--color-primary, #db2777);
+  font-weight: 600;
+}
+.order-bottom {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.order-time {
+  font-size: 12px;
+  color: var(--color-text-secondary, #999);
+}
+</style>

+ 190 - 0
src/view/PostDetailView.vue

@@ -0,0 +1,190 @@
+<template>
+  <div v-loading="postStore.loading" class="detail-page">
+    <template v-if="detail">
+      <ExpertInfoCard :expert="detail.expert" />
+
+      <div class="section">
+        <h2 class="post-title">{{ detail.title }}</h2>
+        <div class="post-content" v-html="detail.content"></div>
+        <el-alert v-if="detail.disclaimer" :title="detail.disclaimer" type="warning" :closable="false" show-icon class="disclaimer" />
+      </div>
+
+      <div class="section tags-section">
+        <el-tag v-for="tag in detail.tags" :key="tag" class="tag">{{ tag }}</el-tag>
+      </div>
+
+      <div class="section pay-section">
+        <div v-if="detail.isPaid" class="pay-content" v-html="detail.payContent"></div>
+        <div v-else class="pay-cover">
+          <el-icon class="lock-icon"><Lock /></el-icon>
+          <p class="pay-tip">付费后可查看完整内容</p>
+          <p class="pay-price">¥{{ detail.price }}</p>
+          <el-button type="primary" :loading="paying" @click="handlePay">立即打赏</el-button>
+        </div>
+      </div>
+
+      <div v-if="detail.previousPosts.length" class="section">
+        <h3 class="section-title">往期命中</h3>
+        <div v-for="item in detail.previousPosts" :key="item.id" class="prev-item">
+          <div class="prev-info">
+            <span class="prev-title">{{ item.title }}</span>
+            <span class="prev-date">{{ item.date }}</span>
+          </div>
+          <el-tag :type="item.hit ? 'success' : 'info'" size="small">
+            {{ item.hit ? '已命中' : '未命中' }}
+          </el-tag>
+        </div>
+      </div>
+    </template>
+
+    <EmptyState v-if="!postStore.loading && !detail" description="帖子不存在" />
+
+    <PayConfirm
+      :visible="showPay"
+      :post-title="detail?.title || ''"
+      :amount="detail?.price || 0"
+      :balance="userStore.userInfo.balance"
+      :loading="paying"
+      @confirm="confirmPay"
+      @cancel="showPay = false"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { Lock } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import { usePostStore, useLoginUserStore } from '../store'
+import ExpertInfoCard from '../components/ExpertInfoCard.vue'
+import PayConfirm from '../components/PayConfirm.vue'
+import EmptyState from '../components/EmptyState.vue'
+
+const route = useRoute()
+const postStore = usePostStore()
+const userStore = useLoginUserStore()
+const showPay = ref(false)
+const paying = ref(false)
+
+const detail = computed(() => postStore.currentDetail)
+
+function handlePay() {
+  if (!userStore.isLoggedIn) {
+    ElMessage.warning('请先登录')
+    return
+  }
+  if (!detail.value) return
+  if (userStore.userInfo.balance < detail.value.price) {
+    ElMessage.warning('余额不足,请先充值')
+    return
+  }
+  showPay.value = true
+}
+
+async function confirmPay() {
+  if (!detail.value) return
+  paying.value = true
+  try {
+    await postStore.fetchPostDetail(route.params.id as string)
+    ElMessage.success('打赏成功')
+    showPay.value = false
+  } catch {
+    ElMessage.error('支付失败')
+  } finally {
+    paying.value = false
+  }
+}
+
+onMounted(() => {
+  postStore.fetchPostDetail(route.params.id as string)
+})
+</script>
+
+<style scoped>
+.detail-page {
+  padding: 0 16px 16px;
+}
+.section {
+  margin-top: 12px;
+  padding: 16px;
+  background: var(--color-card, #fff);
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+.post-title {
+  font-size: 18px;
+  font-weight: 600;
+  margin-bottom: 12px;
+}
+.post-content {
+  font-size: 14px;
+  line-height: 1.8;
+  color: var(--color-text, #333);
+}
+.disclaimer {
+  margin-top: 12px;
+}
+.tags-section {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  padding: 12px 16px;
+}
+.tag {
+  font-size: 12px;
+}
+.pay-section {
+  text-align: center;
+}
+.pay-cover {
+  padding: 24px 0;
+}
+.lock-icon {
+  font-size: 40px;
+  color: var(--color-text-secondary, #999);
+  margin-bottom: 12px;
+}
+.pay-tip {
+  font-size: 14px;
+  color: var(--color-text-secondary, #999);
+  margin-bottom: 8px;
+}
+.pay-price {
+  font-size: 24px;
+  color: var(--color-primary, #db2777);
+  font-weight: 700;
+  margin-bottom: 16px;
+}
+.pay-content {
+  font-size: 14px;
+  line-height: 1.8;
+}
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 12px;
+}
+.prev-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 0;
+  border-bottom: 1px solid var(--color-border, #ebebeb);
+}
+.prev-item:last-child {
+  border-bottom: none;
+}
+.prev-info {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.prev-title {
+  font-size: 13px;
+}
+.prev-date {
+  font-size: 11px;
+  color: var(--color-text-secondary, #999);
+}
+</style>

+ 146 - 0
src/view/ProfileView.vue

@@ -0,0 +1,146 @@
+<template>
+  <div class="profile-page">
+    <div class="user-card">
+      <el-avatar :size="56" :src="userStore.userInfo.avatar">
+        {{ userStore.loginUser.user?.name?.[0] || 'U' }}
+      </el-avatar>
+      <div class="user-detail">
+        <div class="user-name-row">
+          <span class="user-name">{{ userStore.loginUser.user?.name || '未登录' }}</span>
+          <el-tag size="small" type="warning">{{ userStore.levelBadge }}</el-tag>
+        </div>
+        <div class="user-id-row">
+          <span class="user-id">ID: {{ userStore.userInfo.id || userStore.loginUser.user?.id || '-' }}</span>
+          <el-button text size="small" @click="copyId">复制</el-button>
+        </div>
+      </div>
+    </div>
+
+    <div class="menu-group">
+      <div class="menu-item" @click="router.push('/wallet')">
+        <el-icon><Wallet /></el-icon>
+        <span>钱包</span>
+        <el-icon class="arrow"><ArrowRight /></el-icon>
+      </div>
+      <div class="menu-item" @click="router.push('/edit-profile')">
+        <el-icon><Edit /></el-icon>
+        <span>修改我的信息</span>
+        <el-icon class="arrow"><ArrowRight /></el-icon>
+      </div>
+      <div class="menu-item" @click="router.push('/page/privacy')">
+        <el-icon><Document /></el-icon>
+        <span>隐私协议</span>
+        <el-icon class="arrow"><ArrowRight /></el-icon>
+      </div>
+      <div class="menu-item" @click="router.push('/page/complaint')">
+        <el-icon><Warning /></el-icon>
+        <span>投诉反馈</span>
+        <el-icon class="arrow"><ArrowRight /></el-icon>
+      </div>
+      <div class="menu-item" @click="router.push('/page/contact')">
+        <el-icon><Phone /></el-icon>
+        <span>联系客服</span>
+        <el-icon class="arrow"><ArrowRight /></el-icon>
+      </div>
+      <div class="menu-item" @click="router.push('/page/faq')">
+        <el-icon><QuestionFilled /></el-icon>
+        <span>常见问题</span>
+        <el-icon class="arrow"><ArrowRight /></el-icon>
+      </div>
+      <div class="menu-item">
+        <el-icon><Setting /></el-icon>
+        <span>设置</span>
+        <el-icon class="arrow"><ArrowRight /></el-icon>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ElMessage } from 'element-plus'
+import { useLoginUserStore } from '../store'
+import { useRouter } from 'vue-router'
+import {
+  Wallet, Edit, Document, Warning, Phone, QuestionFilled, Setting, ArrowRight
+} from '@element-plus/icons-vue'
+
+const userStore = useLoginUserStore()
+const router = useRouter()
+
+function copyId() {
+  const id = userStore.userInfo.id || userStore.loginUser.user?.id
+  if (id) {
+    navigator.clipboard.writeText(id).then(() => {
+      ElMessage.success('已复制')
+    })
+  }
+}
+</script>
+
+<style scoped>
+.profile-page {
+  padding: 0;
+}
+.user-card {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 24px 16px;
+  background: linear-gradient(135deg, var(--color-primary, #db2777), #be185d);
+  color: #fff;
+}
+.user-detail {
+  flex: 1;
+  min-width: 0;
+}
+.user-name-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+.user-name {
+  font-size: 18px;
+  font-weight: 600;
+}
+.user-id-row {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 12px;
+  opacity: 0.8;
+}
+.user-id-row :deep(.el-button) {
+  color: #fff;
+  opacity: 0.8;
+}
+.menu-group {
+  padding: 8px 16px;
+}
+.menu-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 14px 0;
+  border-bottom: 1px solid var(--color-border, #ebebeb);
+  cursor: pointer;
+  font-size: 14px;
+  transition: background 0.2s;
+}
+.menu-item:active {
+  background: var(--color-primary-light, #fdf2f8);
+  margin: 0 -16px;
+  padding: 14px 16px;
+}
+.menu-item .el-icon:first-child {
+  color: var(--color-text-secondary, #999);
+  font-size: 18px;
+}
+.menu-item span {
+  flex: 1;
+}
+.arrow {
+  color: var(--color-text-secondary, #ccc);
+  font-size: 14px;
+}
+</style>

+ 51 - 0
src/view/StaticPageView.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="static-page">
+    <h2 class="page-title">{{ pageTitle }}</h2>
+    <div class="page-content">
+      <p>{{ placeholderContent }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+const type = computed(() => route.params.type as string)
+
+const titles: Record<string, string> = {
+  privacy: '隐私协议',
+  complaint: '投诉反馈',
+  contact: '联系客服',
+  faq: '常见问题'
+}
+
+const pageTitle = computed(() => titles[type.value] || '页面')
+
+const placeholderContent = computed(() => {
+  const texts: Record<string, string> = {
+    privacy: '本协议详细说明了我们如何收集、使用和保护您的个人信息。请仔细阅读。',
+    complaint: '如果您有任何投诉或反馈,请通过以下方式联系我们。我们会尽快处理您的问题。',
+    contact: '您可以发送邮件至 support@gugugaga.com 或在工作时间拨打客服热线 400-xxx-xxxx。',
+    faq: '此处汇总了用户常见问题及解答,如有其他问题请联系客服。'
+  }
+  return texts[type.value] || '内容建设中...'
+})
+</script>
+
+<style scoped>
+.static-page {
+  padding: 24px 16px;
+}
+.page-title {
+  font-size: 18px;
+  font-weight: 600;
+  margin-bottom: 16px;
+}
+.page-content {
+  font-size: 14px;
+  line-height: 1.8;
+  color: var(--color-text, #333);
+}
+</style>

+ 168 - 0
src/view/WalletView.vue

@@ -0,0 +1,168 @@
+<template>
+  <div class="wallet-page">
+    <div class="balance-card">
+      <p class="balance-label">当前余额(元)</p>
+      <p class="balance-amount">¥{{ userStore.userInfo.balance.toFixed(2) }}</p>
+      <div class="balance-actions">
+        <el-button type="primary" @click="showRecharge = true">充值</el-button>
+        <el-button @click="showWithdraw = true">提现</el-button>
+      </div>
+    </div>
+
+    <el-divider />
+
+    <h3 class="section-title">资金明细</h3>
+    <div class="transaction-list">
+      <div v-for="item in transactions" :key="item.id" class="tx-item">
+        <span class="tx-type">{{ typeLabel(item.type) }}</span>
+        <span class="tx-amount" :class="item.amount > 0 ? 'income' : 'expense'">
+          {{ item.amount > 0 ? '+' : '' }}{{ item.amount.toFixed(2) }}
+        </span>
+        <span class="tx-time">{{ item.time }}</span>
+      </div>
+      <EmptyState v-if="transactions.length === 0" description="暂无资金明细" />
+    </div>
+
+    <el-dialog v-model="showRecharge" title="充值" width="85%">
+      <el-form>
+        <el-form-item label="充值金额">
+          <el-input-number v-model="rechargeAmount" :min="1" :max="99999" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showRecharge = false">取消</el-button>
+        <el-button type="primary" :loading="recharging" @click="doRecharge">确认充值</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="showWithdraw" title="提现" width="85%">
+      <el-form>
+        <el-form-item label="提现金额">
+          <el-input-number v-model="withdrawAmount" :min="1" :max="userStore.userInfo.balance" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showWithdraw = false">取消</el-button>
+        <el-button type="primary" :loading="withdrawing" @click="doWithdraw">确认提现</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useLoginUserStore } from '../store'
+import EmptyState from '../components/EmptyState.vue'
+import type { TransactionItem } from '../type'
+
+const userStore = useLoginUserStore()
+const showRecharge = ref(false)
+const showWithdraw = ref(false)
+const rechargeAmount = ref(100)
+const withdrawAmount = ref(100)
+const recharging = ref(false)
+const withdrawing = ref(false)
+const transactions = ref<TransactionItem[]>([])
+
+function typeLabel(type: string): string {
+  const map: Record<string, string> = { recharge: '充值', withdraw: '提现', payment: '打赏支出' }
+  return map[type] || type
+}
+
+function doRecharge() {
+  recharging.value = true
+  setTimeout(() => {
+    userStore.updateBalance(rechargeAmount.value)
+    transactions.value.unshift({
+      id: Date.now().toString(),
+      time: new Date().toLocaleString(),
+      amount: rechargeAmount.value,
+      type: 'recharge'
+    })
+    ElMessage.success('充值成功')
+    showRecharge.value = false
+    recharging.value = false
+  }, 500)
+}
+
+function doWithdraw() {
+  if (withdrawAmount.value > userStore.userInfo.balance) {
+    ElMessage.warning('余额不足')
+    return
+  }
+  withdrawing.value = true
+  setTimeout(() => {
+    userStore.updateBalance(-withdrawAmount.value)
+    transactions.value.unshift({
+      id: Date.now().toString(),
+      time: new Date().toLocaleString(),
+      amount: -withdrawAmount.value,
+      type: 'withdraw'
+    })
+    ElMessage.success('提现申请已提交,等待处理')
+    showWithdraw.value = false
+    withdrawing.value = false
+  }, 500)
+}
+</script>
+
+<style scoped>
+.wallet-page {
+  padding: 0 16px 16px;
+}
+.balance-card {
+  text-align: center;
+  padding: 32px 0;
+}
+.balance-label {
+  font-size: 13px;
+  color: var(--color-text-secondary, #999);
+  margin-bottom: 8px;
+}
+.balance-amount {
+  font-size: 40px;
+  font-weight: 700;
+  color: var(--color-primary, #db2777);
+  margin-bottom: 16px;
+}
+.balance-actions {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+}
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 12px;
+  padding: 0 16px;
+}
+.transaction-list {
+  padding: 0;
+}
+.tx-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 0;
+  border-bottom: 1px solid var(--color-border, #ebebeb);
+  font-size: 13px;
+}
+.tx-type {
+  flex: 1;
+}
+.tx-amount {
+  font-weight: 600;
+  margin: 0 16px;
+}
+.tx-amount.income {
+  color: #67c23a;
+}
+.tx-amount.expense {
+  color: #f56c6c;
+}
+.tx-time {
+  color: var(--color-text-secondary, #999);
+  font-size: 12px;
+}
+</style>