feat: 完成情绪博物馆项目重构和功能增强 - 新增日记评论和帖子功能 - 重构前端架构,优化用户体验 - 完善WebSocket通信机制 - 更新项目文档和部署配置
This commit is contained in:
+15
-130
@@ -1,140 +1,25 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<a-config-provider :theme="themeConfig">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
<div id="app" class="min-h-screen bg-light-gray">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useAppStore, useUserStore } from '@/stores'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Ant Design 主题配置
|
||||
const themeConfig = computed(() => ({
|
||||
token: {
|
||||
colorPrimary: appStore.theme.primaryColor,
|
||||
colorSuccess: '#52c41a',
|
||||
colorWarning: appStore.theme.secondaryColor,
|
||||
colorError: '#ff4d4f',
|
||||
colorInfo: appStore.theme.primaryColor,
|
||||
borderRadius: 8,
|
||||
fontFamily: "'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
borderRadius: 20,
|
||||
controlHeight: 40,
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 8,
|
||||
controlHeight: 40,
|
||||
},
|
||||
Card: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化应用
|
||||
appStore.init()
|
||||
userStore.initUser()
|
||||
})
|
||||
// 根组件逻辑
|
||||
onMounted(() => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
font-family: 'Noto Sans SC', system-ui, sans-serif;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* 自定义Ant Design样式 */
|
||||
.ant-btn {
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: linear-gradient(135deg, #4a90e2 0%, #5ba0f2 100%);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #5ba0f2 0%, #6bb0ff 100%);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-orange {
|
||||
background: linear-gradient(135deg, #ff7849 0%, #ff8859 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #ff8859 0%, #ff9869 100%);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-affix-wrapper {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.ant-input-affix-wrapper-focused {
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-message {
|
||||
.ant-message-notice-content {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.ant-layout-content {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,256 +0,0 @@
|
||||
// 动画效果样式文件
|
||||
|
||||
/* 淡入向上动画 */
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.delay-100 { animation-delay: 0.1s; }
|
||||
.delay-200 { animation-delay: 0.2s; }
|
||||
.delay-300 { animation-delay: 0.3s; }
|
||||
.delay-400 { animation-delay: 0.4s; }
|
||||
.delay-500 { animation-delay: 0.5s; }
|
||||
.delay-600 { animation-delay: 0.6s; }
|
||||
.delay-700 { animation-delay: 0.7s; }
|
||||
.delay-800 { animation-delay: 0.8s; }
|
||||
.delay-900 { animation-delay: 0.9s; }
|
||||
.delay-1000 { animation-delay: 1s; }
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动触发动画 */
|
||||
.scroll-target {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 波浪动画 */
|
||||
.wave {
|
||||
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+");
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 147px;
|
||||
animation: wave 15s linear infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-direction: reverse;
|
||||
animation-duration: 20s;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-duration: 25s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% { transform: translateX(0); }
|
||||
50% { transform: translateX(-50%); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.hover-lift {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮动画 */
|
||||
.btn-bounce {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 旋转动画 */
|
||||
.loading-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滑入动画 */
|
||||
.slide-in-left {
|
||||
animation: slide-in-left 0.5s ease-out;
|
||||
}
|
||||
|
||||
.slide-in-right {
|
||||
animation: slide-in-right 0.5s ease-out;
|
||||
}
|
||||
|
||||
.slide-in-down {
|
||||
animation: slide-in-down 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹性动画 */
|
||||
.bounce-in {
|
||||
animation: bounce-in 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 渐变背景动画 */
|
||||
.gradient-animation {
|
||||
background: linear-gradient(-45deg, #4A90E2, #F5A623, #4A90E2, #F5A623);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient-shift 15s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打字机效果 */
|
||||
.typewriter {
|
||||
overflow: hidden;
|
||||
border-right: 0.15em solid #4A90E2;
|
||||
white-space: nowrap;
|
||||
margin: 0 auto;
|
||||
letter-spacing: 0.15em;
|
||||
animation: typing 3.5s steps(40, end), blink-caret 0.75s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
from, to {
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
border-color: #4A90E2;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式动画控制 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
|
||||
|
||||
/* 全局重置 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
background-color: #f5f5f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.text-tech-blue {
|
||||
color: #4a90e2 !important;
|
||||
}
|
||||
|
||||
.text-warm-orange {
|
||||
color: #ff7849 !important;
|
||||
}
|
||||
|
||||
.bg-tech-blue {
|
||||
background-color: #4a90e2 !important;
|
||||
}
|
||||
|
||||
.bg-warm-orange {
|
||||
background-color: #ff7849 !important;
|
||||
}
|
||||
|
||||
.bg-light-gray {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
/* 动画类 */
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-target {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
||||
}
|
||||
|
||||
.scroll-target.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 响应式工具类 */
|
||||
.container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.container {
|
||||
max-width: 1536px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ant Design 主题覆盖 */
|
||||
.ant-btn-primary {
|
||||
background-color: #4a90e2;
|
||||
border-color: #4a90e2;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover,
|
||||
.ant-btn-primary:focus {
|
||||
background-color: #5ba0f2;
|
||||
border-color: #5ba0f2;
|
||||
}
|
||||
|
||||
.ant-btn-orange {
|
||||
background-color: #ff7849;
|
||||
border-color: #ff7849;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ant-btn-orange:hover,
|
||||
.ant-btn-orange:focus {
|
||||
background-color: #ff8859;
|
||||
border-color: #ff8859;
|
||||
color: white;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
@@ -0,0 +1,215 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 原始CSS变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* 全局样式 */
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Noto Sans SC', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-gray-900 bg-gray-50;
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* 自定义组件样式 */
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
/* 原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
/* Header滚动样式 */
|
||||
#main-header.scrolled {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 功能卡片样式 */
|
||||
.feature-card-bg {
|
||||
background-color: var(--white);
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card-bg:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.feature-card-image-container {
|
||||
background-color: #eef5fe;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M-10 10 C 20 20, 40 0, 60 10 S 100 0, 120 10" stroke="%234A90E2" fill="none" stroke-width="2" stroke-opacity="0.2"/></svg>');
|
||||
background-size: 50px;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
/* 动画样式 */
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scroll-target {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
||||
}
|
||||
|
||||
.scroll-target.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 波浪动画 */
|
||||
.wave {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 147px;
|
||||
animation: wave 15s linear infinite;
|
||||
}
|
||||
|
||||
.wave:nth-of-type(2) {
|
||||
animation-direction: reverse;
|
||||
animation-duration: 20s;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.wave:nth-of-type(3) {
|
||||
animation-duration: 25s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 聊天消息样式 */
|
||||
#chat-messages {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--tech-blue) var(--light-gray);
|
||||
}
|
||||
|
||||
#chat-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#chat-messages::-webkit-scrollbar-track {
|
||||
background: var(--light-gray);
|
||||
}
|
||||
|
||||
#chat-messages::-webkit-scrollbar-thumb {
|
||||
background-color: var(--tech-blue);
|
||||
border-radius: 10px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.message-animate {
|
||||
animation: message-fade-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
#login-modal:not(.hidden) {
|
||||
animation: modal-fade-in 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
#login-modal:not(.hidden) > div {
|
||||
animation: modal-scale-up 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
#topic-detail-modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 自定义工具类 */
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.shadow-soft {
|
||||
box-shadow: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* 关键帧动画 */
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% { transform: translateX(0); }
|
||||
50% { transform: translateX(-50%); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes message-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-scale-up {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// 主题色彩
|
||||
$tech-blue: #4A90E2;
|
||||
$warm-orange: #F5A623;
|
||||
$white: #FFFFFF;
|
||||
$light-gray: #F7F8FA;
|
||||
$text-dark: #333333;
|
||||
$text-medium: #888888;
|
||||
$border-color: #e8e8e8;
|
||||
|
||||
// 间距
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 16px;
|
||||
$spacing-lg: 24px;
|
||||
$spacing-xl: 32px;
|
||||
$spacing-xxl: 48px;
|
||||
|
||||
// 圆角
|
||||
$border-radius-sm: 4px;
|
||||
$border-radius-md: 8px;
|
||||
$border-radius-lg: 12px;
|
||||
$border-radius-xl: 16px;
|
||||
$border-radius-full: 9999px;
|
||||
|
||||
// 阴影
|
||||
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
// 断点
|
||||
$breakpoint-sm: 640px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 1024px;
|
||||
$breakpoint-xl: 1280px;
|
||||
$breakpoint-xxl: 1536px;
|
||||
|
||||
// 字体大小
|
||||
$font-size-xs: 12px;
|
||||
$font-size-sm: 14px;
|
||||
$font-size-md: 16px; // 添加缺失的 md 尺寸
|
||||
$font-size-base: 16px;
|
||||
$font-size-lg: 18px;
|
||||
$font-size-xl: 20px;
|
||||
$font-size-2xl: 24px;
|
||||
$font-size-3xl: 30px;
|
||||
$font-size-4xl: 36px;
|
||||
|
||||
// 字体权重
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
// 过渡动画
|
||||
$transition-fast: 0.15s ease-in-out;
|
||||
$transition-normal: 0.3s ease-in-out;
|
||||
$transition-slow: 0.5s ease-in-out;
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<!-- 弹框头部 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">聊天历史记录</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">查看和搜索您的所有对话记录</p>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-2 rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<i data-lucide="x" class="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<div class="p-6 border-b border-gray-200 bg-gray-50">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- 关键词搜索 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">搜索关键词</label>
|
||||
<div class="relative">
|
||||
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"></i>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="输入关键词..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期范围 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">开始日期</label>
|
||||
<input
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
@change="handleDateFilter"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">结束日期</label>
|
||||
<input
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
@change="handleDateFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
共找到 {{ totalCount }} 条记录
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="clearFilters"
|
||||
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
<button
|
||||
@click="refreshData"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="messages.length === 0" class="text-center py-8 text-gray-500">
|
||||
<i data-lucide="message-circle" class="w-12 h-12 mx-auto mb-4 text-gray-300"></i>
|
||||
<p>暂无聊天记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||
:class="message.sender === 'user' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'"
|
||||
>
|
||||
{{ message.sender === 'user' ? '我' : '开开' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatDateTime(message.createTime) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-900 leading-relaxed">{{ message.content }}</p>
|
||||
<div v-if="message.aiReply" class="mt-2 p-3 bg-white rounded border-l-4 border-green-400">
|
||||
<p class="text-sm text-gray-700">{{ message.aiReply }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="p-6 border-t border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-600">
|
||||
第 {{ currentPage }} 页,共 {{ totalPages }} 页
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MessageService, { type MessageResponse, type PageResult } from '@/services/message'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const messages = ref<MessageResponse[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalCount = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value))
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
// 方法
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
if (searchKeyword.value.trim()) {
|
||||
// 搜索模式
|
||||
const searchResult = await MessageService.searchUserMessages({
|
||||
keyword: searchKeyword.value.trim(),
|
||||
limit: 100,
|
||||
startTime: startDate.value ? `${startDate.value} 00:00:00` : undefined,
|
||||
endTime: endDate.value ? `${endDate.value} 23:59:59` : undefined
|
||||
})
|
||||
messages.value = searchResult
|
||||
totalCount.value = searchResult.length
|
||||
currentPage.value = 1
|
||||
} else {
|
||||
// 分页模式
|
||||
const pageResult = await MessageService.getUserMessages(currentPage.value, pageSize.value)
|
||||
messages.value = pageResult.records
|
||||
totalCount.value = pageResult.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error)
|
||||
ElMessage.error('加载聊天记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadMessages()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleDateFilter = () => {
|
||||
currentPage.value = 1
|
||||
loadMessages()
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
searchKeyword.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
currentPage.value = 1
|
||||
loadMessages()
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
loadMessages()
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime: string) => {
|
||||
return new Date(dateTime).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹框显示状态
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
loadMessages()
|
||||
// 初始化图标
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条 */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="user-avatar" :class="sizeClass">
|
||||
<img
|
||||
v-if="avatar"
|
||||
:src="avatar"
|
||||
:alt="nickname"
|
||||
class="avatar-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="avatar-placeholder">
|
||||
{{ avatarText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
/** 头像URL */
|
||||
avatar?: string
|
||||
/** 昵称 */
|
||||
nickname: string
|
||||
/** 尺寸 */
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'medium'
|
||||
})
|
||||
|
||||
// 图片加载失败标记
|
||||
const imageError = ref(false)
|
||||
|
||||
// 尺寸类名
|
||||
const sizeClass = computed(() => `avatar-${props.size}`)
|
||||
|
||||
// 头像文字(取昵称首字符)
|
||||
const avatarText = computed(() => {
|
||||
if (!props.nickname) return '用'
|
||||
return props.nickname.charAt(0).toUpperCase()
|
||||
})
|
||||
|
||||
// 处理图片加载失败
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-small {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.avatar-medium {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.avatar-large {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<div class="user-info-trigger">
|
||||
<UserAvatar :avatar="userInfo?.avatar" :nickname="userInfo?.nickname || '用户'" size="medium" />
|
||||
<div class="user-text">
|
||||
<div class="user-nickname">{{ userInfo?.nickname || '用户' }}</div>
|
||||
<div class="user-level">{{ userInfo?.memberLevel || 'Lv.1' }}</div>
|
||||
</div>
|
||||
<el-icon class="dropdown-icon">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
账号设置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="dashboard">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
个人仪表盘
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowDown,
|
||||
User,
|
||||
Setting,
|
||||
DataBoard,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 用户信息
|
||||
const userInfo = computed(() => authStore.userInfo)
|
||||
|
||||
// 处理下拉菜单命令
|
||||
const handleCommand = async (command: string) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'dashboard':
|
||||
router.push('/personal-dashboard')
|
||||
break
|
||||
case 'logout':
|
||||
await handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 处理退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要退出登录吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await authStore.logout()
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-info-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-info-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-nickname {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.user-level {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info-trigger:hover .dropdown-icon {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu) {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item) {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item:hover) {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item.is-divided) {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.user-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-info-trigger {
|
||||
padding: 6px;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<div style="background: white; padding: 40px 20px; text-align: center; border-top: 1px solid #e8e8e8;">
|
||||
<div style="max-width: 1200px; margin: 0 auto;">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="color: #4A90E2; font-size: 20px; margin-bottom: 8px;">开心APP</h3>
|
||||
<p style="color: #888; margin: 0;">陪伴、理解、记录、共同成长。</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: center; gap: 40px; margin-bottom: 20px; flex-wrap: wrap;">
|
||||
<router-link to="/chat" style="color: #888; text-decoration: none;">聊天</router-link>
|
||||
<router-link to="/diary" style="color: #888; text-decoration: none;">日记</router-link>
|
||||
<router-link to="/dashboard" style="color: #888; text-decoration: none;">展板</router-link>
|
||||
<router-link to="/settings" style="color: #888; text-decoration: none;">设置</router-link>
|
||||
</div>
|
||||
|
||||
<p style="color: #888; font-size: 14px; margin: 0;">
|
||||
© 2025 开心APP. All Rights Reserved. 来自"开心"星球的温柔科技。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 简化版Footer组件
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.app-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,321 +0,0 @@
|
||||
<template>
|
||||
<header class="app-header" :class="{ 'scrolled': isScrolled }">
|
||||
<div class="header-content">
|
||||
<!-- Logo -->
|
||||
<router-link to="/" class="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 100 100" class="logo-icon">
|
||||
<path fill="currentColor" d="M85.4,37.3C85.4,37.3,85.4,37.3,85.4,37.3c-2.8-9.9-10-17.7-19.1-21.2c-0.2-0.1-0.5-0.1-0.7-0.2c-0.1,0-0.2-0.1-0.3-0.1 c-1.2-0.4-2.5-0.8-3.7-1.1c-1-0.2-2-0.4-3-0.6c-1.1-0.2-2.1-0.3-3.2-0.4c-1.2-0.1-2.4-0.2-3.6-0.2c-0.1,0-0.2,0-0.3,0h-0.1 c-0.1,0-0.2,0-0.3,0c-1.2,0-2.4,0.1-3.6,0.2c-1.1,0.1-2.1,0.2-3.2,0.4c-1,0.2-2,0.4-3,0.6c-1.3,0.3-2.5,0.6-3.7,1.1 c-0.1,0-0.2,0.1-0.3,0.1c-0.2,0.1-0.5,0.1-0.7,0.2C21.6,19.6,14.4,27.4,11.6,37.3c0,0,0,0.1-0.1,0.1C8,47.7,8,58.8,11.5,69.2 c0,0.1,0.1,0.1,0.1,0.2c2.8,9.9,10,17.7,19.1,21.2c0.2,0.1,0.5,0.1,0.7,0.2c0.1,0,0.2,0.1,0.3,0.1c1.2,0.4,2.5,0.8,3.7,1.1 c1,0.2,2,0.4,3,0.6c1.1,0.2,2.1,0.3,3.2,0.4c1.2,0.1,2.4,0.2,3.6,0.2c0.1,0,0.2,0,0.3,0h0.1c0.1,0,0.2,0,0.3,0 c1.2,0,2.4-0.1,3.6-0.2c-1.1-0.1-2.1-0.2-3.2-0.4c1-0.2,2-0.4,3-0.6c1.3-0.3,2.5-0.6,3.7-1.1c0.1,0,0.2-0.1,0.3-0.1 c0.2-0.1,0.5-0.1,0.7-0.2c9.1-3.5,16.3-11.3,19.1-21.2c0-0.1,0.1-0.1,0.1-0.2C89,58.8,89,47.7,85.4,37.3z M50,77.9 c-15.4,0-27.9-12.5-27.9-27.9S34.6,22.1,50,22.1s27.9,12.5,27.9,27.9S65.4,77.9,50,77.9z"></path>
|
||||
<path fill="#F5A623" d="M50,88.8c-21.4,0-38.8-17.4-38.8-38.8S28.6,11.2,50,11.2s38.8,17.4,38.8,38.8S71.4,88.8,50,88.8z M50,16.2 c-18.7,0-33.8,15.1-33.8,33.8S31.3,83.8,50,83.8s33.8-15.1,33.8-33.8S68.7,16.2,50,16.2z"></path>
|
||||
</svg>
|
||||
<span class="logo-text">开心APP</span>
|
||||
</router-link>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="nav-menu" :class="{ 'mobile-hidden': !mobileMenuVisible }">
|
||||
<router-link to="/chat" class="nav-link" @click="closeMobileMenu">聊天</router-link>
|
||||
<router-link to="/diary" class="nav-link" @click="closeMobileMenu">日记</router-link>
|
||||
<router-link to="/dashboard" class="nav-link" @click="closeMobileMenu">展板</router-link>
|
||||
<router-link to="/topic-tracker" class="nav-link" @click="closeMobileMenu">话题追踪</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- 右侧操作区 -->
|
||||
<div class="header-actions">
|
||||
<!-- 未登录状态 -->
|
||||
<template v-if="!userStore.isLoggedIn">
|
||||
<a-button type="text" @click="$router.push('/login')" class="login-btn">
|
||||
登录
|
||||
</a-button>
|
||||
<a-button type="primary" @click="$router.push('/chat')" class="start-btn">
|
||||
免费开始
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 已登录状态 -->
|
||||
<template v-else>
|
||||
<a-dropdown>
|
||||
<div class="user-info-section">
|
||||
<a-avatar
|
||||
:size="32"
|
||||
:src="userStore.userInfo?.avatar"
|
||||
class="user-avatar"
|
||||
>
|
||||
<template #icon v-if="!userStore.userInfo?.avatar">
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="user-nickname">
|
||||
{{ userStore.userInfo?.nickname || userStore.userInfo?.username || '用户' }}
|
||||
</span>
|
||||
<DownOutlined class="dropdown-icon" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile" @click="$router.push('/dashboard')">
|
||||
<UserOutlined />
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings" @click="$router.push('/settings')">
|
||||
<SettingOutlined />
|
||||
设置
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="mobile-menu-btn"
|
||||
@click="toggleMobileMenu"
|
||||
>
|
||||
<MenuOutlined v-if="!mobileMenuVisible" />
|
||||
<CloseOutlined v-else />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div v-if="mobileMenuVisible" class="mobile-menu">
|
||||
<nav class="mobile-nav">
|
||||
<router-link to="/chat" class="mobile-nav-link" @click="closeMobileMenu">聊天</router-link>
|
||||
<router-link to="/diary" class="mobile-nav-link" @click="closeMobileMenu">日记</router-link>
|
||||
<router-link to="/dashboard" class="mobile-nav-link" @click="closeMobileMenu">展板</router-link>
|
||||
<router-link to="/topic-tracker" class="mobile-nav-link" @click="closeMobileMenu">话题追踪</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
DownOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
CloseOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式状态
|
||||
const isScrolled = ref(false)
|
||||
const mobileMenuVisible = ref(false)
|
||||
|
||||
// 滚动监听
|
||||
const handleScroll = () => {
|
||||
isScrolled.value = window.scrollY > 50
|
||||
}
|
||||
|
||||
// 移动端菜单控制
|
||||
const toggleMobileMenu = () => {
|
||||
mobileMenuVisible.value = !mobileMenuVisible.value
|
||||
}
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuVisible.value = false
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
message.success('退出登录成功')
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
message.error('退出登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
handleScroll() // 初始检查
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.scrolled {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: $tech-blue;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
color: $tech-blue;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
@media (max-width: 480px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #888888;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: #4A90E2;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
color: #4A90E2;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: #4A90E2;
|
||||
background: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.user-nickname {
|
||||
font-weight: 500;
|
||||
color: #4A90E2;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-btn {
|
||||
color: #4A90E2;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: #4A90E2;
|
||||
background: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
.user-nickname {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<nav class="fixed bottom-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-sm shadow-[0_-2px_10px_rgba(0,0,0,0.05)] flex justify-around py-2 border-t border-gray-200/80">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="item.href"
|
||||
class="flex flex-col items-center justify-center text-xs p-2 rounded-md transition-colors w-20"
|
||||
:class="isActive(item.href) ? 'text-tech-blue bg-tech-blue/10 font-semibold' : 'text-text-medium hover:bg-gray-100 hover:text-tech-blue'"
|
||||
>
|
||||
<i :data-lucide="item.icon" class="w-5 h-5 mb-1"></i>
|
||||
<span>{{ item.text }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const navItems = [
|
||||
{ icon: 'message-square', text: '聊天', href: '/chat', name: 'Chat' },
|
||||
{ icon: 'book-open', text: '日记', href: '/diary', name: 'Diary' },
|
||||
{ icon: 'crosshair', text: '话题', href: '/topic-tracker', name: 'TopicTracker' },
|
||||
{ icon: 'milestone', text: '人生轨迹', href: '/life-milestones', name: 'LifeMilestones' },
|
||||
{ icon: 'layout-dashboard', text: '个人展板', href: '/personal-dashboard', name: 'PersonalDashboard' }
|
||||
]
|
||||
|
||||
const isActive = (href: string) => {
|
||||
return route.path === href
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.bg-tech-blue\/10 { background-color: rgba(74, 144, 226, 0.1); }
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 认证相关组合式函数
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AuthService from '@/services/auth'
|
||||
import type { LoginRequest, RegisterRequest, CaptchaResponse } from '@/types/auth'
|
||||
|
||||
/**
|
||||
* 使用认证功能
|
||||
*/
|
||||
export const useAuth = () => {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 验证码相关
|
||||
const captchaData = ref<CaptchaResponse | null>(null)
|
||||
const captchaImage = computed(() =>
|
||||
captchaData.value ? `data:image/png;base64,${captchaData.value.captchaImage}` : ''
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
const response = await AuthService.getCaptcha()
|
||||
captchaData.value = response
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
const refreshCaptcha = async () => {
|
||||
return getCaptcha()
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
const login = async (loginData: LoginRequest) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const success = await authStore.login(loginData)
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('登录成功')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
const register = async (registerData: RegisterRequest) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const success = await authStore.register(registerData)
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('注册成功')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error: any) {
|
||||
console.error('注册失败:', error)
|
||||
ElMessage.error(error.message || '注册失败')
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
const logout = async () => {
|
||||
try {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号是否存在
|
||||
*/
|
||||
const checkAccountExists = async (account: string) => {
|
||||
if (!account || !/^[a-zA-Z0-9_]{4,20}$/.test(account)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await AuthService.checkAccountExists(account)
|
||||
} catch (error) {
|
||||
console.error('检查账号失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查邮箱是否存在
|
||||
*/
|
||||
const checkEmailExists = async (email: string) => {
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await AuthService.checkEmailExists(email)
|
||||
} catch (error) {
|
||||
console.error('检查邮箱失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查手机号是否存在
|
||||
*/
|
||||
const checkPhoneExists = async (phone: string) => {
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await AuthService.checkPhoneExists(phone)
|
||||
} catch (error) {
|
||||
console.error('检查手机号失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
captchaData,
|
||||
captchaImage,
|
||||
|
||||
// 计算属性
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
userInfo: authStore.userInfo,
|
||||
|
||||
// 方法
|
||||
getCaptcha,
|
||||
refreshCaptcha,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
checkAccountExists,
|
||||
checkEmailExists,
|
||||
checkPhoneExists
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用表单验证
|
||||
*/
|
||||
export const useFormValidation = () => {
|
||||
/**
|
||||
* 账号验证规则
|
||||
*/
|
||||
const validateAccount = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入账号'))
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]{4,20}$/.test(value)) {
|
||||
callback(new Error('账号只能包含字母、数字和下划线,长度4-20位'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码验证规则
|
||||
*/
|
||||
const validatePassword = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入密码'))
|
||||
return
|
||||
}
|
||||
if (value.length < 6 || value.length > 20) {
|
||||
callback(new Error('密码长度必须在6-20位之间'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认密码验证规则
|
||||
*/
|
||||
const validateConfirmPassword = (password: string) => {
|
||||
return (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请再次输入密码'))
|
||||
return
|
||||
}
|
||||
if (value !== password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱验证规则
|
||||
*/
|
||||
const validateEmail = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入邮箱地址'))
|
||||
return
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(value)) {
|
||||
callback(new Error('请输入正确的邮箱格式'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号验证规则
|
||||
*/
|
||||
const validatePhone = (rule: any, value: string, callback: any) => {
|
||||
if (value && !/^1[3-9]\d{9}$/.test(value)) {
|
||||
callback(new Error('请输入正确的手机号格式'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
return {
|
||||
validateAccount,
|
||||
validatePassword,
|
||||
validateConfirmPassword,
|
||||
validateEmail,
|
||||
validatePhone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 表单验证组合式函数
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
/**
|
||||
* 使用表单验证
|
||||
*/
|
||||
export const useFormValidation = <T extends Record<string, any>>(
|
||||
initialData: T,
|
||||
rules: FormRules
|
||||
) => {
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = reactive<T>({ ...initialData })
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const isValidating = ref(false)
|
||||
|
||||
/**
|
||||
* 验证整个表单
|
||||
*/
|
||||
const validateForm = async (): Promise<boolean> => {
|
||||
if (!formRef.value) return false
|
||||
|
||||
try {
|
||||
isValidating.value = true
|
||||
await formRef.value.validate()
|
||||
errors.value = {}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
return false
|
||||
} finally {
|
||||
isValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证指定字段
|
||||
*/
|
||||
const validateField = async (field: keyof T): Promise<boolean> => {
|
||||
if (!formRef.value) return false
|
||||
|
||||
try {
|
||||
await formRef.value.validateField(field as string)
|
||||
delete errors.value[field as string]
|
||||
return true
|
||||
} catch (error) {
|
||||
errors.value[field as string] = error as string
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除验证结果
|
||||
*/
|
||||
const clearValidation = (fields?: (keyof T)[]) => {
|
||||
if (!formRef.value) return
|
||||
|
||||
if (fields) {
|
||||
formRef.value.clearValidate(fields as string[])
|
||||
fields.forEach(field => {
|
||||
delete errors.value[field as string]
|
||||
})
|
||||
} else {
|
||||
formRef.value.clearValidate()
|
||||
errors.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
const resetForm = () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
formRef.value.resetFields()
|
||||
Object.assign(formData, initialData)
|
||||
errors.value = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置字段错误
|
||||
*/
|
||||
const setFieldError = (field: keyof T, message: string) => {
|
||||
errors.value[field as string] = message
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除字段错误
|
||||
*/
|
||||
const clearFieldError = (field: keyof T) => {
|
||||
delete errors.value[field as string]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段错误
|
||||
*/
|
||||
const getFieldError = (field: keyof T) => {
|
||||
return errors.value[field as string]
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查表单是否有错误
|
||||
*/
|
||||
const hasErrors = computed(() => {
|
||||
return Object.keys(errors.value).length > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 检查表单是否有效
|
||||
*/
|
||||
const isValid = computed(() => {
|
||||
return !hasErrors.value && !isValidating.value
|
||||
})
|
||||
|
||||
return {
|
||||
formRef,
|
||||
formData,
|
||||
errors: computed(() => errors.value),
|
||||
isValidating: computed(() => isValidating.value),
|
||||
hasErrors,
|
||||
isValid,
|
||||
validateForm,
|
||||
validateField,
|
||||
clearValidation,
|
||||
resetForm,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
getFieldError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用验证规则
|
||||
*/
|
||||
export const validationRules = {
|
||||
/**
|
||||
* 必填验证
|
||||
*/
|
||||
required: (message = '此字段为必填项') => ({
|
||||
required: true,
|
||||
message,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 邮箱验证
|
||||
*/
|
||||
email: (message = '请输入正确的邮箱格式') => ({
|
||||
type: 'email' as const,
|
||||
message,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 手机号验证
|
||||
*/
|
||||
phone: (message = '请输入正确的手机号格式') => ({
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 长度验证
|
||||
*/
|
||||
length: (min: number, max: number, message?: string) => ({
|
||||
min,
|
||||
max,
|
||||
message: message || `长度必须在${min}-${max}位之间`,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 最小长度验证
|
||||
*/
|
||||
minLength: (min: number, message?: string) => ({
|
||||
min,
|
||||
message: message || `长度不能少于${min}位`,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 最大长度验证
|
||||
*/
|
||||
maxLength: (max: number, message?: string) => ({
|
||||
max,
|
||||
message: message || `长度不能超过${max}位`,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 正则验证
|
||||
*/
|
||||
pattern: (pattern: RegExp, message: string) => ({
|
||||
pattern,
|
||||
message,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 自定义验证
|
||||
*/
|
||||
custom: (validator: (rule: any, value: any, callback: any) => void) => ({
|
||||
validator,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 账号验证(字母数字下划线)
|
||||
*/
|
||||
account: (message = '账号只能包含字母、数字和下划线,长度4-20位') => ({
|
||||
pattern: /^[a-zA-Z0-9_]{4,20}$/,
|
||||
message,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 密码验证
|
||||
*/
|
||||
password: (min = 6, max = 20, message?: string) => ({
|
||||
min,
|
||||
max,
|
||||
message: message || `密码长度必须在${min}-${max}位之间`,
|
||||
trigger: 'blur'
|
||||
}),
|
||||
|
||||
/**
|
||||
* 确认密码验证
|
||||
*/
|
||||
confirmPassword: (passwordField: string, message = '两次输入的密码不一致') => ({
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请再次输入密码'))
|
||||
return
|
||||
}
|
||||
// 这里需要访问表单数据,在实际使用时需要传入表单数据
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用实时验证
|
||||
*/
|
||||
export const useRealtimeValidation = <T extends Record<string, any>>(
|
||||
formData: T,
|
||||
rules: FormRules
|
||||
) => {
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const validFields = ref<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* 验证单个字段
|
||||
*/
|
||||
const validateField = async (field: keyof T, value: any) => {
|
||||
const fieldRules = rules[field as string]
|
||||
if (!fieldRules) return true
|
||||
|
||||
try {
|
||||
// 这里简化处理,实际应该使用async-validator
|
||||
const ruleArray = Array.isArray(fieldRules) ? fieldRules : [fieldRules]
|
||||
|
||||
for (const rule of ruleArray) {
|
||||
if (rule.required && (!value || value === '')) {
|
||||
throw new Error(rule.message || '此字段为必填项')
|
||||
}
|
||||
|
||||
if (rule.min && value && value.length < rule.min) {
|
||||
throw new Error(rule.message || `长度不能少于${rule.min}位`)
|
||||
}
|
||||
|
||||
if (rule.max && value && value.length > rule.max) {
|
||||
throw new Error(rule.message || `长度不能超过${rule.max}位`)
|
||||
}
|
||||
|
||||
if (rule.pattern && value && !rule.pattern.test(value)) {
|
||||
throw new Error(rule.message || '格式不正确')
|
||||
}
|
||||
}
|
||||
|
||||
delete errors.value[field as string]
|
||||
validFields.value.add(field as string)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
errors.value[field as string] = error.message
|
||||
validFields.value.delete(field as string)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有字段是否有效
|
||||
*/
|
||||
const isAllValid = computed(() => {
|
||||
const requiredFields = Object.keys(rules)
|
||||
return requiredFields.every(field => validFields.value.has(field)) &&
|
||||
Object.keys(errors.value).length === 0
|
||||
})
|
||||
|
||||
return {
|
||||
errors: computed(() => errors.value),
|
||||
validFields: computed(() => validFields.value),
|
||||
isAllValid,
|
||||
validateField
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 加载状态管理组合式函数
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElLoading } from 'element-plus'
|
||||
import type { LoadingInstance } from 'element-plus/es/components/loading/src/loading'
|
||||
|
||||
// 全局加载状态
|
||||
const globalLoading = ref(false)
|
||||
const loadingCount = ref(0)
|
||||
|
||||
/**
|
||||
* 使用加载状态
|
||||
*/
|
||||
export const useLoading = (initialState = false) => {
|
||||
const loading = ref(initialState)
|
||||
|
||||
/**
|
||||
* 设置加载状态
|
||||
*/
|
||||
const setLoading = (state: boolean) => {
|
||||
loading.value = state
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始加载
|
||||
*/
|
||||
const startLoading = () => {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束加载
|
||||
*/
|
||||
const stopLoading = () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步操作包装器
|
||||
*/
|
||||
const withLoading = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
startLoading()
|
||||
return await fn()
|
||||
} finally {
|
||||
stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading: computed(() => loading.value),
|
||||
setLoading,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
withLoading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用全局加载状态
|
||||
*/
|
||||
export const useGlobalLoading = () => {
|
||||
/**
|
||||
* 增加加载计数
|
||||
*/
|
||||
const addLoading = () => {
|
||||
loadingCount.value++
|
||||
globalLoading.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少加载计数
|
||||
*/
|
||||
const removeLoading = () => {
|
||||
loadingCount.value = Math.max(0, loadingCount.value - 1)
|
||||
if (loadingCount.value === 0) {
|
||||
globalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置加载状态
|
||||
*/
|
||||
const resetLoading = () => {
|
||||
loadingCount.value = 0
|
||||
globalLoading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局异步操作包装器
|
||||
*/
|
||||
const withGlobalLoading = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
addLoading()
|
||||
return await fn()
|
||||
} finally {
|
||||
removeLoading()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
globalLoading: computed(() => globalLoading.value),
|
||||
loadingCount: computed(() => loadingCount.value),
|
||||
addLoading,
|
||||
removeLoading,
|
||||
resetLoading,
|
||||
withGlobalLoading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用页面加载遮罩
|
||||
*/
|
||||
export const usePageLoading = () => {
|
||||
let loadingInstance: LoadingInstance | null = null
|
||||
|
||||
/**
|
||||
* 显示页面加载遮罩
|
||||
*/
|
||||
const showPageLoading = (options?: {
|
||||
text?: string
|
||||
background?: string
|
||||
target?: string | HTMLElement
|
||||
}) => {
|
||||
const {
|
||||
text = '加载中...',
|
||||
background = 'rgba(0, 0, 0, 0.7)',
|
||||
target = 'body'
|
||||
} = options || {}
|
||||
|
||||
loadingInstance = ElLoading.service({
|
||||
lock: true,
|
||||
text,
|
||||
background,
|
||||
target
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏页面加载遮罩
|
||||
*/
|
||||
const hidePageLoading = () => {
|
||||
if (loadingInstance) {
|
||||
loadingInstance.close()
|
||||
loadingInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面异步操作包装器
|
||||
*/
|
||||
const withPageLoading = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: {
|
||||
text?: string
|
||||
background?: string
|
||||
target?: string | HTMLElement
|
||||
}
|
||||
): Promise<T> => {
|
||||
try {
|
||||
showPageLoading(options)
|
||||
return await fn()
|
||||
} finally {
|
||||
hidePageLoading()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showPageLoading,
|
||||
hidePageLoading,
|
||||
withPageLoading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用按钮加载状态
|
||||
*/
|
||||
export const useButtonLoading = () => {
|
||||
const buttonLoadings = ref<Record<string, boolean>>({})
|
||||
|
||||
/**
|
||||
* 设置按钮加载状态
|
||||
*/
|
||||
const setButtonLoading = (key: string, loading: boolean) => {
|
||||
buttonLoadings.value[key] = loading
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按钮加载状态
|
||||
*/
|
||||
const getButtonLoading = (key: string) => {
|
||||
return computed(() => buttonLoadings.value[key] || false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮异步操作包装器
|
||||
*/
|
||||
const withButtonLoading = async <T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
try {
|
||||
setButtonLoading(key, true)
|
||||
return await fn()
|
||||
} finally {
|
||||
setButtonLoading(key, false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buttonLoadings: computed(() => buttonLoadings.value),
|
||||
setButtonLoading,
|
||||
getButtonLoading,
|
||||
withButtonLoading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用延迟加载
|
||||
*/
|
||||
export const useDelayedLoading = (delay = 300) => {
|
||||
const loading = ref(false)
|
||||
const actualLoading = ref(false)
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
|
||||
/**
|
||||
* 设置加载状态(带延迟)
|
||||
*/
|
||||
const setLoading = (state: boolean) => {
|
||||
if (state) {
|
||||
// 立即设置实际加载状态
|
||||
actualLoading.value = true
|
||||
|
||||
// 延迟显示加载UI
|
||||
timer = setTimeout(() => {
|
||||
loading.value = true
|
||||
}, delay)
|
||||
} else {
|
||||
// 立即隐藏加载UI
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
loading.value = false
|
||||
actualLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟异步操作包装器
|
||||
*/
|
||||
const withDelayedLoading = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
setLoading(true)
|
||||
return await fn()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading: computed(() => loading.value),
|
||||
actualLoading: computed(() => actualLoading.value),
|
||||
setLoading,
|
||||
withDelayedLoading
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 环境配置
|
||||
* 根据不同环境加载对应的配置文件
|
||||
*/
|
||||
|
||||
// 环境类型
|
||||
export type EnvType = 'local' | 'dev' | 'test' | 'prod'
|
||||
|
||||
// 环境配置接口
|
||||
export interface EnvConfig {
|
||||
// 环境名称
|
||||
name: string
|
||||
// API基础URL
|
||||
apiBaseUrl: string
|
||||
// WebSocket URL
|
||||
wsBaseUrl: string
|
||||
// 文件上传URL
|
||||
uploadUrl: string
|
||||
// 是否开启调试模式
|
||||
debug: boolean
|
||||
// 是否开启mock
|
||||
mock: boolean
|
||||
// 应用标题
|
||||
appTitle: string
|
||||
// 应用版本
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
// 获取当前环境
|
||||
export const getCurrentEnv = (): EnvType => {
|
||||
// 从环境变量获取,默认为local
|
||||
const env = import.meta.env.VITE_APP_ENV as EnvType
|
||||
return env || 'local'
|
||||
}
|
||||
|
||||
// 获取环境配置
|
||||
export const getEnvConfig = (): EnvConfig => {
|
||||
const env = getCurrentEnv()
|
||||
|
||||
// 调试信息:打印所有环境变量
|
||||
console.log('当前环境:', env)
|
||||
console.log('所有环境变量:', import.meta.env)
|
||||
|
||||
// 优先使用环境变量,如果没有则使用默认值
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const wsBaseUrl = import.meta.env.VITE_WS_BASE_URL
|
||||
const uploadUrl = import.meta.env.VITE_UPLOAD_URL
|
||||
const debug = import.meta.env.VITE_DEBUG === 'true'
|
||||
const mock = import.meta.env.VITE_MOCK === 'true'
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION || '1.0.0'
|
||||
|
||||
console.log('环境变量解析结果:', {
|
||||
apiBaseUrl,
|
||||
wsBaseUrl,
|
||||
uploadUrl,
|
||||
debug,
|
||||
mock,
|
||||
appTitle,
|
||||
appVersion
|
||||
})
|
||||
|
||||
// 如果环境变量存在,直接使用
|
||||
if (apiBaseUrl) {
|
||||
return {
|
||||
name: getEnvironmentName(env),
|
||||
apiBaseUrl,
|
||||
wsBaseUrl: wsBaseUrl || apiBaseUrl.replace('http', 'ws').replace('/api', ''),
|
||||
uploadUrl: uploadUrl || `${apiBaseUrl}/upload`,
|
||||
debug,
|
||||
mock,
|
||||
appTitle: appTitle || `情绪博物馆 - ${getEnvironmentName(env)}`,
|
||||
appVersion
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有环境变量,使用默认配置
|
||||
switch (env) {
|
||||
case 'local':
|
||||
return {
|
||||
name: '本地环境',
|
||||
apiBaseUrl: 'http://localhost:19089/api',
|
||||
wsBaseUrl: 'ws://localhost:19089/api',
|
||||
uploadUrl: 'http://localhost:19089/api/upload',
|
||||
debug: true,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 本地',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
|
||||
case 'dev':
|
||||
return {
|
||||
name: '开发环境',
|
||||
apiBaseUrl: 'http://localhost:19089/api',
|
||||
wsBaseUrl: 'ws://localhost:19089/api',
|
||||
uploadUrl: 'http://localhost:19089/api/upload',
|
||||
debug: true,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 开发',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
|
||||
case 'test':
|
||||
return {
|
||||
name: '测试环境',
|
||||
apiBaseUrl: 'http://test.emotion-museum.com/api',
|
||||
wsBaseUrl: 'ws://test.emotion-museum.com',
|
||||
uploadUrl: 'http://test.emotion-museum.com/api/upload',
|
||||
debug: false,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆 - 测试',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
|
||||
case 'prod':
|
||||
return {
|
||||
name: '生产环境',
|
||||
apiBaseUrl: 'https://api.emotion-museum.com/api',
|
||||
wsBaseUrl: 'wss://api.emotion-museum.com',
|
||||
uploadUrl: 'https://api.emotion-museum.com/api/upload',
|
||||
debug: false,
|
||||
mock: false,
|
||||
appTitle: '情绪博物馆',
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`未知的环境类型: ${env}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取环境名称
|
||||
const getEnvironmentName = (env: EnvType): string => {
|
||||
switch (env) {
|
||||
case 'local': return '本地环境'
|
||||
case 'dev': return '开发环境'
|
||||
case 'test': return '测试环境'
|
||||
case 'prod': return '生产环境'
|
||||
default: return '未知环境'
|
||||
}
|
||||
}
|
||||
|
||||
// 导出当前环境配置
|
||||
export const envConfig = getEnvConfig()
|
||||
|
||||
// 导出常用配置
|
||||
export const {
|
||||
apiBaseUrl,
|
||||
wsBaseUrl,
|
||||
uploadUrl,
|
||||
debug,
|
||||
mock,
|
||||
appTitle,
|
||||
appVersion
|
||||
} = envConfig
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
+19
-12
@@ -1,22 +1,29 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
// Ant Design Vue
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import './assets/styles/index.css'
|
||||
|
||||
// 全局样式
|
||||
import '@/assets/styles/global.scss'
|
||||
|
||||
// 创建应用实例
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 使用插件
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
// 初始化认证状态
|
||||
const authStore = useAuthStore()
|
||||
authStore.initAuth().then(() => {
|
||||
app.mount('#app')
|
||||
}).catch((error) => {
|
||||
console.error('初始化认证状态失败:', error)
|
||||
app.mount('#app')
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 路由守卫
|
||||
*/
|
||||
|
||||
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
// 不需要登录的路由白名单
|
||||
const whiteList = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/',
|
||||
'/404',
|
||||
'/403',
|
||||
'/500'
|
||||
]
|
||||
|
||||
// 需要登录的路由
|
||||
const authRequiredRoutes = [
|
||||
'/chat',
|
||||
'/chat-history',
|
||||
'/diary',
|
||||
'/life-milestones',
|
||||
'/life-trajectory',
|
||||
'/messages',
|
||||
'/personal-dashboard',
|
||||
'/settings',
|
||||
'/topic-tracker',
|
||||
'/emotion',
|
||||
'/map',
|
||||
'/social',
|
||||
'/analysis',
|
||||
'/profile'
|
||||
]
|
||||
|
||||
/**
|
||||
* 检查路由是否需要认证
|
||||
*/
|
||||
const requiresAuth = (path: string): boolean => {
|
||||
return authRequiredRoutes.some(route => path.startsWith(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路由是否在白名单中
|
||||
*/
|
||||
const isInWhiteList = (path: string): boolean => {
|
||||
return whiteList.includes(path) || whiteList.some(route => path.startsWith(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查守卫
|
||||
*/
|
||||
const authGuard = async (
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 如果目标路由不需要认证,直接通过
|
||||
if (!requiresAuth(to.path)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已登录
|
||||
if (!authStore.isLoggedIn) {
|
||||
// 未登录,跳转到登录页
|
||||
ElMessage.warning('请先登录')
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录,检查token是否有效
|
||||
try {
|
||||
// 这里可以添加token验证逻辑
|
||||
// const isValid = await authStore.validateToken()
|
||||
// if (!isValid) {
|
||||
// throw new Error('Token无效')
|
||||
// }
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('Token验证失败:', error)
|
||||
ElMessage.error('登录状态已过期,请重新登录')
|
||||
|
||||
// 清除认证状态
|
||||
await authStore.logout()
|
||||
|
||||
// 跳转到登录页
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面标题守卫
|
||||
*/
|
||||
const titleGuard = (
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) => {
|
||||
// 设置页面标题
|
||||
const title = to.meta.title as string
|
||||
if (title) {
|
||||
document.title = `${title} - ${envConfig.appTitle}`
|
||||
} else {
|
||||
document.title = envConfig.appTitle
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面加载进度守卫
|
||||
*/
|
||||
const progressGuard = (
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) => {
|
||||
// 这里可以添加页面加载进度条逻辑
|
||||
// NProgress.start()
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面加载完成守卫
|
||||
*/
|
||||
const progressDoneGuard = () => {
|
||||
// 这里可以添加页面加载完成逻辑
|
||||
// NProgress.done()
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录重定向守卫
|
||||
*/
|
||||
const loginRedirectGuard = (
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 如果已登录且访问登录页,重定向到首页
|
||||
if (authStore.isLoggedIn && (to.path === '/login' || to.path === '/register')) {
|
||||
const redirect = to.query.redirect as string || '/'
|
||||
next(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
// 移除权限检查守卫,该功能不存在
|
||||
|
||||
/**
|
||||
* 安装路由守卫
|
||||
*/
|
||||
export const setupRouterGuards = (router: Router) => {
|
||||
// 全局前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
try {
|
||||
// 页面加载进度
|
||||
// NProgress.start()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
console.log('🔍 路由守卫检查:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
hasToken: !!authStore.accessToken,
|
||||
hasUserInfo: !!authStore.userInfo
|
||||
})
|
||||
|
||||
// 如果已登录且访问登录页,重定向到首页
|
||||
if (authStore.isLoggedIn && (to.path === '/login' || to.path === '/register')) {
|
||||
const redirect = to.query.redirect as string || '/'
|
||||
console.log('🔍 已登录用户访问登录页,重定向到:', redirect)
|
||||
next(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否需要认证
|
||||
if (requiresAuth(to.path)) {
|
||||
console.log('🔍 页面需要认证:', to.path)
|
||||
|
||||
// 如果当前未登录,先尝试恢复本地存储的认证状态
|
||||
if (!authStore.isLoggedIn) {
|
||||
console.log('🔍 路由守卫:尝试恢复本地认证状态')
|
||||
const restored = authStore.restoreLocalAuth()
|
||||
console.log('🔍 路由守卫:恢复结果:', restored)
|
||||
|
||||
if (restored) {
|
||||
console.log('🔍 认证状态已恢复:', {
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
hasToken: !!authStore.accessToken,
|
||||
hasUserInfo: !!authStore.userInfo
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 再次检查登录状态
|
||||
if (!authStore.isLoggedIn) {
|
||||
console.log('🔍 用户未登录,跳转到登录页')
|
||||
// 未登录,跳转到登录页
|
||||
ElMessage.warning('请先登录')
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔍 用户已登录,允许访问:', to.path)
|
||||
}
|
||||
|
||||
// 设置页面标题
|
||||
const title = to.meta.title as string
|
||||
if (title) {
|
||||
document.title = `${title} - ${envConfig.appTitle}`
|
||||
} else {
|
||||
document.title = envConfig.appTitle
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('路由守卫错误:', error)
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// 全局后置守卫
|
||||
router.afterEach((to, from) => {
|
||||
// 页面加载完成
|
||||
progressDoneGuard()
|
||||
|
||||
// 页面访问统计
|
||||
if (envConfig.debug) {
|
||||
console.log(`路由跳转: ${from.path} -> ${to.path}`)
|
||||
}
|
||||
})
|
||||
|
||||
// 全局解析守卫
|
||||
router.beforeResolve((to, from, next) => {
|
||||
// 这里可以添加异步数据加载逻辑
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
authGuard,
|
||||
titleGuard,
|
||||
progressGuard,
|
||||
progressDoneGuard,
|
||||
loginRedirectGuard,
|
||||
requiresAuth,
|
||||
isInWhiteList
|
||||
}
|
||||
+172
-92
@@ -1,5 +1,19 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { setupRouterGuards } from './guards'
|
||||
|
||||
// 扩展路由元信息类型
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
role?: string
|
||||
icon?: string
|
||||
hidden?: boolean
|
||||
keepAlive?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -7,8 +21,9 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home/index.vue'),
|
||||
meta: {
|
||||
title: '开心APP - 你的情绪陪伴使者',
|
||||
keepAlive: true
|
||||
title: '首页',
|
||||
requiresAuth: false,
|
||||
icon: 'House'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -16,8 +31,20 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/Chat/index.vue'),
|
||||
meta: {
|
||||
title: '与开开聊天',
|
||||
requiresAuth: false
|
||||
title: 'AI对话',
|
||||
requiresAuth: true,
|
||||
icon: 'ChatDotRound',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/chat-history',
|
||||
name: 'ChatHistory',
|
||||
component: () => import('@/views/ChatHistory/index.vue'),
|
||||
meta: {
|
||||
title: '聊天历史',
|
||||
requiresAuth: true,
|
||||
icon: 'Clock'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -26,34 +53,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/Diary/index.vue'),
|
||||
meta: {
|
||||
title: '情绪日记',
|
||||
requiresAuth: false
|
||||
requiresAuth: true,
|
||||
icon: 'EditPen'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard/index.vue'),
|
||||
path: '/life-milestones',
|
||||
name: 'LifeMilestones',
|
||||
component: () => import('@/views/LifeMilestones/index.vue'),
|
||||
meta: {
|
||||
title: '个人展板',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile/index.vue'),
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/topic-tracker',
|
||||
name: 'TopicTracker',
|
||||
component: () => import('@/views/TopicTracker/index.vue'),
|
||||
meta: {
|
||||
title: '话题追踪',
|
||||
requiresAuth: false
|
||||
title: '人生里程碑',
|
||||
requiresAuth: true,
|
||||
icon: 'Trophy'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -62,7 +73,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/LifeTrajectory/index.vue'),
|
||||
meta: {
|
||||
title: '人生轨迹',
|
||||
requiresAuth: false
|
||||
requiresAuth: true,
|
||||
icon: 'TrendCharts'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -71,7 +83,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/Messages/index.vue'),
|
||||
meta: {
|
||||
title: '消息中心',
|
||||
requiresAuth: false
|
||||
requiresAuth: true,
|
||||
icon: 'Message'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/personal-dashboard',
|
||||
name: 'PersonalDashboard',
|
||||
component: () => import('@/views/PersonalDashboard/index.vue'),
|
||||
meta: {
|
||||
title: '个人仪表盘',
|
||||
requiresAuth: true,
|
||||
icon: 'DataBoard'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -79,26 +102,81 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings/index.vue'),
|
||||
meta: {
|
||||
title: '用户设置',
|
||||
requiresAuth: false
|
||||
title: '设置',
|
||||
requiresAuth: true,
|
||||
icon: 'Setting'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/chat-history',
|
||||
name: 'ChatHistory',
|
||||
component: () => import('@/views/Chat/History.vue'),
|
||||
path: '/topic-tracker',
|
||||
name: 'TopicTracker',
|
||||
component: () => import('@/views/TopicTracker/index.vue'),
|
||||
meta: {
|
||||
title: '聊天历史',
|
||||
requiresAuth: false
|
||||
title: '话题追踪',
|
||||
requiresAuth: true,
|
||||
icon: 'Search'
|
||||
}
|
||||
},
|
||||
// 兼容原有页面
|
||||
{
|
||||
path: '/emotion',
|
||||
name: 'Emotion',
|
||||
component: () => import('@/views/Emotion/index.vue'),
|
||||
meta: {
|
||||
title: '情绪管理',
|
||||
requiresAuth: true,
|
||||
icon: 'Sunny'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/map',
|
||||
name: 'Map',
|
||||
component: () => import('@/views/Map/index.vue'),
|
||||
meta: {
|
||||
title: '情绪地图',
|
||||
requiresAuth: true,
|
||||
icon: 'Location'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/social',
|
||||
name: 'Social',
|
||||
component: () => import('@/views/Social/index.vue'),
|
||||
meta: {
|
||||
title: '社交分享',
|
||||
requiresAuth: true,
|
||||
icon: 'Share'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'Analysis',
|
||||
component: () => import('@/views/Analysis/index.vue'),
|
||||
meta: {
|
||||
title: '情绪分析',
|
||||
requiresAuth: true,
|
||||
icon: 'TrendCharts'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile/index.vue'),
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
requiresAuth: true,
|
||||
icon: 'User'
|
||||
}
|
||||
},
|
||||
// 认证相关页面
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login/index.vue'),
|
||||
meta: {
|
||||
title: '用户登录',
|
||||
requiresAuth: false
|
||||
title: '登录',
|
||||
requiresAuth: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -106,25 +184,65 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register/index.vue'),
|
||||
meta: {
|
||||
title: '用户注册',
|
||||
requiresAuth: false
|
||||
title: '注册',
|
||||
requiresAuth: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
// 调试页面(仅开发环境)
|
||||
{
|
||||
path: '/debug',
|
||||
name: 'Debug',
|
||||
component: () => import('@/views/Debug/index.vue'),
|
||||
meta: {
|
||||
title: '环境变量调试',
|
||||
requiresAuth: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/debug/websocket',
|
||||
name: 'WebSocketTest',
|
||||
component: () => import('@/views/Debug/WebSocketTest.vue'),
|
||||
meta: {
|
||||
title: 'WebSocket测试',
|
||||
requiresAuth: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
|
||||
// 错误页面
|
||||
{
|
||||
path: '/403',
|
||||
name: 'Forbidden',
|
||||
component: () => import('@/views/NotFound/index.vue'),
|
||||
meta: {
|
||||
title: '访问被拒绝',
|
||||
requiresAuth: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound/index.vue'),
|
||||
meta: {
|
||||
title: '页面未找到',
|
||||
requiresAuth: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: {
|
||||
title: '页面未找到'
|
||||
}
|
||||
redirect: '/404'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
history: createWebHistory('/emotion-museum/'),
|
||||
routes,
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// 路由切换时的滚动行为
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
@@ -133,49 +251,11 @@ const router = createRouter({
|
||||
}
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title as string
|
||||
}
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth) {
|
||||
// 动态导入用户store以避免循环依赖
|
||||
const { useUserStore } = await import('@/stores/user')
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (!userStore.isLoggedIn) {
|
||||
// 保存当前路径,登录后跳转回来
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已登录用户访问登录/注册页面,重定向到首页
|
||||
if (to.path === '/login' || to.path === '/register') {
|
||||
const { useUserStore } = await import('@/stores/user')
|
||||
const userStore = useUserStore()
|
||||
|
||||
console.log('路由守卫检查登录状态:', {
|
||||
path: to.path,
|
||||
isLoggedIn: userStore.isLoggedIn,
|
||||
token: !!userStore.token,
|
||||
userInfo: !!userStore.userInfo
|
||||
})
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
console.log('用户已登录,重定向到首页')
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
// 设置路由守卫
|
||||
setupRouterGuards(router)
|
||||
|
||||
export default router
|
||||
|
||||
// 导出路由相关工具
|
||||
export { routes }
|
||||
export type { RouteRecordRaw }
|
||||
@@ -1,184 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// 创建axios实例
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// 添加认证token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 添加请求时间戳
|
||||
config.headers['X-Request-Time'] = Date.now().toString()
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('Request error:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const { data } = response
|
||||
|
||||
// 检查业务状态码
|
||||
if (data.code !== 200) {
|
||||
console.error('API Business Error:', {
|
||||
code: data.code,
|
||||
message: data.message,
|
||||
url: response.config.url
|
||||
})
|
||||
|
||||
// 对于认证错误,特殊处理
|
||||
if (data.code === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
const error = new Error(data.message || '请求失败') as any
|
||||
error.response = response
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// 处理HTTP错误
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
console.error('HTTP Error:', {
|
||||
status,
|
||||
url: error.config?.url,
|
||||
message: data?.message || error.message
|
||||
})
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 未授权,清除token并跳转到登录页
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
break
|
||||
case 403:
|
||||
console.error('Access forbidden')
|
||||
break
|
||||
case 404:
|
||||
console.error('Resource not found')
|
||||
break
|
||||
case 500:
|
||||
console.error('Server error')
|
||||
break
|
||||
default:
|
||||
console.error('HTTP Error:', status, data?.message || error.message)
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('Network error:', error.message)
|
||||
} else {
|
||||
console.error('Request setup error:', error.message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 通用请求方法
|
||||
export const request = {
|
||||
get: <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> =>
|
||||
api.get(url, config).then(res => res.data.data),
|
||||
|
||||
post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> =>
|
||||
api.post(url, data, config).then(res => res.data.data),
|
||||
|
||||
put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> =>
|
||||
api.put(url, data, config).then(res => res.data.data),
|
||||
|
||||
delete: <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> =>
|
||||
api.delete(url, config).then(res => res.data.data),
|
||||
|
||||
patch: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> =>
|
||||
api.patch(url, data, config).then(res => res.data.data),
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
export const uploadFile = (file: File, onProgress?: (progress: number) => void): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return api.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
}).then(res => res.data.data.url)
|
||||
}
|
||||
|
||||
// 消息相关API
|
||||
export const messageApi = {
|
||||
// 获取用户消息分页
|
||||
getUserMessages: (current: number = 1, size: number = 20) =>
|
||||
request.get(`/message/user/page`, { params: { current, size } }),
|
||||
|
||||
// 搜索用户消息
|
||||
searchUserMessages: (keyword: string, limit: number = 50) =>
|
||||
request.post(`/message/user/search`, { keyword, limit }),
|
||||
|
||||
// 获取用户最近的聊天记录
|
||||
getRecentMessages: (limit: number = 10) =>
|
||||
request.post(`/message/user/recent`, { limit }),
|
||||
|
||||
// 获取消息详情
|
||||
getMessageById: (id: string) =>
|
||||
request.get(`/message/${id}`)
|
||||
}
|
||||
|
||||
// 情绪记录相关API
|
||||
export const emotionRecordApi = {
|
||||
// 获取用户情绪记录分页
|
||||
getUserEmotionRecords: (current: number = 1, size: number = 10) =>
|
||||
request.get(`/emotion-records/user`, { params: { current, size } }),
|
||||
|
||||
// 获取用户最近情绪记录
|
||||
getUserRecentEmotionRecords: (limit: number = 5) =>
|
||||
request.get(`/emotion-records/user/recent`, { params: { limit } }),
|
||||
|
||||
// 获取情绪记录详情
|
||||
getEmotionRecordById: (id: string) =>
|
||||
request.get(`/emotion-records/${id}`),
|
||||
|
||||
// 删除情绪记录
|
||||
deleteEmotionRecord: (id: string) =>
|
||||
request.delete(`/emotion-records/${id}`)
|
||||
}
|
||||
|
||||
// 情绪总结相关API
|
||||
export const emotionSummaryApi = {
|
||||
// 生成情绪记录总结
|
||||
generateEmotionSummary: () =>
|
||||
request.post(`/emotion-summary/generate`),
|
||||
|
||||
// 获取情绪记录总结状态
|
||||
getEmotionSummaryStatus: () =>
|
||||
request.get(`/emotion-summary/status`)
|
||||
}
|
||||
|
||||
export default api
|
||||
+198
-91
@@ -1,114 +1,221 @@
|
||||
import request from '@/utils/request'
|
||||
/**
|
||||
* 认证相关API服务
|
||||
*/
|
||||
|
||||
import { http } from '@/utils/request'
|
||||
import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
AuthResponse,
|
||||
UserInfo,
|
||||
CaptchaResponse,
|
||||
ApiResponse,
|
||||
RefreshTokenRequest,
|
||||
ChangePasswordRequest,
|
||||
ForgotPasswordRequest,
|
||||
ResetPasswordRequest,
|
||||
UserInfo
|
||||
SendCodeRequest,
|
||||
VerifyCodeRequest,
|
||||
OAuthLoginRequest,
|
||||
BindOAuthRequest,
|
||||
UnbindOAuthRequest,
|
||||
OAuthInfo,
|
||||
LoginHistory,
|
||||
OnlineUser
|
||||
} from '@/types/auth'
|
||||
|
||||
export const authService = {
|
||||
// 获取验证码
|
||||
async getCaptcha(): Promise<CaptchaResponse> {
|
||||
return await request.get('/auth/captcha')
|
||||
},
|
||||
|
||||
// 用户登录
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
return await request.post('/auth/login', data)
|
||||
},
|
||||
|
||||
// 用户注册
|
||||
async register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
return await request.post('/auth/register', data)
|
||||
},
|
||||
|
||||
// 刷新token
|
||||
async refreshToken(data: RefreshTokenRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
const response = await request.post('/auth/refresh-token', data)
|
||||
/**
|
||||
* 认证API服务类
|
||||
*/
|
||||
export class AuthService {
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
static async login(data: LoginRequest): Promise<AuthResponse> {
|
||||
const response = await http.post<AuthResponse>('/auth/login', data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// 用户登出
|
||||
async logout(): Promise<ApiResponse<void>> {
|
||||
const response = await request.post('/auth/logout')
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
static async register(data: RegisterRequest): Promise<AuthResponse> {
|
||||
const response = await http.post<AuthResponse>('/auth/register', data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
|
||||
const response = await request.get('/auth/user-info')
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
static async getCaptcha(): Promise<CaptchaResponse> {
|
||||
const response = await http.get<CaptchaResponse>('/auth/captcha')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
async changePassword(data: ChangePasswordRequest): Promise<ApiResponse<void>> {
|
||||
const response = await request.post('/auth/change-password', data)
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
static async logout(): Promise<void> {
|
||||
const response = await http.post<void>('/auth/logout')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// 忘记密码
|
||||
async forgotPassword(data: ForgotPasswordRequest): Promise<ApiResponse<void>> {
|
||||
return await request.post('/forgot-password', data)
|
||||
},
|
||||
/**
|
||||
* 刷新Token
|
||||
*/
|
||||
static async refreshToken(data: RefreshTokenRequest): Promise<AuthResponse> {
|
||||
const response = await http.post<AuthResponse>('/auth/refresh-token', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
async resetPassword(data: ResetPasswordRequest): Promise<ApiResponse<void>> {
|
||||
return await request.post('/reset-password', data)
|
||||
},
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
static async getCurrentUserInfo(): Promise<UserInfo> {
|
||||
const response = await http.get<UserInfo>('/auth/user/info')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证token有效性
|
||||
async validateToken(): Promise<ApiResponse<boolean>> {
|
||||
return await request.get('/validate-token')
|
||||
},
|
||||
/**
|
||||
* 验证Token是否有效
|
||||
*/
|
||||
static async validateToken(): Promise<boolean> {
|
||||
const response = await http.get<boolean>('/auth/validate-token')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 检查账号是否存在
|
||||
async checkAccount(account: string): Promise<ApiResponse<boolean>> {
|
||||
return await request.get(`/check-account?account=${account}`)
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
static async changePassword(data: ChangePasswordRequest): Promise<void> {
|
||||
return http.post<void>('/auth/change-password', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
static async resetPassword(data: ResetPasswordRequest): Promise<void> {
|
||||
return http.post<void>('/auth/reset-password', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
static async sendCode(data: SendCodeRequest): Promise<void> {
|
||||
return http.post<void>('/auth/send-code', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
*/
|
||||
static async verifyCode(data: VerifyCodeRequest): Promise<boolean> {
|
||||
return http.post<boolean>('/auth/verify-code', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三方登录
|
||||
*/
|
||||
static async oauthLogin(data: OAuthLoginRequest): Promise<AuthResponse> {
|
||||
return http.post<AuthResponse>('/auth/oauth/login', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定第三方账号
|
||||
*/
|
||||
static async bindOAuth(data: BindOAuthRequest): Promise<void> {
|
||||
return http.post<void>('/auth/oauth/bind', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑第三方账号
|
||||
*/
|
||||
static async unbindOAuth(data: UnbindOAuthRequest): Promise<void> {
|
||||
return http.post<void>('/auth/oauth/unbind', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第三方账号绑定信息
|
||||
*/
|
||||
static async getOAuthInfo(): Promise<OAuthInfo[]> {
|
||||
return http.get<OAuthInfo[]>('/auth/oauth/info')
|
||||
}
|
||||
|
||||
// 移除权限接口,该接口不存在
|
||||
|
||||
/**
|
||||
* 获取登录历史
|
||||
*/
|
||||
static async getLoginHistory(page = 1, size = 10): Promise<{
|
||||
list: LoginHistory[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}> {
|
||||
return http.get<{
|
||||
list: LoginHistory[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}>('/auth/login-history', {
|
||||
params: { page, size }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户列表(管理员功能)
|
||||
*/
|
||||
static async getOnlineUsers(page = 1, size = 10): Promise<{
|
||||
list: OnlineUser[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}> {
|
||||
return http.get<{
|
||||
list: OnlineUser[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}>('/auth/online-users', {
|
||||
params: { page, size }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制下线用户(管理员功能)
|
||||
*/
|
||||
static async forceLogout(userId: string): Promise<void> {
|
||||
return http.post<void>(`/auth/force-logout/${userId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号是否存在
|
||||
*/
|
||||
static async checkAccountExists(account: string): Promise<boolean> {
|
||||
const response = await http.get<boolean>('/auth/check-account', {
|
||||
params: { account }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查邮箱是否存在
|
||||
*/
|
||||
static async checkEmailExists(email: string): Promise<boolean> {
|
||||
const response = await http.get<boolean>('/auth/check-email', {
|
||||
params: { email }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查手机号是否存在
|
||||
*/
|
||||
static async checkPhoneExists(phone: string): Promise<boolean> {
|
||||
const response = await http.get<boolean>('/auth/check-phone', {
|
||||
params: { phone }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
export const authUtils = {
|
||||
// 获取token
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('token')
|
||||
},
|
||||
|
||||
// 设置token
|
||||
setToken(token: string): void {
|
||||
localStorage.setItem('token', token)
|
||||
},
|
||||
|
||||
// 移除token
|
||||
removeToken(): void {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
getUserInfo(): UserInfo | null {
|
||||
const userInfo = localStorage.getItem('userInfo')
|
||||
return userInfo ? JSON.parse(userInfo) : null
|
||||
},
|
||||
|
||||
// 设置用户信息
|
||||
setUserInfo(userInfo: UserInfo): void {
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo))
|
||||
},
|
||||
|
||||
// 检查是否已登录
|
||||
isLoggedIn(): boolean {
|
||||
return !!this.getToken()
|
||||
},
|
||||
|
||||
// 清除所有认证信息
|
||||
clearAuth(): void {
|
||||
this.removeToken()
|
||||
}
|
||||
}
|
||||
// 导出默认实例
|
||||
export default AuthService
|
||||
|
||||
+196
-49
@@ -1,51 +1,198 @@
|
||||
import { request } from './api'
|
||||
import type { ChatMessage, ChatSession, PaginatedResponse } from '@/types'
|
||||
import { http } from '@/utils/request'
|
||||
import type { ChatMessage, ChatSession } from '@/types'
|
||||
|
||||
export const chatApi = {
|
||||
// 发送AI聊天消息(REST备用,主用WebSocket)
|
||||
sendAiMessage: (conversationId: string, message: string, userId: string): Promise<any> =>
|
||||
request.post('/ai/chat', { conversationId, message, userId }),
|
||||
|
||||
// 创建会话
|
||||
createSession: (userId: string, title: string): Promise<ChatSession> =>
|
||||
request.post('/conversation', { userId, title }),
|
||||
|
||||
// 获取会话分页
|
||||
getSessions: (params: { page: number, size: number, userId?: string }): Promise<PaginatedResponse<ChatSession>> =>
|
||||
request.get('/conversation/page', { params }),
|
||||
|
||||
// 获取用户所有会话
|
||||
getUserSessions: (userId: string): Promise<ChatSession[]> =>
|
||||
request.get(`/conversation/user/${userId}`),
|
||||
|
||||
// 删除会话
|
||||
deleteSession: (id: string): Promise<void> =>
|
||||
request.delete(`/conversation/${id}`),
|
||||
|
||||
// 更新会话标题
|
||||
updateSessionTitle: (id: string, title: string): Promise<ChatSession> =>
|
||||
request.put(`/conversation/${id}`, { title }),
|
||||
|
||||
// 获取会话消息分页
|
||||
getSessionMessages: (conversationId: string, params: { page: number, size: number }): Promise<PaginatedResponse<ChatMessage>> =>
|
||||
request.get(`/message/conversation/${conversationId}/page`, { params }),
|
||||
|
||||
// 获取会话所有消息
|
||||
getAllSessionMessages: (conversationId: string): Promise<ChatMessage[]> =>
|
||||
request.get(`/message/conversation/${conversationId}`),
|
||||
|
||||
// 创建消息(保存到数据库)
|
||||
createMessage: (data: {
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
content: string,
|
||||
contentType?: string,
|
||||
senderType?: string,
|
||||
senderId?: string
|
||||
}): Promise<ChatMessage> =>
|
||||
request.post('/message', data),
|
||||
|
||||
// 聊天统计
|
||||
getChatStats: (userId?: string, conversationId?: string): Promise<any> =>
|
||||
request.get('/ai/stats', { params: { userId, conversationId } }),
|
||||
export interface CreateSessionRequest {
|
||||
userId: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
id: string
|
||||
title: string
|
||||
userId: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface GetSessionsResponse {
|
||||
sessions: ChatSession[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GetMessagesResponse {
|
||||
messages: ChatMessage[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// 后端实际返回的会话数据结构
|
||||
export interface ConversationResponse {
|
||||
id: string
|
||||
userId: string
|
||||
userType: string
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
lastActiveTime: string
|
||||
messageCount: number
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天API服务
|
||||
*/
|
||||
export class ChatApiService {
|
||||
|
||||
/**
|
||||
* 创建聊天会话
|
||||
*/
|
||||
async createSession(userId: string, title?: string): Promise<ChatSession> {
|
||||
try {
|
||||
console.log('📝 创建会话API调用:', { userId, title })
|
||||
const response = await http.post<ConversationResponse>('/conversation', {
|
||||
userId,
|
||||
title: title || `对话${Date.now()}`
|
||||
})
|
||||
console.log('📝 创建会话API响应:', response)
|
||||
|
||||
// 处理HTTP响应的data字段
|
||||
const data = (response as any).data || response
|
||||
|
||||
// 转换后端数据格式为前端格式 - 完整匹配ChatSession接口
|
||||
return {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
userId: data.userId || data.user_id,
|
||||
userType: data.userType || data.user_type,
|
||||
type: data.type,
|
||||
status: data.status,
|
||||
createTime: data.createTime || data.create_time,
|
||||
updateTime: data.updateTime || data.update_time,
|
||||
messageCount: data.messageCount || data.message_count || 0,
|
||||
startTime: data.startTime || data.start_time,
|
||||
endTime: data.endTime || data.end_time,
|
||||
lastActiveTime: data.lastActiveTime || data.last_active_time
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 创建会话失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有会话
|
||||
*/
|
||||
async getUserSessions(userId: string): Promise<ChatSession[]> {
|
||||
try {
|
||||
console.log('📂 获取用户会话API调用:', { userId })
|
||||
const response = await http.get<ConversationResponse[]>(`/conversation/user/${userId}`)
|
||||
console.log('📂 获取用户会话API响应:', response)
|
||||
|
||||
// 处理HTTP响应的data字段
|
||||
const data = (response as any).data || response
|
||||
|
||||
// 后端返回ConversationResponse数组,需要转换为ChatSession格式
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((conv: any) => ({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
userId: conv.userId || conv.user_id, // 兼容不同的字段名
|
||||
createTime: conv.createTime || conv.create_time,
|
||||
updateTime: conv.updateTime || conv.update_time,
|
||||
messageCount: conv.messageCount || conv.message_count || 0
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('❌ 获取用户会话失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话的所有消息
|
||||
* 注意:后端没有按会话ID获取消息的接口,这里返回空数组
|
||||
* 实际的消息加载通过messageApi的接口实现
|
||||
*/
|
||||
async getAllSessionMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
console.log('⚠️ getAllSessionMessages: 后端没有此接口,返回空数组。会话ID:', sessionId)
|
||||
console.log('💡 建议使用messageApi.getRecentMessages()或messageApi.getUserMessages()获取消息')
|
||||
|
||||
// 返回空数组,避免404错误
|
||||
// 实际的消息加载由messageApi处理
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
console.log('🗑️ 删除会话API调用:', { sessionId })
|
||||
await http.delete(`/conversation/${sessionId}`)
|
||||
console.log('✅ 删除会话成功')
|
||||
} catch (error) {
|
||||
console.error('❌ 删除会话失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话标题
|
||||
*/
|
||||
async updateSessionTitle(sessionId: string, title: string): Promise<void> {
|
||||
try {
|
||||
console.log('✏️ 更新会话标题API调用:', { sessionId, title })
|
||||
await http.put(`/conversation/${sessionId}`, { title })
|
||||
console.log('✅ 更新会话标题成功')
|
||||
} catch (error) {
|
||||
console.error('❌ 更新会话标题失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的消息
|
||||
* 注意:这个方法已废弃,请使用messageApi.getRecentMessages()
|
||||
*/
|
||||
async getRecentMessages(params: { limit?: number } = {}): Promise<ChatMessage[]> {
|
||||
try {
|
||||
console.log('⚠️ getRecentMessages: 此方法已废弃,请使用messageApi.getRecentMessages()')
|
||||
console.log('💡 建议直接调用messageApi.getRecentMessages(limit)')
|
||||
console.log('📝 参数:', params)
|
||||
|
||||
// 返回空数组,避免调用不存在的接口
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('❌ getRecentMessages错误:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索消息
|
||||
* 注意:这个方法已废弃,请使用messageApi.searchUserMessages()
|
||||
*/
|
||||
async searchMessages(keyword: string, sessionId?: string): Promise<ChatMessage[]> {
|
||||
try {
|
||||
console.log('⚠️ searchMessages: 此方法已废弃,请使用messageApi.searchUserMessages()')
|
||||
console.log('💡 建议直接调用messageApi.searchUserMessages(keyword, limit)')
|
||||
console.log('📝 参数:', { keyword, sessionId })
|
||||
|
||||
// 返回空数组,避免调用不存在的接口
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('❌ searchMessages错误:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建聊天API服务实例
|
||||
export const chatApi = new ChatApiService()
|
||||
|
||||
export default chatApi
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export function publishDiary(data: any) {
|
||||
return request.post('/diary-post/publish', data);
|
||||
}
|
||||
|
||||
export function getUserDiaries(userId: string, page = 1, size = 10) {
|
||||
return request.get(`/diary-post/user/${userId}/page`, {
|
||||
params: { current: page, size }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { http } from '@/utils/request'
|
||||
import type { ChatMessage } from '@/types'
|
||||
|
||||
// 消息相关类型定义
|
||||
export interface MessageResponse {
|
||||
id: string
|
||||
conversationId: string
|
||||
content: string
|
||||
type: string
|
||||
sender: string
|
||||
isRead: number
|
||||
aiReply?: string
|
||||
emotionAnalysis?: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
total: number
|
||||
size: number
|
||||
current: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface MessagePageRequest {
|
||||
current: number
|
||||
size: number
|
||||
conversationId?: string
|
||||
type?: string
|
||||
sender?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export interface MessageSearchRequest {
|
||||
keyword: string
|
||||
limit: number
|
||||
conversationId?: string
|
||||
type?: string
|
||||
sender?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export interface MessageRecentRequest {
|
||||
limit: number
|
||||
conversationId?: string
|
||||
type?: string
|
||||
sender?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息API服务 - 与web项目保持一致的接口
|
||||
*/
|
||||
export const messageApi = {
|
||||
// 获取用户消息分页
|
||||
getUserMessages: async (current: number = 1, size: number = 20) => {
|
||||
console.log('📨 调用getUserMessages API:', { current, size })
|
||||
const response = await http.get(`/message/user/page`, { params: { current, size } })
|
||||
console.log('📨 getUserMessages API响应:', response)
|
||||
return response
|
||||
},
|
||||
|
||||
// 搜索用户消息
|
||||
searchUserMessages: async (keyword: string, limit: number = 50) => {
|
||||
console.log('🔍 调用searchUserMessages API:', { keyword, limit })
|
||||
const response = await http.post(`/message/user/search`, { keyword, limit })
|
||||
console.log('🔍 searchUserMessages API响应:', response)
|
||||
return response
|
||||
},
|
||||
|
||||
// 获取用户最近的聊天记录 - 修复:使用POST请求匹配后端接口
|
||||
getRecentMessages: async (limit: number = 10) => {
|
||||
console.log('📝 调用getRecentMessages API:', { limit })
|
||||
const response = await http.post(`/message/user/recent`, { limit })
|
||||
console.log('📝 getRecentMessages API响应:', response)
|
||||
return response
|
||||
},
|
||||
|
||||
// 获取消息详情
|
||||
getMessageById: async (id: string) => {
|
||||
console.log('📄 调用getMessageById API:', { id })
|
||||
const response = await http.get(`/message/${id}`)
|
||||
console.log('📄 getMessageById API响应:', response)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息服务类 - 提供高级封装
|
||||
*/
|
||||
class MessageService {
|
||||
/**
|
||||
* 获取用户消息分页
|
||||
*/
|
||||
static async getUserMessages(current: number = 1, size: number = 20): Promise<PageResult<MessageResponse>> {
|
||||
const response = await messageApi.getUserMessages(current, size)
|
||||
return response.data || response
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户消息
|
||||
*/
|
||||
static async searchUserMessages(request: MessageSearchRequest): Promise<MessageResponse[]> {
|
||||
const response = await messageApi.searchUserMessages(request.keyword, request.limit)
|
||||
return response.data || response
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最近的聊天记录
|
||||
*/
|
||||
static async getRecentMessages(request: MessageRecentRequest): Promise<MessageResponse[]> {
|
||||
console.log('📝 MessageService.getRecentMessages 调用:', request)
|
||||
const response = await messageApi.getRecentMessages(request.limit)
|
||||
console.log('📝 MessageService.getRecentMessages 响应:', response)
|
||||
|
||||
// 处理响应数据结构
|
||||
const messageList = response.data || response || []
|
||||
return Array.isArray(messageList) ? messageList : []
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取消息详情
|
||||
*/
|
||||
static async getMessageById(id: string): Promise<MessageResponse> {
|
||||
const response = await messageApi.getMessageById(id)
|
||||
return response.data || response
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换消息格式为聊天消息格式 - 完整匹配后端字段
|
||||
*/
|
||||
static convertToChatMessage(msg: MessageResponse): ChatMessage {
|
||||
console.log('🔄 转换消息格式:', msg)
|
||||
|
||||
// 处理时间格式 - 后端返回 "2025-07-26 22:09:10" 格式
|
||||
// 直接保持原始字符串格式,让前端UI层处理
|
||||
let timestamp = msg.createTime
|
||||
console.log('🕐 保持原始时间格式:', msg.createTime)
|
||||
|
||||
const chatMessage: ChatMessage = {
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
type: msg.sender === 'user' ? 'user' : (msg.sender === 'ai' ? 'ai' : 'system'),
|
||||
timestamp: timestamp,
|
||||
conversationId: msg.conversationId,
|
||||
sessionId: msg.conversationId, // 别名,保持兼容性
|
||||
status: 'sent',
|
||||
sender: msg.sender as 'user' | 'ai' | 'system',
|
||||
isRead: msg.isRead,
|
||||
role: msg.sender === 'user' ? 'user' : 'assistant' // 用于UI显示
|
||||
}
|
||||
|
||||
console.log('✅ 转换后的消息详情:', {
|
||||
原始sender: msg.sender,
|
||||
转换后role: chatMessage.role,
|
||||
转换后type: chatMessage.type,
|
||||
时间: msg.createTime + ' -> ' + timestamp
|
||||
})
|
||||
console.log('✅ 转换后的消息:', chatMessage)
|
||||
return chatMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量转换消息格式
|
||||
*/
|
||||
static convertToChatMessages(messages: MessageResponse[]): ChatMessage[] {
|
||||
console.log('🔄 批量转换消息格式,数量:', messages.length)
|
||||
const chatMessages = messages.map(msg => this.convertToChatMessage(msg))
|
||||
console.log('✅ 批量转换完成,结果:', chatMessages)
|
||||
return chatMessages
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageService
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Client, IMessage } from '@stomp/stompjs'
|
||||
import SockJS from 'sockjs-client'
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
// WebSocket消息类型
|
||||
export interface WebSocketMessage {
|
||||
messageId?: string
|
||||
conversationId?: string
|
||||
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING'
|
||||
content: string
|
||||
senderId: string
|
||||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||||
status?: 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
|
||||
createTime?: string
|
||||
timestamp?: number
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 聊天请求类型 - 完全匹配后端ChatRequest
|
||||
export interface ChatRequest {
|
||||
content: string
|
||||
senderId: string
|
||||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||||
messageType: 'TEXT' | 'IMAGE' | 'FILE' | 'SYSTEM' | 'HEARTBEAT'
|
||||
conversationId?: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
// 连接状态
|
||||
export type ConnectionStatus = 'CONNECTING' | 'CONNECTED' | 'DISCONNECTED' | 'ERROR'
|
||||
|
||||
// 事件回调类型
|
||||
export interface WebSocketCallbacks {
|
||||
onMessage?: (message: WebSocketMessage) => void
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onError?: (error: any) => void
|
||||
onStatusChange?: (status: ConnectionStatus) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* STOMP WebSocket服务类
|
||||
* 使用STOMP协议与后端Spring WebSocket通信
|
||||
*/
|
||||
export class StompWebSocketService {
|
||||
private client: Client | null = null
|
||||
private callbacks: WebSocketCallbacks = {}
|
||||
private status: ConnectionStatus = 'DISCONNECTED'
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectInterval = 3000
|
||||
private userId: string | null = null
|
||||
private conversationId: string | null = null
|
||||
|
||||
constructor() {
|
||||
// 构建WebSocket URL
|
||||
const wsUrl = `${envConfig.apiBaseUrl}/ws/chat`
|
||||
console.log('STOMP WebSocket URL:', wsUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接WebSocket
|
||||
*/
|
||||
connect(userId?: string, callbacks?: WebSocketCallbacks): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.callbacks = { ...callbacks }
|
||||
|
||||
// 设置用户ID和类型
|
||||
if (userId) {
|
||||
this.userId = userId
|
||||
console.log('🔌 使用登录用户ID:', userId)
|
||||
} else {
|
||||
this.userId = `guest_${Date.now()}`
|
||||
console.log('🔌 使用访客ID:', this.userId)
|
||||
}
|
||||
|
||||
this.setStatus('CONNECTING')
|
||||
|
||||
// 创建STOMP客户端
|
||||
this.client = new Client({
|
||||
webSocketFactory: () => {
|
||||
const wsUrl = `${envConfig.apiBaseUrl}/ws/chat`
|
||||
return new SockJS(wsUrl)
|
||||
},
|
||||
|
||||
// 连接头信息
|
||||
connectHeaders: this.getConnectHeaders(),
|
||||
|
||||
// 调试信息
|
||||
debug: (str) => {
|
||||
console.log('STOMP Debug:', str)
|
||||
},
|
||||
|
||||
// 重连配置
|
||||
reconnectDelay: this.reconnectInterval,
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
})
|
||||
|
||||
// 连接成功回调
|
||||
this.client.onConnect = (frame) => {
|
||||
console.log('✅ STOMP WebSocket连接成功:', frame)
|
||||
this.setStatus('CONNECTED')
|
||||
this.reconnectAttempts = 0
|
||||
|
||||
// 订阅消息
|
||||
this.subscribeToMessages()
|
||||
|
||||
// 发送连接消息
|
||||
this.sendConnectMessage()
|
||||
|
||||
this.callbacks.onConnect?.()
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 连接错误回调
|
||||
this.client.onStompError = (frame) => {
|
||||
console.error('❌ STOMP连接错误:', frame)
|
||||
this.setStatus('ERROR')
|
||||
this.callbacks.onError?.(frame)
|
||||
reject(new Error(`STOMP连接错误: ${frame.headers['message']}`))
|
||||
}
|
||||
|
||||
// WebSocket错误回调
|
||||
this.client.onWebSocketError = (error) => {
|
||||
console.error('❌ WebSocket错误:', error)
|
||||
this.setStatus('ERROR')
|
||||
this.callbacks.onError?.(error)
|
||||
}
|
||||
|
||||
// 连接关闭回调
|
||||
this.client.onWebSocketClose = (event) => {
|
||||
console.log('🔌 WebSocket连接关闭:', event)
|
||||
this.setStatus('DISCONNECTED')
|
||||
this.callbacks.onDisconnect?.()
|
||||
|
||||
// 自动重连
|
||||
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 激活连接
|
||||
this.client.activate()
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ STOMP连接异常:', error)
|
||||
this.setStatus('ERROR')
|
||||
this.callbacks.onError?.(error)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.client) {
|
||||
this.client.deactivate()
|
||||
this.client = null
|
||||
}
|
||||
this.userId = null
|
||||
this.conversationId = null
|
||||
this.setStatus('DISCONNECTED')
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
*/
|
||||
sendChatMessage(content: string, conversationId?: string): void {
|
||||
if (!this.client || !this.client.connected) {
|
||||
const error = new Error('STOMP客户端未连接,无法发送消息')
|
||||
console.error('STOMP未连接')
|
||||
this.callbacks.onError?.({ userMessage: '连接已断开,请等待重连后再试', originalError: error })
|
||||
return
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
const error = new Error('消息内容不能为空')
|
||||
this.callbacks.onError?.({ userMessage: '消息内容不能为空', originalError: error })
|
||||
return
|
||||
}
|
||||
|
||||
// 判断用户类型
|
||||
const isGuest = !this.userId || this.userId.startsWith('guest_')
|
||||
const senderType = isGuest ? 'GUEST' : 'USER'
|
||||
|
||||
console.log('📤 发送STOMP聊天消息,用户信息:', {
|
||||
userId: this.userId,
|
||||
senderType,
|
||||
isGuest,
|
||||
content: content.trim()
|
||||
})
|
||||
|
||||
// 创建聊天请求 - 匹配后端ChatRequest格式
|
||||
const chatRequest: ChatRequest = {
|
||||
content: content.trim(),
|
||||
senderId: this.userId!,
|
||||
senderType,
|
||||
messageType: 'TEXT',
|
||||
conversationId: conversationId || this.conversationId || undefined,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
console.log('📤 准备发送的聊天请求:', chatRequest)
|
||||
|
||||
try {
|
||||
// 发送到后端的/app/chat.send端点
|
||||
this.client.publish({
|
||||
destination: '/app/chat.send',
|
||||
body: JSON.stringify(chatRequest)
|
||||
})
|
||||
console.log('✅ STOMP聊天消息发送成功:', chatRequest)
|
||||
} catch (error) {
|
||||
console.error('❌ STOMP消息发送失败:', error)
|
||||
this.callbacks.onError?.({
|
||||
userMessage: '消息发送失败,请重试',
|
||||
originalError: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话ID
|
||||
*/
|
||||
setConversationId(conversationId: string): void {
|
||||
this.conversationId = conversationId
|
||||
console.log('设置会话ID:', conversationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已连接
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.status === 'CONNECTED' && this.client?.connected === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接头信息
|
||||
*/
|
||||
private getConnectHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
// 添加用户ID
|
||||
if (this.userId) {
|
||||
headers['X-User-Id'] = this.userId
|
||||
}
|
||||
|
||||
// 添加JWT token - 修复:使用正确的localStorage key
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
console.log('🔐 添加Authorization头到STOMP连接,token预览:', token.substring(0, 20) + '...')
|
||||
} else {
|
||||
console.warn('🔐 未找到access_token,WebSocket将以访客身份连接')
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息
|
||||
*/
|
||||
private subscribeToMessages(): void {
|
||||
if (!this.client?.connected) return
|
||||
|
||||
// 订阅用户私有消息
|
||||
if (this.userId) {
|
||||
const userQueuePath = `/user/${this.userId}/queue/messages`
|
||||
console.log('📨 订阅用户私有队列:', userQueuePath)
|
||||
|
||||
this.client.subscribe(userQueuePath, (message: IMessage) => {
|
||||
this.handleMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
// 订阅广播消息
|
||||
this.client.subscribe('/topic/broadcast', (message: IMessage) => {
|
||||
this.handleMessage(message)
|
||||
})
|
||||
|
||||
// 如果有会话ID,订阅会话特定消息
|
||||
if (this.conversationId) {
|
||||
this.client.subscribe(`/topic/conversation/${this.conversationId}`, (message: IMessage) => {
|
||||
this.handleMessage(message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理收到的消息
|
||||
*/
|
||||
private handleMessage(message: IMessage): void {
|
||||
try {
|
||||
const wsMessage: WebSocketMessage = JSON.parse(message.body)
|
||||
console.log('📨 收到STOMP消息:', wsMessage)
|
||||
this.callbacks.onMessage?.(wsMessage)
|
||||
} catch (error) {
|
||||
console.error('❌ 解析STOMP消息失败:', error, message.body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送连接消息
|
||||
*/
|
||||
private sendConnectMessage(): void {
|
||||
if (!this.client?.connected) return
|
||||
|
||||
const connectRequest = {
|
||||
userId: this.userId,
|
||||
clientType: 'web',
|
||||
clientVersion: '1.0.0',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
try {
|
||||
this.client.publish({
|
||||
destination: '/app/chat.connect',
|
||||
body: JSON.stringify(connectRequest)
|
||||
})
|
||||
console.log('✅ STOMP连接消息发送成功:', connectRequest)
|
||||
} catch (error) {
|
||||
console.error('❌ STOMP连接消息发送失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置连接状态
|
||||
*/
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
this.status = status
|
||||
this.callbacks.onStatusChange?.(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排重连
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('达到最大重连次数,停止重连')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
console.log(`${this.reconnectInterval}ms后尝试第${this.reconnectAttempts}次重连`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.status !== 'CONNECTED') {
|
||||
this.connect(this.userId!, this.callbacks).catch(() => {
|
||||
// 重连失败会自动安排下次重连
|
||||
})
|
||||
}
|
||||
}, this.reconnectInterval)
|
||||
|
||||
// 递增重连间隔
|
||||
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, 30000)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建STOMP WebSocket服务实例
|
||||
export const stompWebSocketService = new StompWebSocketService()
|
||||
|
||||
export default stompWebSocketService
|
||||
@@ -0,0 +1,130 @@
|
||||
import { http } from '@/utils/request'
|
||||
|
||||
// 用户相关类型定义
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
account: string
|
||||
username: string
|
||||
nickname?: string // 可选,如果为空则使用username
|
||||
email?: string // 可选
|
||||
phone?: string // 可选
|
||||
avatar?: string // 可选,头像URL
|
||||
birthDate?: string // 可选,生日
|
||||
location?: string // 可选,所在地
|
||||
bio?: string // 可选,个人简介
|
||||
memberLevel?: string // 可选,会员等级
|
||||
totalDays?: number // 可选,使用天数
|
||||
selfAwareness?: number // 可选,自我感知
|
||||
emotionalResilience?: number // 可选,情绪韧性
|
||||
actionPower?: number // 可选,行动力
|
||||
empathy?: number // 可选,共情力
|
||||
lifeEnthusiasm?: number // 可选,生活热度
|
||||
status: number
|
||||
isVerified?: number // 可选,是否已验证
|
||||
createTime: string
|
||||
updateTime: string
|
||||
lastActiveTime?: string // 可选,最后活跃时间
|
||||
}
|
||||
|
||||
export interface UserProfileUpdateRequest {
|
||||
nickname?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
avatar?: string
|
||||
birthDate?: string
|
||||
location?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
export interface GrowthStats {
|
||||
selfAwareness: number
|
||||
emotionalResilience: number
|
||||
actionPower: number
|
||||
empathy: number
|
||||
lifeEnthusiasm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户服务
|
||||
*/
|
||||
class UserService {
|
||||
/**
|
||||
* 获取当前用户个人资料
|
||||
*/
|
||||
static async getCurrentUserProfile(): Promise<UserProfile> {
|
||||
const response = await http.get<UserProfile>('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前用户个人资料
|
||||
*/
|
||||
static async updateCurrentUserProfile(data: UserProfileUpdateRequest): Promise<UserProfile> {
|
||||
const response = await http.put<UserProfile>('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
*/
|
||||
static async uploadAvatar(file: File): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await http.post<{ url: string }>('/user/avatar/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
return response.data.url
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户成长数据
|
||||
*/
|
||||
static async getUserGrowthStats(): Promise<GrowthStats> {
|
||||
const response = await http.get<GrowthStats>('/user/growth-stats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户密码
|
||||
*/
|
||||
static async updatePassword(data: {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}): Promise<void> {
|
||||
await http.put('/user/password', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
static async verifyEmail(code: string): Promise<void> {
|
||||
await http.post('/user/email/verify', { code })
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*/
|
||||
static async sendEmailVerificationCode(): Promise<void> {
|
||||
await http.post('/user/email/send-code')
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号
|
||||
*/
|
||||
static async verifyPhone(code: string): Promise<void> {
|
||||
await http.post('/user/phone/verify', { code })
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送手机验证码
|
||||
*/
|
||||
static async sendPhoneVerificationCode(): Promise<void> {
|
||||
await http.post('/user/phone/send-code')
|
||||
}
|
||||
}
|
||||
|
||||
export default UserService
|
||||
@@ -1,423 +0,0 @@
|
||||
import SockJS from 'sockjs-client'
|
||||
import * as Stomp from 'stompjs'
|
||||
// import type { ChatMessage } from '@/types' // 暂时注释,未使用
|
||||
|
||||
// WebSocket消息类型 - 与后端保持一致
|
||||
export interface WebSocketMessage {
|
||||
messageId: string
|
||||
conversationId?: string
|
||||
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING'
|
||||
content: string
|
||||
senderId: string
|
||||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||||
status: 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
|
||||
createTime: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 聊天请求类型 - 与后端ChatRequest保持一致
|
||||
export interface ChatRequest {
|
||||
content: string
|
||||
senderId: string
|
||||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||||
messageType: 'TEXT' | 'IMAGE' | 'FILE' | 'SYSTEM' | 'HEARTBEAT'
|
||||
conversationId?: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
// 连接请求类型
|
||||
export interface ConnectRequest {
|
||||
userId?: string
|
||||
username?: string
|
||||
clientType?: string
|
||||
clientVersion?: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
// WebSocket连接状态
|
||||
export type ConnectionStatus = 'CONNECTING' | 'CONNECTED' | 'DISCONNECTED' | 'ERROR'
|
||||
|
||||
// 事件回调类型
|
||||
export interface WebSocketCallbacks {
|
||||
onMessage?: (message: WebSocketMessage) => void
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onError?: (error: any) => void
|
||||
onStatusChange?: (status: ConnectionStatus) => void
|
||||
}
|
||||
|
||||
export class WebSocketService {
|
||||
private client: Stomp.Client | null = null
|
||||
private callbacks: WebSocketCallbacks = {}
|
||||
private status: ConnectionStatus = 'DISCONNECTED'
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectInterval = 3000
|
||||
private heartbeatTimer: number | null = null
|
||||
private userId: string | null = null
|
||||
private conversationId: string | null = null
|
||||
|
||||
constructor(private wsUrl: string) {}
|
||||
|
||||
/**
|
||||
* 连接WebSocket
|
||||
*/
|
||||
connect(userId?: string, callbacks?: WebSocketCallbacks): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.callbacks = { ...callbacks }
|
||||
this.userId = userId || `guest_${Date.now()}`
|
||||
this.setStatus('CONNECTING')
|
||||
|
||||
// 创建SockJS连接
|
||||
const socket = new SockJS(this.wsUrl, null, {
|
||||
transports: ['websocket', 'xhr-streaming', 'xhr-polling']
|
||||
})
|
||||
this.client = Stomp.over(socket)
|
||||
|
||||
// 禁用调试日志
|
||||
this.client.debug = () => {}
|
||||
|
||||
// 设置心跳
|
||||
this.client.heartbeat.outgoing = 20000
|
||||
this.client.heartbeat.incoming = 20000
|
||||
|
||||
// 连接配置 - 添加token支持
|
||||
const connectHeaders: any = {
|
||||
'X-User-Id': this.userId
|
||||
}
|
||||
|
||||
// 如果有token,添加到连接头中
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
connectHeaders['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
console.log('WebSocket连接配置:', {
|
||||
userId: this.userId,
|
||||
hasToken: !!token,
|
||||
headers: connectHeaders
|
||||
})
|
||||
|
||||
this.client.connect(
|
||||
connectHeaders,
|
||||
(frame) => {
|
||||
console.log('WebSocket连接成功:', frame)
|
||||
this.setStatus('CONNECTED')
|
||||
this.reconnectAttempts = 0
|
||||
|
||||
// 订阅用户消息
|
||||
this.subscribeToMessages()
|
||||
|
||||
// 发送连接消息
|
||||
this.sendConnectMessage()
|
||||
|
||||
// 启动心跳
|
||||
this.startHeartbeat()
|
||||
|
||||
this.callbacks.onConnect?.()
|
||||
resolve()
|
||||
},
|
||||
(error) => {
|
||||
console.error('WebSocket连接失败:', error)
|
||||
this.setStatus('ERROR')
|
||||
|
||||
// 详细的错误处理
|
||||
let errorMessage = '连接失败'
|
||||
if (error && typeof error === 'object') {
|
||||
const errorObj = error as any
|
||||
if (errorObj.type === 'close') {
|
||||
switch (errorObj.code) {
|
||||
case 1006:
|
||||
errorMessage = '连接异常断开,正在重连...'
|
||||
break
|
||||
case 1000:
|
||||
errorMessage = '连接正常关闭'
|
||||
break
|
||||
case 1001:
|
||||
errorMessage = '服务器正在重启,请稍后重试'
|
||||
break
|
||||
case 1002:
|
||||
errorMessage = '协议错误'
|
||||
break
|
||||
case 1003:
|
||||
errorMessage = '数据格式错误'
|
||||
break
|
||||
default:
|
||||
errorMessage = `连接关闭 (代码: ${errorObj.code})`
|
||||
}
|
||||
} else if (errorObj.message) {
|
||||
errorMessage = errorObj.message
|
||||
}
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error
|
||||
}
|
||||
|
||||
this.callbacks.onError?.({ error, userMessage: errorMessage })
|
||||
|
||||
// 尝试重连
|
||||
this.scheduleReconnect()
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('WebSocket初始化失败:', error)
|
||||
this.setStatus('ERROR')
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.client?.connected) {
|
||||
this.sendDisconnectMessage()
|
||||
this.client.disconnect(() => {
|
||||
console.log('WebSocket已断开连接')
|
||||
})
|
||||
}
|
||||
|
||||
this.stopHeartbeat()
|
||||
this.setStatus('DISCONNECTED')
|
||||
this.callbacks.onDisconnect?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
*/
|
||||
sendChatMessage(content: string, conversationId?: string): void {
|
||||
if (!this.client?.connected) {
|
||||
const error = new Error('WebSocket连接已断开,无法发送消息')
|
||||
console.error('WebSocket未连接')
|
||||
this.callbacks.onError?.({ userMessage: '连接已断开,请等待重连后再试', originalError: error })
|
||||
return
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
const error = new Error('消息内容不能为空')
|
||||
this.callbacks.onError?.({ userMessage: '消息内容不能为空', originalError: error })
|
||||
return
|
||||
}
|
||||
|
||||
// 使用新的后端接口格式
|
||||
const chatRequest: ChatRequest = {
|
||||
content: content.trim(),
|
||||
senderId: this.userId!,
|
||||
senderType: this.userId?.startsWith('guest_') ? 'GUEST' : 'USER',
|
||||
messageType: 'TEXT',
|
||||
conversationId: conversationId || this.conversationId || undefined,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
try {
|
||||
this.client.send('/app/chat.send', {}, JSON.stringify(chatRequest))
|
||||
console.log('发送聊天消息:', {
|
||||
...chatRequest,
|
||||
currentUserId: this.userId,
|
||||
expectedSubscriptionPath: '/user/queue/messages'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
this.callbacks.onError?.({
|
||||
userMessage: '消息发送失败,请重试',
|
||||
originalError: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话ID
|
||||
*/
|
||||
setConversationId(conversationId: string): void {
|
||||
this.conversationId = conversationId
|
||||
console.log('WebSocket会话ID已更新:', conversationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话ID
|
||||
*/
|
||||
getConversationId(): string | null {
|
||||
return this.conversationId
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已连接
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.status === 'CONNECTED' && this.client?.connected === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息
|
||||
*/
|
||||
private subscribeToMessages(): void {
|
||||
if (!this.client?.connected) return
|
||||
|
||||
// 订阅用户私有消息 - 包含用户ID的完整路径
|
||||
if (this.userId) {
|
||||
const userQueuePath = `/user/${this.userId}/queue/messages`
|
||||
console.log('订阅用户私有队列:', userQueuePath)
|
||||
this.client.subscribe(userQueuePath, (message) => {
|
||||
try {
|
||||
const wsMessage: WebSocketMessage = JSON.parse(message.body)
|
||||
console.log('收到用户私有WebSocket消息:', wsMessage)
|
||||
this.callbacks.onMessage?.(wsMessage)
|
||||
} catch (error) {
|
||||
console.error('解析用户私有WebSocket消息失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 同时订阅基于sessionId的队列(从日志看后端也在使用这个)
|
||||
if (this.client.ws && this.client.ws.url) {
|
||||
// 从WebSocket URL中提取sessionId
|
||||
const urlParts = this.client.ws.url.split('/')
|
||||
const sessionId = urlParts[urlParts.length - 2] // 倒数第二个部分是sessionId
|
||||
if (sessionId) {
|
||||
console.log('订阅基于sessionId的队列:', `/queue/messages-user${sessionId}`)
|
||||
this.client.subscribe(`/queue/messages-user${sessionId}`, (message) => {
|
||||
try {
|
||||
const wsMessage: WebSocketMessage = JSON.parse(message.body)
|
||||
console.log('收到基于sessionId的WebSocket消息:', wsMessage)
|
||||
this.callbacks.onMessage?.(wsMessage)
|
||||
} catch (error) {
|
||||
console.error('解析基于sessionId的WebSocket消息失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅广播消息
|
||||
this.client.subscribe('/topic/broadcast', (message) => {
|
||||
try {
|
||||
const wsMessage: WebSocketMessage = JSON.parse(message.body)
|
||||
console.log('收到广播消息:', wsMessage)
|
||||
this.callbacks.onMessage?.(wsMessage)
|
||||
} catch (error) {
|
||||
console.error('解析广播消息失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// 如果有会话ID,也订阅会话特定的消息
|
||||
if (this.conversationId) {
|
||||
this.client.subscribe(`/topic/conversation/${this.conversationId}`, (message) => {
|
||||
try {
|
||||
const wsMessage: WebSocketMessage = JSON.parse(message.body)
|
||||
console.log('收到会话WebSocket消息:', wsMessage)
|
||||
this.callbacks.onMessage?.(wsMessage)
|
||||
} catch (error) {
|
||||
console.error('解析会话WebSocket消息失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送连接消息
|
||||
*/
|
||||
private sendConnectMessage(): void {
|
||||
if (!this.client?.connected) return
|
||||
|
||||
const connectRequest: ConnectRequest = {
|
||||
userId: this.userId!,
|
||||
username: this.userId!,
|
||||
clientType: 'web',
|
||||
clientVersion: '1.0.0',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
try {
|
||||
this.client.send('/app/chat.connect', {}, JSON.stringify(connectRequest))
|
||||
console.log('发送连接消息:', connectRequest)
|
||||
} catch (error) {
|
||||
console.error('发送连接消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送断开连接消息
|
||||
*/
|
||||
private sendDisconnectMessage(): void {
|
||||
if (!this.client?.connected) return
|
||||
|
||||
try {
|
||||
this.client.send('/app/chat.disconnect', {}, JSON.stringify({}))
|
||||
} catch (error) {
|
||||
console.error('发送断开连接消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (this.client?.connected) {
|
||||
try {
|
||||
this.client.send('/app/chat.heartbeat', {}, JSON.stringify({}))
|
||||
} catch (error) {
|
||||
console.error('心跳发送失败:', error)
|
||||
}
|
||||
}
|
||||
}, 30000) // 30秒心跳间隔
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置连接状态
|
||||
*/
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
this.status = status
|
||||
this.callbacks.onStatusChange?.(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排重连
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('达到最大重连次数,停止重连')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
console.log(`${this.reconnectInterval}ms后尝试第${this.reconnectAttempts}次重连`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.status !== 'CONNECTED') {
|
||||
this.connect(this.userId!, this.callbacks).catch(() => {
|
||||
// 重连失败会自动安排下次重连
|
||||
})
|
||||
}
|
||||
}, this.reconnectInterval)
|
||||
|
||||
// 递增重连间隔
|
||||
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, 30000)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建WebSocket服务实例
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || 'http://localhost:19089/ws/chat'
|
||||
export const webSocketService = new WebSocketService(wsUrl)
|
||||
|
||||
export default webSocketService
|
||||
@@ -1,65 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { ThemeConfig } from '@/types'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// 应用状态
|
||||
const loading = ref(false)
|
||||
const mobileMenuVisible = ref(false)
|
||||
const theme = ref<ThemeConfig>({
|
||||
primaryColor: '#4A90E2',
|
||||
secondaryColor: '#F5A623',
|
||||
backgroundColor: '#F7F8FA',
|
||||
textColor: '#333333',
|
||||
borderRadius: '8px'
|
||||
})
|
||||
|
||||
// 设备信息
|
||||
const isMobile = ref(false)
|
||||
const screenWidth = ref(window.innerWidth)
|
||||
|
||||
// 方法
|
||||
const setLoading = (value: boolean) => {
|
||||
loading.value = value
|
||||
}
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
mobileMenuVisible.value = !mobileMenuVisible.value
|
||||
}
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuVisible.value = false
|
||||
}
|
||||
|
||||
const updateScreenWidth = () => {
|
||||
screenWidth.value = window.innerWidth
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
const setTheme = (newTheme: Partial<ThemeConfig>) => {
|
||||
theme.value = { ...theme.value, ...newTheme }
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const init = () => {
|
||||
updateScreenWidth()
|
||||
window.addEventListener('resize', updateScreenWidth)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
mobileMenuVisible,
|
||||
theme,
|
||||
isMobile,
|
||||
screenWidth,
|
||||
|
||||
// 方法
|
||||
setLoading,
|
||||
toggleMobileMenu,
|
||||
closeMobileMenu,
|
||||
updateScreenWidth,
|
||||
setTheme,
|
||||
init
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 认证状态管理
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AuthService from '@/services/auth'
|
||||
import { handleApiError } from '@/utils/errorHandler'
|
||||
import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
AuthResponse,
|
||||
UserInfo
|
||||
} from '@/types/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 状态
|
||||
const accessToken = ref<string>('')
|
||||
const refreshToken = ref<string>('')
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
// 移除权限状态,该功能不存在
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => !!accessToken.value && !!userInfo.value)
|
||||
const userId = computed(() => userInfo.value?.id || '')
|
||||
const username = computed(() => userInfo.value?.username || '')
|
||||
const nickname = computed(() => userInfo.value?.nickname || '')
|
||||
const avatar = computed(() => userInfo.value?.avatar || '')
|
||||
const email = computed(() => userInfo.value?.email || '')
|
||||
const phone = computed(() => userInfo.value?.phone || '')
|
||||
|
||||
// 移除权限检查方法,该功能不存在
|
||||
|
||||
/**
|
||||
* 初始化认证状态
|
||||
*/
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
console.log('🔄 初始化认证状态...')
|
||||
|
||||
// 从本地存储恢复token
|
||||
const storedAccessToken = localStorage.getItem('access_token')
|
||||
const storedRefreshToken = localStorage.getItem('refresh_token')
|
||||
const storedUserInfo = localStorage.getItem('user_info')
|
||||
|
||||
console.log('🔄 本地存储状态:', {
|
||||
hasToken: !!storedAccessToken,
|
||||
hasRefreshToken: !!storedRefreshToken,
|
||||
hasUserInfo: !!storedUserInfo
|
||||
})
|
||||
|
||||
if (storedAccessToken && storedUserInfo) {
|
||||
// 恢复认证状态
|
||||
accessToken.value = storedAccessToken
|
||||
refreshToken.value = storedRefreshToken || ''
|
||||
userInfo.value = JSON.parse(storedUserInfo)
|
||||
|
||||
console.log('🔄 认证状态已恢复')
|
||||
|
||||
// 简单验证:尝试获取用户信息来验证token是否有效
|
||||
try {
|
||||
await getCurrentUserInfo()
|
||||
console.log('🔄 Token验证成功')
|
||||
} catch (error) {
|
||||
console.warn('🔄 Token可能已过期,但不强制登出:', error)
|
||||
// 不强制登出,让用户在下次API调用时处理
|
||||
}
|
||||
} else {
|
||||
console.log('🔄 无有效的本地认证信息')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔄 初始化认证状态失败:', error)
|
||||
// 不自动登出,避免清除用户刚登录的状态
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
const login = async (loginData: LoginRequest): Promise<boolean> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
console.log('🔐 开始登录流程...')
|
||||
const response = await AuthService.login(loginData)
|
||||
console.log('🔐 登录响应数据:', response)
|
||||
|
||||
// 保存认证信息
|
||||
setAuthData(response)
|
||||
console.log('🔐 认证信息已保存')
|
||||
|
||||
// 验证token是否正确保存
|
||||
const savedToken = localStorage.getItem('access_token')
|
||||
console.log('🔐 保存的token:', savedToken ? '已保存' : '未保存')
|
||||
|
||||
// 获取最新的用户信息
|
||||
await getCurrentUserInfo()
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('🔐 登录失败:', error)
|
||||
handleApiError(error, '用户登录')
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
const register = async (registerData: RegisterRequest): Promise<boolean> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await AuthService.register(registerData)
|
||||
|
||||
// 保存认证信息
|
||||
setAuthData(response)
|
||||
|
||||
// 移除权限获取,该接口不存在
|
||||
|
||||
ElMessage.success('注册成功')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
handleApiError(error, '用户注册')
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
// 调用登出接口
|
||||
if (accessToken.value) {
|
||||
await AuthService.logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登出接口调用失败:', error)
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
clearAuthData()
|
||||
ElMessage.success('已退出登录')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
*/
|
||||
const refreshAccessToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (!refreshToken.value) {
|
||||
throw new Error('没有刷新令牌')
|
||||
}
|
||||
|
||||
const response = await AuthService.refreshToken({
|
||||
refreshToken: refreshToken.value
|
||||
})
|
||||
|
||||
// 更新认证信息
|
||||
setAuthData(response)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('刷新Token失败:', error)
|
||||
// 刷新失败,清除认证状态
|
||||
await logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
const getCurrentUserInfo = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await AuthService.getCurrentUserInfo()
|
||||
// 后端直接返回用户信息,不是嵌套在userInfo字段中
|
||||
userInfo.value = response
|
||||
|
||||
// 更新本地存储
|
||||
localStorage.setItem('user_info', JSON.stringify(response))
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默恢复本地认证状态(不进行API调用)
|
||||
*/
|
||||
const restoreLocalAuth = () => {
|
||||
try {
|
||||
const storedAccessToken = localStorage.getItem('access_token')
|
||||
const storedRefreshToken = localStorage.getItem('refresh_token')
|
||||
const storedUserInfo = localStorage.getItem('user_info')
|
||||
|
||||
if (storedAccessToken && storedUserInfo) {
|
||||
accessToken.value = storedAccessToken
|
||||
refreshToken.value = storedRefreshToken || ''
|
||||
userInfo.value = JSON.parse(storedUserInfo)
|
||||
console.log('🔄 本地认证状态已恢复')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('🔄 恢复本地认证状态失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置认证数据
|
||||
*/
|
||||
const setAuthData = (authData: AuthResponse): void => {
|
||||
accessToken.value = authData.accessToken
|
||||
refreshToken.value = authData.refreshToken
|
||||
userInfo.value = authData.userInfo
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('access_token', authData.accessToken)
|
||||
localStorage.setItem('refresh_token', authData.refreshToken)
|
||||
localStorage.setItem('user_info', JSON.stringify(authData.userInfo))
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除认证数据
|
||||
*/
|
||||
const clearAuthData = (): void => {
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_info')
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
const updateUserInfo = (newUserInfo: Partial<UserInfo>): void => {
|
||||
if (userInfo.value) {
|
||||
userInfo.value = { ...userInfo.value, ...newUserInfo }
|
||||
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
isLoading,
|
||||
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
userId,
|
||||
username,
|
||||
nickname,
|
||||
avatar,
|
||||
email,
|
||||
phone,
|
||||
|
||||
// 方法
|
||||
initAuth,
|
||||
restoreLocalAuth,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
getCurrentUserInfo,
|
||||
setAuthData,
|
||||
clearAuthData,
|
||||
updateUserInfo
|
||||
}
|
||||
})
|
||||
+316
-37
@@ -1,12 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ChatMessage, ChatSession } from '@/types'
|
||||
import webSocketService, { type WebSocketMessage, type ConnectionStatus } from '@/services/websocket'
|
||||
import { useUserStore } from './user'
|
||||
import { stompWebSocketService, type WebSocketMessage, type ConnectionStatus } from '@/services/stomp-websocket'
|
||||
import { useAuthStore } from './auth'
|
||||
import { chatApi } from '@/services/chat'
|
||||
import MessageService, { messageApi } from '@/services/message'
|
||||
|
||||
// 聊天消息类型
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string
|
||||
type: 'user' | 'ai'
|
||||
timestamp: string
|
||||
conversationId?: string
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 聊天会话类型
|
||||
export interface ChatSession {
|
||||
id: string
|
||||
title: string
|
||||
userId?: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const userStore = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 聊天状态
|
||||
const currentSession = ref<ChatSession | null>(null)
|
||||
@@ -17,7 +39,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const connectionStatus = ref<ConnectionStatus>('DISCONNECTED')
|
||||
const wsConnected = ref(false)
|
||||
|
||||
// 方法
|
||||
// 计算属性
|
||||
const currentMessages = computed(() => {
|
||||
if (!currentSession.value) return []
|
||||
return messages.value.filter(msg =>
|
||||
msg.conversationId === currentSession.value?.id ||
|
||||
msg.sessionId === currentSession.value?.id
|
||||
)
|
||||
})
|
||||
|
||||
// 添加消息
|
||||
const addMessage = (message: Omit<ChatMessage, 'id' | 'timestamp'>) => {
|
||||
const newMessage: ChatMessage = {
|
||||
...message,
|
||||
@@ -60,8 +91,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
// 仅通过WebSocket推送,后端会统一处理消息保存
|
||||
webSocketService.sendChatMessage(content, currentSession.value?.id)
|
||||
// 仅通过STOMP WebSocket推送,后端会统一处理消息保存
|
||||
stompWebSocketService.sendChatMessage(content, currentSession.value?.id)
|
||||
|
||||
// 更新消息状态为已发送
|
||||
updateMessageStatus(userMessage.id, 'sent')
|
||||
@@ -87,8 +118,13 @@ export const useChatStore = defineStore('chat', () => {
|
||||
// 创建会话:同步后端
|
||||
const createSession = async (title?: string) => {
|
||||
let newSession: ChatSession
|
||||
if (userStore.user?.id) {
|
||||
newSession = await chatApi.createSession(userStore.user.id, title || `对话${sessions.value.length + 1}`)
|
||||
const currentUserId = authStore.userInfo?.id || authStore.userId
|
||||
|
||||
console.log('📝 创建会话,当前用户ID:', currentUserId)
|
||||
|
||||
if (currentUserId) {
|
||||
newSession = await chatApi.createSession(currentUserId, title || `对话${sessions.value.length + 1}`)
|
||||
console.log('✅ 已为登录用户创建会话:', newSession)
|
||||
} else {
|
||||
newSession = {
|
||||
id: Date.now().toString(),
|
||||
@@ -97,6 +133,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
updateTime: new Date().toISOString(),
|
||||
messageCount: 0
|
||||
}
|
||||
console.log('⚠️ 为访客创建本地会话:', newSession)
|
||||
}
|
||||
sessions.value.unshift(newSession)
|
||||
currentSession.value = newSession
|
||||
@@ -104,7 +141,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
// 如果WebSocket已连接,设置新的会话ID
|
||||
if (wsConnected.value) {
|
||||
webSocketService.setConversationId(newSession.id)
|
||||
stompWebSocketService.setConversationId(newSession.id)
|
||||
}
|
||||
|
||||
return newSession
|
||||
@@ -119,18 +156,38 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
// 如果WebSocket已连接,更新会话ID
|
||||
if (wsConnected.value) {
|
||||
webSocketService.setConversationId(sessionId)
|
||||
stompWebSocketService.setConversationId(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会话消息:从后端获取
|
||||
// 加载会话消息:使用现有的消息API
|
||||
const loadSessionMessages = async (sessionId: string) => {
|
||||
console.log('📨 开始加载会话消息:', sessionId)
|
||||
console.log('💡 注意:后端没有按会话ID获取消息的接口,使用最近消息代替')
|
||||
|
||||
try {
|
||||
const msgs = await chatApi.getAllSessionMessages(sessionId)
|
||||
messages.value = msgs
|
||||
// 由于后端没有按会话ID获取消息的接口,我们使用最近消息
|
||||
// 这是一个临时方案,理想情况下应该在后端添加相应接口
|
||||
console.log('📨 使用最近消息API代替会话消息...')
|
||||
const response = await messageApi.getRecentMessages(50)
|
||||
console.log('📨 最近消息API响应:', response)
|
||||
|
||||
// 处理API响应数据结构
|
||||
const messageList = response.data || response || []
|
||||
console.log('📨 提取的消息列表:', messageList)
|
||||
|
||||
const chatMessages = MessageService.convertToChatMessages(messageList)
|
||||
console.log('📨 转换后的聊天消息:', chatMessages)
|
||||
|
||||
// 如果需要过滤特定会话的消息,可以在这里添加过滤逻辑
|
||||
// const sessionMessages = chatMessages.filter(msg => msg.sessionId === sessionId)
|
||||
|
||||
messages.value = chatMessages
|
||||
console.log('📨 会话消息加载完成,消息数量:', messages.value.length)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load session messages:', error)
|
||||
console.error('❌ 加载会话消息失败:', error)
|
||||
messages.value = []
|
||||
}
|
||||
}
|
||||
@@ -156,14 +213,171 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 清空消息
|
||||
const clearMessages = () => {
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
const searchMessages = (keyword: string) => {
|
||||
return messages.value.filter(message =>
|
||||
message.content.toLowerCase().includes(keyword.toLowerCase())
|
||||
)
|
||||
// 搜索消息:支持本地搜索和远程搜索
|
||||
const searchMessages = async (keyword: string) => {
|
||||
console.log('🔍 开始搜索消息:', { keyword })
|
||||
|
||||
if (!keyword.trim()) {
|
||||
console.log('🔍 搜索关键词为空,返回空结果')
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
// 先尝试远程搜索
|
||||
console.log('🔍 尝试远程搜索...')
|
||||
const response = await messageApi.searchUserMessages(keyword, 50)
|
||||
console.log('🔍 远程搜索API响应:', response)
|
||||
|
||||
// 处理API响应数据结构
|
||||
const searchResults = response.data || response || []
|
||||
console.log('🔍 提取的搜索结果:', searchResults)
|
||||
|
||||
const chatMessages = MessageService.convertToChatMessages(searchResults)
|
||||
console.log('🔍 转换后的搜索结果:', chatMessages)
|
||||
|
||||
return chatMessages
|
||||
} catch (error) {
|
||||
console.error('❌ 远程搜索失败,使用本地搜索:', error)
|
||||
// 如果远程搜索失败,使用本地搜索
|
||||
const localResults = messages.value.filter(message =>
|
||||
message.content.toLowerCase().includes(keyword.toLowerCase())
|
||||
)
|
||||
console.log('🔍 本地搜索结果:', localResults)
|
||||
return localResults
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户历史消息
|
||||
const loadUserMessages = async (page: number = 1, size: number = 20) => {
|
||||
console.log('📨 开始加载用户历史消息:', { page, size })
|
||||
try {
|
||||
const response = await messageApi.getUserMessages(page, size)
|
||||
console.log('📨 API响应原始数据:', response)
|
||||
|
||||
// 处理API响应数据结构
|
||||
const result = response.data || response
|
||||
const messageList = result.records || result.list || []
|
||||
|
||||
console.log('📨 提取的消息列表:', messageList)
|
||||
|
||||
const chatMessages = MessageService.convertToChatMessages(messageList)
|
||||
console.log('📨 转换后的聊天消息:', chatMessages)
|
||||
|
||||
if (page === 1) {
|
||||
// 第一页,替换现有消息
|
||||
messages.value = chatMessages
|
||||
console.log('📨 第一页数据已加载,消息总数:', messages.value.length)
|
||||
} else {
|
||||
// 后续页,追加到现有消息
|
||||
messages.value = [...messages.value, ...chatMessages]
|
||||
console.log('📨 追加数据已加载,消息总数:', messages.value.length)
|
||||
}
|
||||
|
||||
const returnData = {
|
||||
list: messageList,
|
||||
total: result.total || 0,
|
||||
page: result.current || page,
|
||||
size: result.size || size,
|
||||
pages: result.pages || 0
|
||||
}
|
||||
|
||||
console.log('📨 返回的分页数据:', returnData)
|
||||
return returnData
|
||||
} catch (error) {
|
||||
console.error('❌ 加载用户历史消息失败:', error)
|
||||
return { list: [], total: 0, page, size, pages: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近消息
|
||||
const loadRecentMessages = async (limit: number = 10) => {
|
||||
console.log('📝 开始加载最近消息:', { limit })
|
||||
try {
|
||||
// 直接使用messageApi,避免多层封装
|
||||
const response = await messageApi.getRecentMessages(limit)
|
||||
console.log('📝 最近消息API响应:', response)
|
||||
|
||||
// 处理响应数据 - 根据您的修改,messageApi现在返回 response.data || response
|
||||
let messageList = []
|
||||
if (Array.isArray(response)) {
|
||||
messageList = response
|
||||
console.log('📝 直接使用响应数组,消息数量:', messageList.length)
|
||||
} else if (response && response.data && Array.isArray(response.data)) {
|
||||
messageList = response.data
|
||||
console.log('📝 使用response.data,消息数量:', messageList.length)
|
||||
} else {
|
||||
console.warn('📝 无法识别的响应格式:', response)
|
||||
messageList = []
|
||||
}
|
||||
|
||||
console.log('📝 提取的最近消息列表:', messageList)
|
||||
console.log('📝 第一条消息示例:', messageList[0])
|
||||
|
||||
if (messageList.length === 0) {
|
||||
console.log('📝 没有找到最近消息')
|
||||
messages.value = []
|
||||
return []
|
||||
}
|
||||
|
||||
const chatMessages = MessageService.convertToChatMessages(messageList)
|
||||
console.log('📝 转换后的最近消息:', chatMessages)
|
||||
|
||||
// 详细检查每条消息的转换结果
|
||||
chatMessages.forEach((msg, index) => {
|
||||
console.log(`📝 消息${index + 1}:`, {
|
||||
id: msg.id,
|
||||
content: msg.content.substring(0, 20) + '...',
|
||||
sender: msg.sender,
|
||||
type: msg.type,
|
||||
role: msg.role,
|
||||
timestamp: msg.timestamp
|
||||
})
|
||||
})
|
||||
|
||||
// 按时间排序(最新的在后面)
|
||||
chatMessages.sort((a, b) => {
|
||||
// 处理时间格式 "2025-07-26 22:09:10" -> ISO格式
|
||||
const parseTime = (timestamp: string | Date) => {
|
||||
if (timestamp instanceof Date) {
|
||||
return timestamp.getTime()
|
||||
}
|
||||
if (typeof timestamp === 'string') {
|
||||
// 如果是 "2025-07-26 22:09:10" 格式,转换为ISO格式
|
||||
if (timestamp.includes(' ') && !timestamp.includes('T')) {
|
||||
const isoString = timestamp.replace(' ', 'T')
|
||||
return new Date(isoString).getTime()
|
||||
}
|
||||
return new Date(timestamp).getTime()
|
||||
}
|
||||
return new Date().getTime()
|
||||
}
|
||||
|
||||
const timeA = parseTime(a.timestamp)
|
||||
const timeB = parseTime(b.timestamp)
|
||||
|
||||
console.log('📝 排序比较:', {
|
||||
a: { id: a.id.substring(0, 8), timestamp: a.timestamp, parsed: new Date(timeA).toLocaleString() },
|
||||
b: { id: b.id.substring(0, 8), timestamp: b.timestamp, parsed: new Date(timeB).toLocaleString() },
|
||||
result: timeA - timeB
|
||||
})
|
||||
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
messages.value = chatMessages
|
||||
console.log('📝 最近消息已加载并排序,消息总数:', messages.value.length)
|
||||
|
||||
return chatMessages
|
||||
} catch (error) {
|
||||
console.error('❌ 加载最近消息失败:', error)
|
||||
messages.value = []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 添加AI回复消息(直接显示完整内容)
|
||||
@@ -184,7 +398,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
// WebSocket消息处理
|
||||
const handleWebSocketMessage = (wsMessage: WebSocketMessage) => {
|
||||
let handleWebSocketMessage = (wsMessage: WebSocketMessage) => {
|
||||
console.log('收到WebSocket消息:', wsMessage.type, wsMessage.senderType)
|
||||
|
||||
switch (wsMessage.type) {
|
||||
@@ -232,10 +446,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
// WebSocket连接管理
|
||||
const connectWebSocket = async () => {
|
||||
try {
|
||||
// 优先使用userInfo中的用户ID,如果没有则使用user中的ID
|
||||
const userId = userStore.userInfo?.id || userStore.user?.id || undefined
|
||||
// 获取当前登录用户的ID
|
||||
const userId = authStore.userInfo?.id || authStore.userId || undefined
|
||||
console.log('🔌 准备连接WebSocket,当前用户:', {
|
||||
userId,
|
||||
userInfo: authStore.userInfo,
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
accessToken: authStore.accessToken ? '已有token' : '无token'
|
||||
})
|
||||
|
||||
await webSocketService.connect(userId, {
|
||||
await stompWebSocketService.connect(userId, {
|
||||
onMessage: handleWebSocketMessage,
|
||||
onConnect: () => {
|
||||
console.log('WebSocket连接成功')
|
||||
@@ -244,7 +464,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
// 设置会话ID
|
||||
if (currentSession.value?.id) {
|
||||
webSocketService.setConversationId(currentSession.value.id)
|
||||
stompWebSocketService.setConversationId(currentSession.value.id)
|
||||
}
|
||||
},
|
||||
onDisconnect: () => {
|
||||
@@ -281,27 +501,69 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
const disconnectWebSocket = () => {
|
||||
webSocketService.disconnect()
|
||||
stompWebSocketService.disconnect()
|
||||
wsConnected.value = false
|
||||
isConnected.value = false
|
||||
isTyping.value = false
|
||||
}
|
||||
|
||||
// 初始化
|
||||
// 初始化聊天 - 参考web项目的实现
|
||||
const initChat = async () => {
|
||||
// 如果没有会话,创建一个默认会话
|
||||
if (sessions.value.length === 0) {
|
||||
await createSession('与开开的对话')
|
||||
}
|
||||
console.log('🚀 初始化聊天功能...')
|
||||
|
||||
// 连接WebSocket
|
||||
await connectWebSocket()
|
||||
try {
|
||||
// 1. 首先尝试加载最近消息(优先显示历史数据)
|
||||
console.log('📨 优先加载最近消息...')
|
||||
await loadRecentMessages(20)
|
||||
|
||||
// 2. 尝试加载用户的历史会话
|
||||
const currentUserId = authStore.userInfo?.id || authStore.userId
|
||||
if (currentUserId) {
|
||||
try {
|
||||
console.log('📂 尝试加载用户会话,用户ID:', currentUserId)
|
||||
const userSessions = await chatApi.getUserSessions(currentUserId)
|
||||
if (userSessions.length > 0) {
|
||||
sessions.value = userSessions
|
||||
currentSession.value = userSessions[0]
|
||||
console.log('✅ 加载到用户会话:', userSessions.length, '个')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 加载用户会话失败,继续使用已加载的消息:', error)
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ 未找到用户ID,无法加载用户会话')
|
||||
}
|
||||
|
||||
// 3. 如果没有会话,创建一个默认会话
|
||||
if (sessions.value.length === 0) {
|
||||
console.log('📝 创建默认会话...')
|
||||
await createSession('与开开的对话')
|
||||
}
|
||||
|
||||
// 4. 如果有特定会话但消息为空,尝试加载会话消息
|
||||
if (currentSession.value?.id && messages.value.length === 0) {
|
||||
console.log('📨 尝试加载特定会话消息...')
|
||||
try {
|
||||
await loadSessionMessages(currentSession.value.id)
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 加载会话消息失败,保持当前消息:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 连接WebSocket
|
||||
console.log('🔌 连接WebSocket...')
|
||||
await connectWebSocket()
|
||||
|
||||
console.log('✅ 聊天功能初始化完成,当前消息数量:', messages.value.length)
|
||||
} catch (error) {
|
||||
console.error('❌ 聊天功能初始化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听会话变化,更新WebSocket会话ID
|
||||
watch(currentSession, (newSession) => {
|
||||
if (newSession?.id && wsConnected.value) {
|
||||
webSocketService.setConversationId(newSession.id)
|
||||
stompWebSocketService.setConversationId(newSession.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -314,19 +576,36 @@ export const useChatStore = defineStore('chat', () => {
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
wsConnected,
|
||||
currentMessages,
|
||||
|
||||
// 方法
|
||||
// 基础方法
|
||||
addMessage,
|
||||
sendMessage,
|
||||
createSession,
|
||||
switchSession,
|
||||
loadSessionMessages,
|
||||
deleteSession,
|
||||
clearMessages,
|
||||
searchMessages,
|
||||
initChat,
|
||||
|
||||
// 消息加载方法
|
||||
loadSessionMessages,
|
||||
loadUserMessages,
|
||||
loadRecentMessages,
|
||||
searchMessages,
|
||||
|
||||
// WebSocket方法
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
handleWebSocketMessage
|
||||
handleWebSocketMessage,
|
||||
|
||||
// 消息监听方法
|
||||
onMessage: (callback: (message: any) => void) => {
|
||||
// 简单的消息监听实现
|
||||
const originalHandler = handleWebSocketMessage
|
||||
handleWebSocketMessage = (message: any) => {
|
||||
originalHandler(message)
|
||||
callback(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { DiaryEntry } from '@/types'
|
||||
|
||||
export const useDiaryStore = defineStore('diary', () => {
|
||||
// 日记状态
|
||||
const entries = ref<DiaryEntry[]>([])
|
||||
const currentEntry = ref<DiaryEntry | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 方法
|
||||
const addEntry = async (content: string, mood?: string, tags?: string[]) => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const newEntry: DiaryEntry = {
|
||||
id: Date.now().toString(),
|
||||
content,
|
||||
mood,
|
||||
tags: tags || [],
|
||||
createTime: new Date().toISOString(),
|
||||
updateTime: new Date().toISOString()
|
||||
}
|
||||
|
||||
// TODO: 调用API保存日记
|
||||
// const response = await diaryApi.createEntry(newEntry)
|
||||
|
||||
// 模拟AI回复
|
||||
setTimeout(() => {
|
||||
newEntry.aiReply = generateAIReply(content, mood)
|
||||
entries.value.unshift(newEntry)
|
||||
isLoading.value = false
|
||||
}, 1000)
|
||||
|
||||
return newEntry
|
||||
} catch (error) {
|
||||
console.error('Failed to add diary entry:', error)
|
||||
isLoading.value = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const updateEntry = async (id: string, updates: Partial<DiaryEntry>) => {
|
||||
const index = entries.value.findIndex(entry => entry.id === id)
|
||||
if (index > -1) {
|
||||
entries.value[index] = {
|
||||
...entries.value[index],
|
||||
...updates,
|
||||
updateTime: new Date().toISOString()
|
||||
}
|
||||
|
||||
// TODO: 调用API更新日记
|
||||
// await diaryApi.updateEntry(id, updates)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEntry = async (id: string) => {
|
||||
const index = entries.value.findIndex(entry => entry.id === id)
|
||||
if (index > -1) {
|
||||
entries.value.splice(index, 1)
|
||||
|
||||
// TODO: 调用API删除日记
|
||||
// await diaryApi.deleteEntry(id)
|
||||
}
|
||||
}
|
||||
|
||||
const getEntry = (id: string) => {
|
||||
return entries.value.find(entry => entry.id === id)
|
||||
}
|
||||
|
||||
const searchEntries = (keyword: string) => {
|
||||
return entries.value.filter(entry =>
|
||||
entry.content.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||
entry.tags?.some(tag => tag.toLowerCase().includes(keyword.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
const getEntriesByMood = (mood: string) => {
|
||||
return entries.value.filter(entry => entry.mood === mood)
|
||||
}
|
||||
|
||||
const getEntriesByDateRange = (startDate: string, endDate: string) => {
|
||||
return entries.value.filter(entry => {
|
||||
const entryDate = new Date(entry.createTime).toISOString().split('T')[0]
|
||||
return entryDate >= startDate && entryDate <= endDate
|
||||
})
|
||||
}
|
||||
|
||||
const loadEntries = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// TODO: 从API加载日记列表
|
||||
// const response = await diaryApi.getEntries()
|
||||
// entries.value = response.data
|
||||
|
||||
// 临时模拟数据
|
||||
entries.value = [
|
||||
{
|
||||
id: '1',
|
||||
content: '今天天气很好,心情也不错。和朋友一起去公园散步,看到了很多美丽的花朵。',
|
||||
mood: 'happy',
|
||||
tags: ['散步', '朋友', '公园'],
|
||||
createTime: new Date(Date.now() - 86400000).toISOString(),
|
||||
updateTime: new Date(Date.now() - 86400000).toISOString(),
|
||||
aiReply: '听起来你度过了美好的一天!和朋友一起在大自然中放松是很棒的体验。这样的时光能让我们感受到生活的美好。'
|
||||
}
|
||||
]
|
||||
|
||||
isLoading.value = false
|
||||
} catch (error) {
|
||||
console.error('Failed to load diary entries:', error)
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成AI回复的辅助函数
|
||||
const generateAIReply = (_content: string, mood?: string) => {
|
||||
const replies = {
|
||||
happy: [
|
||||
'很高兴看到你心情愉快!继续保持这份美好的心情吧。',
|
||||
'你的快乐感染了我!希望这份喜悦能持续下去。',
|
||||
'看到你开心,我也很开心。愿你每天都有这样的好心情!'
|
||||
],
|
||||
sad: [
|
||||
'我能感受到你的难过。记住,这只是暂时的,一切都会好起来的。',
|
||||
'每个人都会有低落的时候,这很正常。我会陪伴你度过这段时光。',
|
||||
'虽然现在感到难过,但请相信明天会更好。我一直在这里支持你。'
|
||||
],
|
||||
neutral: [
|
||||
'感谢你分享今天的经历。每一天都是独特的,值得被记录。',
|
||||
'生活就是这样平凡而珍贵。感谢你让我了解你的日常。',
|
||||
'平静的日子也有它的美好。希望你能在平凡中发现小确幸。'
|
||||
]
|
||||
}
|
||||
|
||||
const moodReplies = replies[(mood as keyof typeof replies) || 'neutral'] || replies.neutral
|
||||
return moodReplies[Math.floor(Math.random() * moodReplies.length)]
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
entries,
|
||||
currentEntry,
|
||||
isLoading,
|
||||
|
||||
// 方法
|
||||
addEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
getEntry,
|
||||
searchEntries,
|
||||
getEntriesByMood,
|
||||
getEntriesByDateRange,
|
||||
loadEntries
|
||||
}
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
|
||||
// 导出所有store
|
||||
export { useUserStore } from './user'
|
||||
export { useChatStore } from './chat'
|
||||
export { useDiaryStore } from './diary'
|
||||
export { useAppStore } from './app'
|
||||
@@ -1,157 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authService, authUtils } from '@/services/auth'
|
||||
import type { User } from '@/types'
|
||||
import type { UserInfo, LoginRequest } from '@/types/auth'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 用户状态
|
||||
const user = ref<User | null>(null)
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const token = ref<string>('')
|
||||
const isLoading = ref(false)
|
||||
const isLoggedIn = computed(() => !!token.value && (!!user.value || !!userInfo.value))
|
||||
|
||||
// 方法
|
||||
const setUser = (userData: User) => {
|
||||
user.value = userData
|
||||
}
|
||||
|
||||
const setToken = (tokenValue: string) => {
|
||||
token.value = tokenValue
|
||||
// 存储到localStorage
|
||||
if (tokenValue) {
|
||||
localStorage.setItem('token', tokenValue)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
const setUserInfo = (userInfoData: UserInfo | null) => {
|
||||
userInfo.value = userInfoData
|
||||
// 存储到localStorage
|
||||
if (userInfoData) {
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfoData))
|
||||
} else {
|
||||
localStorage.removeItem('userInfo')
|
||||
}
|
||||
}
|
||||
|
||||
// 新的登录方法,支持认证服务
|
||||
const loginWithAuth = async (loginData: LoginRequest) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await authService.login(loginData)
|
||||
setToken(data.accessToken)
|
||||
setUserInfo(data.userInfo)
|
||||
return data
|
||||
} catch (error: any) {
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (credentials: { username: string; password: string }) => {
|
||||
try {
|
||||
// TODO: 调用登录API
|
||||
// const response = await authApi.login(credentials)
|
||||
// setToken(response.data.token)
|
||||
// setUser(response.data.user)
|
||||
|
||||
// 临时模拟登录
|
||||
setToken('mock-token')
|
||||
setUser({
|
||||
id: '1',
|
||||
username: credentials.username,
|
||||
email: 'user@example.com',
|
||||
nickname: '用户',
|
||||
createTime: new Date().toISOString(),
|
||||
updateTime: new Date().toISOString()
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await authService.logout()
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
} finally {
|
||||
// 清除状态和本地存储
|
||||
user.value = null
|
||||
userInfo.value = null
|
||||
setToken('')
|
||||
authUtils.clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfile = (profileData: Partial<User>) => {
|
||||
if (user.value) {
|
||||
user.value = { ...user.value, ...profileData }
|
||||
// TODO: 调用更新API
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化用户状态
|
||||
const initUser = () => {
|
||||
const savedToken = authUtils.getToken()
|
||||
const savedUserInfo = authUtils.getUserInfo()
|
||||
|
||||
console.log('初始化用户状态:', { savedToken: !!savedToken, savedUserInfo })
|
||||
|
||||
if (savedToken) {
|
||||
setToken(savedToken)
|
||||
}
|
||||
|
||||
if (savedUserInfo) {
|
||||
setUserInfo(savedUserInfo)
|
||||
}
|
||||
|
||||
console.log('用户状态初始化完成:', {
|
||||
token: !!token.value,
|
||||
userInfo: userInfo.value,
|
||||
isLoggedIn: isLoggedIn.value
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新用户信息
|
||||
const refreshUserInfo = async () => {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const response = await authService.getUserInfo()
|
||||
if (response.success) {
|
||||
userInfo.value = response.data
|
||||
authUtils.setUserInfo(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Refresh user info error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
user,
|
||||
userInfo,
|
||||
token,
|
||||
isLoading,
|
||||
isLoggedIn,
|
||||
|
||||
// 方法
|
||||
setUser,
|
||||
setToken,
|
||||
setUserInfo,
|
||||
login,
|
||||
loginWithAuth,
|
||||
logout,
|
||||
updateProfile,
|
||||
initUser,
|
||||
refreshUserInfo
|
||||
}
|
||||
})
|
||||
+206
-28
@@ -1,83 +1,261 @@
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
// 登录请求
|
||||
export interface LoginRequest {
|
||||
/** 账号(支持账号/邮箱/手机号) */
|
||||
account: string
|
||||
/** 密码 */
|
||||
password: string
|
||||
/** 验证码 */
|
||||
captcha: string
|
||||
captchaKey?: string
|
||||
remember?: boolean
|
||||
/** 验证码key */
|
||||
captchaKey: string
|
||||
/** 记住我 */
|
||||
rememberMe?: boolean
|
||||
}
|
||||
|
||||
// 注册请求
|
||||
export interface RegisterRequest {
|
||||
/** 账号 */
|
||||
account: string
|
||||
/** 密码 */
|
||||
password: string
|
||||
/** 确认密码 */
|
||||
confirmPassword: string
|
||||
/** 用户名 */
|
||||
username: string
|
||||
/** 昵称 */
|
||||
nickname: string
|
||||
/** 邮箱 */
|
||||
email: string
|
||||
/** 手机号 */
|
||||
phone?: string
|
||||
email?: string
|
||||
/** 验证码 */
|
||||
captcha: string
|
||||
captchaKey?: string
|
||||
/** 验证码key */
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
export interface UserInfo {
|
||||
/** 用户ID */
|
||||
id: string
|
||||
/** 账号 */
|
||||
account: string
|
||||
username?: string
|
||||
nickname?: string
|
||||
/** 用户名 */
|
||||
username: string
|
||||
/** 昵称 */
|
||||
nickname: string
|
||||
/** 头像 */
|
||||
avatar?: string
|
||||
phone?: string
|
||||
/** 邮箱 */
|
||||
email?: string
|
||||
/** 手机号 */
|
||||
phone?: string
|
||||
/** 生日 */
|
||||
birthDate?: string
|
||||
/** 所在地 */
|
||||
location?: string
|
||||
/** 个人简介 */
|
||||
bio?: string
|
||||
/** 会员等级 */
|
||||
memberLevel?: string
|
||||
/** 使用天数 */
|
||||
totalDays?: number
|
||||
/** 成长数据 */
|
||||
growthStats?: GrowthStats
|
||||
/** 状态 */
|
||||
status: number
|
||||
/** 是否已验证 */
|
||||
isVerified?: number
|
||||
/** 创建时间 */
|
||||
createTime: string
|
||||
updateTime: string
|
||||
/** 最后活跃时间 */
|
||||
lastActiveTime?: string
|
||||
}
|
||||
|
||||
// 登录响应
|
||||
export interface LoginResponse {
|
||||
// 成长数据
|
||||
export interface GrowthStats {
|
||||
/** 自我感知 */
|
||||
selfAwareness: number
|
||||
/** 情绪韧性 */
|
||||
emotionalResilience: number
|
||||
/** 行动力 */
|
||||
actionPower: number
|
||||
/** 共情力 */
|
||||
empathy: number
|
||||
/** 生活热度 */
|
||||
lifeEnthusiasm: number
|
||||
}
|
||||
|
||||
// 认证响应
|
||||
export interface AuthResponse {
|
||||
/** 访问令牌 */
|
||||
accessToken: string
|
||||
/** 刷新令牌 */
|
||||
refreshToken: string
|
||||
/** 用户信息 */
|
||||
userInfo: UserInfo
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number
|
||||
/** 登录时间 */
|
||||
loginTime: string
|
||||
}
|
||||
|
||||
// 用户信息响应
|
||||
export interface UserInfoResponse {
|
||||
/** 用户信息 */
|
||||
userInfo: UserInfo
|
||||
}
|
||||
|
||||
// 验证码响应
|
||||
export interface CaptchaResponse {
|
||||
/** 验证码key */
|
||||
captchaKey: string
|
||||
/** 验证码图片(base64) */
|
||||
captchaImage: string
|
||||
/** 过期时间(秒) */
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
// API响应基础结构
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// 刷新token请求
|
||||
// 刷新Token请求
|
||||
export interface RefreshTokenRequest {
|
||||
/** 刷新令牌 */
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
// 修改密码请求
|
||||
export interface ChangePasswordRequest {
|
||||
/** 旧密码 */
|
||||
oldPassword: string
|
||||
/** 新密码 */
|
||||
newPassword: string
|
||||
/** 确认新密码 */
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
// 忘记密码请求
|
||||
export interface ForgotPasswordRequest {
|
||||
account: string
|
||||
captcha: string
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 重置密码请求
|
||||
export interface ResetPasswordRequest {
|
||||
token: string
|
||||
/** 账号 */
|
||||
account: string
|
||||
/** 新密码 */
|
||||
newPassword: string
|
||||
/** 确认新密码 */
|
||||
confirmPassword: string
|
||||
/** 验证码 */
|
||||
captcha: string
|
||||
/** 验证码key */
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 发送验证码请求
|
||||
export interface SendCodeRequest {
|
||||
/** 手机号或邮箱 */
|
||||
target: string
|
||||
/** 验证码类型 */
|
||||
type: 'sms' | 'email'
|
||||
/** 场景 */
|
||||
scene: 'register' | 'login' | 'reset_password' | 'change_phone' | 'change_email'
|
||||
}
|
||||
|
||||
// 验证验证码请求
|
||||
export interface VerifyCodeRequest {
|
||||
/** 手机号或邮箱 */
|
||||
target: string
|
||||
/** 验证码 */
|
||||
code: string
|
||||
/** 场景 */
|
||||
scene: string
|
||||
}
|
||||
|
||||
// 第三方登录请求
|
||||
export interface OAuthLoginRequest {
|
||||
/** 平台类型 */
|
||||
platform: 'wechat' | 'qq' | 'weibo' | 'github'
|
||||
/** 授权码 */
|
||||
code: string
|
||||
/** 状态码 */
|
||||
state?: string
|
||||
}
|
||||
|
||||
// 绑定第三方账号请求
|
||||
export interface BindOAuthRequest {
|
||||
/** 平台类型 */
|
||||
platform: 'wechat' | 'qq' | 'weibo' | 'github'
|
||||
/** 授权码 */
|
||||
code: string
|
||||
/** 状态码 */
|
||||
state?: string
|
||||
}
|
||||
|
||||
// 解绑第三方账号请求
|
||||
export interface UnbindOAuthRequest {
|
||||
/** 平台类型 */
|
||||
platform: 'wechat' | 'qq' | 'weibo' | 'github'
|
||||
}
|
||||
|
||||
// 第三方账号信息
|
||||
export interface OAuthInfo {
|
||||
/** 平台类型 */
|
||||
platform: 'wechat' | 'qq' | 'weibo' | 'github'
|
||||
/** 平台用户ID */
|
||||
openId: string
|
||||
/** 平台昵称 */
|
||||
nickname: string
|
||||
/** 平台头像 */
|
||||
avatar: string
|
||||
/** 绑定时间 */
|
||||
bindTime: string
|
||||
}
|
||||
|
||||
// 用户权限信息
|
||||
export interface UserPermission {
|
||||
/** 角色列表 */
|
||||
roles: string[]
|
||||
/** 权限列表 */
|
||||
permissions: string[]
|
||||
/** 菜单列表 */
|
||||
menus: string[]
|
||||
}
|
||||
|
||||
// 登录历史
|
||||
export interface LoginHistory {
|
||||
/** ID */
|
||||
id: string
|
||||
/** 登录时间 */
|
||||
loginTime: string
|
||||
/** 登录IP */
|
||||
loginIp: string
|
||||
/** 登录地址 */
|
||||
loginAddress: string
|
||||
/** 设备信息 */
|
||||
deviceInfo: string
|
||||
/** 浏览器信息 */
|
||||
browserInfo: string
|
||||
/** 登录状态 */
|
||||
status: number
|
||||
}
|
||||
|
||||
// 在线用户信息
|
||||
export interface OnlineUser {
|
||||
/** 用户ID */
|
||||
userId: string
|
||||
/** 用户名 */
|
||||
username: string
|
||||
/** 昵称 */
|
||||
nickname: string
|
||||
/** 头像 */
|
||||
avatar?: string
|
||||
/** 登录时间 */
|
||||
loginTime: string
|
||||
/** 最后活动时间 */
|
||||
lastActiveTime: string
|
||||
/** 登录IP */
|
||||
loginIp: string
|
||||
/** 设备信息 */
|
||||
deviceInfo: string
|
||||
/** 浏览器信息 */
|
||||
browserInfo: string
|
||||
}
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_ENV: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_VERSION: string
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_WS_BASE_URL: string
|
||||
readonly VITE_UPLOAD_URL: string
|
||||
readonly VITE_DEBUG: string
|
||||
readonly VITE_MOCK: string
|
||||
readonly VITE_APP_DESCRIPTION: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
lucide: {
|
||||
createIcons: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
+55
-134
@@ -3,178 +3,99 @@ export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
phone?: string
|
||||
avatar?: string
|
||||
nickname?: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
// 聊天消息类型
|
||||
// 聊天消息类型 - 与后端MessageResponse匹配
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string
|
||||
type: 'user' | 'ai'
|
||||
type: 'user' | 'ai' | 'system'
|
||||
timestamp: string
|
||||
sessionId?: string
|
||||
conversationId?: string // 改为conversationId以匹配后端
|
||||
sessionId?: string // 保留sessionId作为别名
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
sender?: 'user' | 'ai' | 'system'
|
||||
isRead?: number
|
||||
error?: string
|
||||
sender?: string
|
||||
role?: 'user' | 'assistant' | 'system' // 用于UI显示
|
||||
}
|
||||
|
||||
// 聊天会话类型
|
||||
// 聊天会话类型 - 与后端ConversationResponse匹配
|
||||
export interface ChatSession {
|
||||
id: string
|
||||
title: string
|
||||
userId?: string
|
||||
userId: string
|
||||
userType?: string
|
||||
type?: string
|
||||
status?: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
messageCount: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
lastActiveTime?: string
|
||||
}
|
||||
|
||||
// 日记条目类型
|
||||
export interface DiaryEntry {
|
||||
id: string
|
||||
content: string
|
||||
mood?: string
|
||||
tags?: string[]
|
||||
createTime: string
|
||||
updateTime: string
|
||||
aiReply?: string
|
||||
}
|
||||
|
||||
// 个人信息类型
|
||||
export interface PersonalInfo {
|
||||
id: string
|
||||
userId: string
|
||||
nickname?: string
|
||||
age?: number
|
||||
gender?: string
|
||||
location?: string
|
||||
occupation?: string
|
||||
interests: string[]
|
||||
skills: string[]
|
||||
quotes: PersonalQuote[]
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
// 个人语录类型
|
||||
export interface PersonalQuote {
|
||||
id: string
|
||||
content: string
|
||||
createTime: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
// 话题类型
|
||||
export interface Topic {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
createTime: string
|
||||
updateTime: string
|
||||
status: 'active' | 'completed' | 'paused'
|
||||
progress?: number
|
||||
}
|
||||
|
||||
// 生活轨迹事件类型
|
||||
export interface LifeEvent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
date: string
|
||||
type: 'milestone' | 'achievement' | 'memory' | 'goal'
|
||||
importance: 1 | 2 | 3 | 4 | 5
|
||||
tags?: string[]
|
||||
attachments?: string[]
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
export interface Message {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: 'system' | 'notification' | 'reminder'
|
||||
status: 'unread' | 'read'
|
||||
createTime: string
|
||||
actionUrl?: string
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
// API响应基础类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
timestamp: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// 分页参数类型
|
||||
export interface PaginationParams {
|
||||
// 分页请求参数
|
||||
export interface PageParams {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
total?: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[]
|
||||
pagination: {
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
// 登录请求参数
|
||||
export interface LoginRequest {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
// 导航链接类型
|
||||
export interface NavLink {
|
||||
name: string
|
||||
href: string
|
||||
icon?: string
|
||||
children?: NavLink[]
|
||||
// 登录响应数据
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
user: User
|
||||
}
|
||||
|
||||
// 功能特性类型
|
||||
export interface Feature {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
alt: string
|
||||
// 注册请求参数
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 心情统计类型
|
||||
export interface MoodStats {
|
||||
date: string
|
||||
mood: string
|
||||
score: number
|
||||
// 用户资料更新请求
|
||||
export interface UserProfileUpdateRequest {
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
// 表单验证规则类型
|
||||
export interface ValidationRule {
|
||||
required?: boolean
|
||||
message?: string
|
||||
pattern?: RegExp
|
||||
min?: number
|
||||
max?: number
|
||||
validator?: (rule: any, value: any) => Promise<void>
|
||||
}
|
||||
|
||||
// 主题配置类型
|
||||
export interface ThemeConfig {
|
||||
primaryColor: string
|
||||
secondaryColor: string
|
||||
backgroundColor: string
|
||||
textColor: string
|
||||
borderRadius: string
|
||||
}
|
||||
|
||||
// 环境配置类型
|
||||
export interface EnvConfig {
|
||||
apiBaseUrl: string
|
||||
uploadUrl: string
|
||||
wsUrl: string
|
||||
isDevelopment: boolean
|
||||
isProduction: boolean
|
||||
// 文件上传响应
|
||||
export interface UploadResponse {
|
||||
url: string
|
||||
filename: string
|
||||
size: number
|
||||
type: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 错误处理工具
|
||||
*/
|
||||
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
|
||||
// 错误类型枚举
|
||||
export enum ErrorType {
|
||||
NETWORK = 'NETWORK',
|
||||
AUTH = 'AUTH',
|
||||
VALIDATION = 'VALIDATION',
|
||||
BUSINESS = 'BUSINESS',
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
// 错误信息接口
|
||||
export interface ErrorInfo {
|
||||
type: ErrorType
|
||||
code?: string | number
|
||||
message: string
|
||||
details?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误分类器
|
||||
*/
|
||||
export class ErrorClassifier {
|
||||
/**
|
||||
* 分析错误类型
|
||||
*/
|
||||
static classify(error: any): ErrorInfo {
|
||||
// 网络错误
|
||||
if (error.code === 'NETWORK_ERROR' || error.message?.includes('Network Error')) {
|
||||
return {
|
||||
type: ErrorType.NETWORK,
|
||||
code: error.code,
|
||||
message: '网络连接失败,请检查网络设置'
|
||||
}
|
||||
}
|
||||
|
||||
// 认证错误
|
||||
if (error.status === 401 || error.code === 401) {
|
||||
return {
|
||||
type: ErrorType.AUTH,
|
||||
code: 401,
|
||||
message: '登录已过期,请重新登录'
|
||||
}
|
||||
}
|
||||
|
||||
// 权限错误
|
||||
if (error.status === 403 || error.code === 403) {
|
||||
return {
|
||||
type: ErrorType.AUTH,
|
||||
code: 403,
|
||||
message: '没有权限访问该资源'
|
||||
}
|
||||
}
|
||||
|
||||
// 验证错误
|
||||
if (error.status === 400 || error.code === 400) {
|
||||
return {
|
||||
type: ErrorType.VALIDATION,
|
||||
code: 400,
|
||||
message: error.message || '请求参数错误'
|
||||
}
|
||||
}
|
||||
|
||||
// 服务器错误
|
||||
if (error.status >= 500 || error.code >= 500) {
|
||||
return {
|
||||
type: ErrorType.NETWORK,
|
||||
code: error.status || error.code,
|
||||
message: '服务器内部错误,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
// 业务错误
|
||||
if (error.message) {
|
||||
return {
|
||||
type: ErrorType.BUSINESS,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error
|
||||
}
|
||||
}
|
||||
|
||||
// 未知错误
|
||||
return {
|
||||
type: ErrorType.UNKNOWN,
|
||||
message: '发生未知错误,请稍后重试',
|
||||
details: error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误处理器
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* 处理错误
|
||||
*/
|
||||
static handle(error: any, options: {
|
||||
showMessage?: boolean
|
||||
showNotification?: boolean
|
||||
logError?: boolean
|
||||
} = {}) {
|
||||
const {
|
||||
showMessage = true,
|
||||
showNotification = false,
|
||||
logError = true
|
||||
} = options
|
||||
|
||||
const errorInfo = ErrorClassifier.classify(error)
|
||||
|
||||
// 记录错误日志
|
||||
if (logError) {
|
||||
console.error('错误处理:', {
|
||||
type: errorInfo.type,
|
||||
code: errorInfo.code,
|
||||
message: errorInfo.message,
|
||||
details: errorInfo.details,
|
||||
originalError: error
|
||||
})
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
if (showMessage) {
|
||||
this.showErrorMessage(errorInfo)
|
||||
}
|
||||
|
||||
// 显示错误通知
|
||||
if (showNotification) {
|
||||
this.showErrorNotification(errorInfo)
|
||||
}
|
||||
|
||||
return errorInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误消息
|
||||
*/
|
||||
private static showErrorMessage(errorInfo: ErrorInfo) {
|
||||
switch (errorInfo.type) {
|
||||
case ErrorType.NETWORK:
|
||||
ElMessage.error({
|
||||
message: errorInfo.message,
|
||||
duration: 5000
|
||||
})
|
||||
break
|
||||
case ErrorType.AUTH:
|
||||
ElMessage.warning({
|
||||
message: errorInfo.message,
|
||||
duration: 3000
|
||||
})
|
||||
break
|
||||
case ErrorType.VALIDATION:
|
||||
ElMessage.warning({
|
||||
message: errorInfo.message,
|
||||
duration: 3000
|
||||
})
|
||||
break
|
||||
case ErrorType.BUSINESS:
|
||||
ElMessage.error({
|
||||
message: errorInfo.message,
|
||||
duration: 4000
|
||||
})
|
||||
break
|
||||
default:
|
||||
ElMessage.error({
|
||||
message: errorInfo.message,
|
||||
duration: 4000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误通知
|
||||
*/
|
||||
private static showErrorNotification(errorInfo: ErrorInfo) {
|
||||
ElNotification.error({
|
||||
title: '错误提示',
|
||||
message: errorInfo.message,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证相关错误
|
||||
*/
|
||||
static handleAuthError(error: any) {
|
||||
const errorInfo = this.handle(error, {
|
||||
showMessage: true,
|
||||
logError: true
|
||||
})
|
||||
|
||||
// 如果是认证错误,可能需要跳转到登录页
|
||||
if (errorInfo.type === ErrorType.AUTH && errorInfo.code === 401) {
|
||||
// 清除本地认证信息
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_info')
|
||||
|
||||
// 延迟跳转,让用户看到错误消息
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return errorInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理API请求错误
|
||||
*/
|
||||
static handleApiError(error: any, context?: string) {
|
||||
const contextMessage = context ? `${context}: ` : ''
|
||||
|
||||
const errorInfo = this.handle(error, {
|
||||
showMessage: true,
|
||||
logError: true
|
||||
})
|
||||
|
||||
// 添加上下文信息
|
||||
if (context) {
|
||||
console.error(`${contextMessage}`, errorInfo)
|
||||
}
|
||||
|
||||
return errorInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表单验证错误
|
||||
*/
|
||||
static handleValidationError(error: any, fieldName?: string) {
|
||||
let message = error.message || '表单验证失败'
|
||||
|
||||
if (fieldName) {
|
||||
message = `${fieldName}: ${message}`
|
||||
}
|
||||
|
||||
ElMessage.warning({
|
||||
message,
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
return {
|
||||
type: ErrorType.VALIDATION,
|
||||
message,
|
||||
details: error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误处理装饰器
|
||||
*/
|
||||
export function handleError(options?: {
|
||||
showMessage?: boolean
|
||||
showNotification?: boolean
|
||||
logError?: boolean
|
||||
}) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
try {
|
||||
return await originalMethod.apply(this, args)
|
||||
} catch (error) {
|
||||
ErrorHandler.handle(error, options)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
// 导出常用方法
|
||||
export const handleApiError = ErrorHandler.handleApiError
|
||||
export const handleAuthError = ErrorHandler.handleAuthError
|
||||
export const handleValidationError = ErrorHandler.handleValidationError
|
||||
@@ -1,229 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
// 配置dayjs
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
// 时间格式化
|
||||
export const formatTime = {
|
||||
// 相对时间
|
||||
relative: (date: string | Date) => dayjs(date).fromNow(),
|
||||
|
||||
// 标准格式
|
||||
standard: (date: string | Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
|
||||
// 日期格式
|
||||
date: (date: string | Date) => dayjs(date).format('YYYY-MM-DD'),
|
||||
|
||||
// 时间格式
|
||||
time: (date: string | Date) => dayjs(date).format('HH:mm:ss'),
|
||||
|
||||
// 友好格式
|
||||
friendly: (date: string | Date) => {
|
||||
const now = dayjs()
|
||||
const target = dayjs(date)
|
||||
const diffDays = now.diff(target, 'day')
|
||||
|
||||
if (diffDays === 0) {
|
||||
return target.format('HH:mm')
|
||||
} else if (diffDays === 1) {
|
||||
return '昨天 ' + target.format('HH:mm')
|
||||
} else if (diffDays < 7) {
|
||||
return target.format('M月D日 HH:mm')
|
||||
} else {
|
||||
return target.format('YYYY年M月D日')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(() => func(...args), wait)
|
||||
}
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
export const throttle = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let lastTime = 0
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now()
|
||||
if (now - lastTime >= wait) {
|
||||
lastTime = now
|
||||
func(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
export const generateId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
// 深拷贝
|
||||
export const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime()) as T
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => deepClone(item)) as T
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const cloned = {} as T
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
cloned[key] = deepClone(obj[key])
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// 文件大小格式化
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 数字格式化
|
||||
export const formatNumber = (num: number): string => {
|
||||
if (num < 1000) return num.toString()
|
||||
if (num < 10000) return (num / 1000).toFixed(1) + 'K'
|
||||
if (num < 100000000) return (num / 10000).toFixed(1) + '万'
|
||||
return (num / 100000000).toFixed(1) + '亿'
|
||||
}
|
||||
|
||||
// 颜色工具
|
||||
export const colorUtils = {
|
||||
// 十六进制转RGB
|
||||
hexToRgb: (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null
|
||||
},
|
||||
|
||||
// RGB转十六进制
|
||||
rgbToHex: (r: number, g: number, b: number) => {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
},
|
||||
|
||||
// 获取随机颜色
|
||||
random: () => {
|
||||
return '#' + Math.floor(Math.random() * 16777215).toString(16)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地存储工具
|
||||
export const storage = {
|
||||
set: (key: string, value: any) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch (error) {
|
||||
console.error('Storage set error:', error)
|
||||
}
|
||||
},
|
||||
|
||||
get: <T = any>(key: string, defaultValue?: T): T | null => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : defaultValue || null
|
||||
} catch (error) {
|
||||
console.error('Storage get error:', error)
|
||||
return defaultValue || null
|
||||
}
|
||||
},
|
||||
|
||||
remove: (key: string) => {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch (error) {
|
||||
console.error('Storage remove error:', error)
|
||||
}
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
try {
|
||||
localStorage.clear()
|
||||
} catch (error) {
|
||||
console.error('Storage clear error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL工具
|
||||
export const urlUtils = {
|
||||
// 获取查询参数
|
||||
getQuery: (name: string): string | null => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
return urlParams.get(name)
|
||||
},
|
||||
|
||||
// 设置查询参数
|
||||
setQuery: (params: Record<string, string>) => {
|
||||
const url = new URL(window.location.href)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value)
|
||||
})
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
},
|
||||
|
||||
// 删除查询参数
|
||||
removeQuery: (name: string) => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete(name)
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
}
|
||||
|
||||
// 设备检测
|
||||
export const deviceUtils = {
|
||||
isMobile: () => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),
|
||||
isIOS: () => /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
isAndroid: () => /Android/.test(navigator.userAgent),
|
||||
isWechat: () => /MicroMessenger/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
// 验证工具
|
||||
export const validators = {
|
||||
email: (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
|
||||
phone: (phone: string) => /^1[3-9]\d{9}$/.test(phone),
|
||||
password: (password: string) => password.length >= 6,
|
||||
url: (url: string) => {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* MessageService 测试工具
|
||||
* 用于验证消息服务是否正常工作
|
||||
*/
|
||||
|
||||
import MessageService from '@/services/message'
|
||||
|
||||
export const testMessageService = async () => {
|
||||
console.log('🧪 开始测试 MessageService...')
|
||||
|
||||
try {
|
||||
// 测试获取最近消息
|
||||
console.log('📝 测试获取最近消息...')
|
||||
const recentMessages = await MessageService.getRecentMessages({ limit: 5 })
|
||||
console.log('✅ 最近消息:', recentMessages)
|
||||
|
||||
// 测试分页获取消息
|
||||
console.log('📄 测试分页获取消息...')
|
||||
const pageMessages = await MessageService.getUserMessages(1, 10)
|
||||
console.log('✅ 分页消息:', pageMessages)
|
||||
|
||||
console.log('🎉 MessageService 测试完成!')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ MessageService 测试失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 在开发环境下可以在控制台调用 window.testMessageService() 进行测试
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).testMessageService = testMessageService
|
||||
}
|
||||
+234
-104
@@ -1,118 +1,248 @@
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import router from '@/router'
|
||||
/**
|
||||
* HTTP请求工具
|
||||
* 基于axios封装的统一请求实例
|
||||
*/
|
||||
|
||||
// 获取API基础URL
|
||||
const getApiBaseUrl = () => {
|
||||
// 开发环境使用代理
|
||||
if (import.meta.env.DEV) {
|
||||
return '/api'
|
||||
}
|
||||
// 生产环境使用环境变量
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
// 请求响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// 请求配置接口
|
||||
export interface RequestConfig extends AxiosRequestConfig {
|
||||
// 是否显示loading
|
||||
showLoading?: boolean
|
||||
// 是否显示错误消息
|
||||
showError?: boolean
|
||||
// 是否需要token
|
||||
needToken?: boolean
|
||||
}
|
||||
|
||||
// 创建axios实例
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('API Base URL:', getApiBaseUrl())
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 从localStorage获取token
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (token && config.headers) {
|
||||
// 在请求头中添加Authorization
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
const createAxiosInstance = (): AxiosInstance => {
|
||||
const instance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8'
|
||||
}
|
||||
|
||||
console.log('发送请求:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
hasToken: !!token,
|
||||
headers: config.headers
|
||||
})
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求拦截器错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const { data } = response
|
||||
// 标准后端格式: { code, message, data, timestamp }
|
||||
if (typeof data === 'object' && data !== null && 'code' in data) {
|
||||
if (data.code !== 200) {
|
||||
message.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config: any) => {
|
||||
// 添加token
|
||||
const token = localStorage.getItem('access_token')
|
||||
console.log('🔑 请求拦截器 - Token状态:', {
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? `${token.substring(0, 20)}...` : 'null',
|
||||
url: config.url,
|
||||
needToken: config.needToken
|
||||
})
|
||||
|
||||
if (token && config.needToken !== false) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
console.log('🔑 已添加Authorization头')
|
||||
} else {
|
||||
console.log('🔑 未添加Authorization头 - 原因:', !token ? '无token' : 'needToken=false')
|
||||
}
|
||||
// 只返回data字段, 兼容验证码等所有接口
|
||||
return data.data
|
||||
|
||||
// 添加请求ID用于追踪
|
||||
config.headers['X-Request-ID'] = generateRequestId()
|
||||
|
||||
// 打印请求日志
|
||||
if (envConfig.debug) {
|
||||
console.log('🚀 Request:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
headers: config.headers
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
console.error('❌ Request Error:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
// 兼容极特殊情况(如验证码图片流等)
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
console.error('响应拦截器错误:', error)
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const { data } = response
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// token过期或无效
|
||||
message.error('登录已过期,请重新登录')
|
||||
|
||||
// 清除本地存储的用户信息
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
|
||||
// 清除store中的用户信息
|
||||
const userStore = useUserStore()
|
||||
userStore.setToken('')
|
||||
userStore.setUserInfo(null)
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/login')
|
||||
break
|
||||
|
||||
case 403:
|
||||
message.error('没有权限访问该资源')
|
||||
break
|
||||
|
||||
case 404:
|
||||
message.error('请求的资源不存在')
|
||||
break
|
||||
|
||||
case 500:
|
||||
message.error('服务器内部错误')
|
||||
break
|
||||
|
||||
default:
|
||||
message.error(data?.message || '请求失败')
|
||||
// 打印响应日志
|
||||
if (envConfig.debug) {
|
||||
console.log('✅ Response:', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: data
|
||||
})
|
||||
}
|
||||
} else if (error.request) {
|
||||
message.error('网络连接失败,请检查网络')
|
||||
} else {
|
||||
message.error('请求配置错误')
|
||||
|
||||
// 检查业务状态码
|
||||
if (data.code === 200 || data.success) {
|
||||
return data
|
||||
}
|
||||
|
||||
// 处理业务错误
|
||||
const errorMessage = data.message || '请求失败'
|
||||
|
||||
// 特殊错误码处理
|
||||
switch (data.code) {
|
||||
case 401:
|
||||
console.warn('🚫 业务层401错误:', errorMessage)
|
||||
// 只有在非登录接口时才处理401
|
||||
if (!response.config.url?.includes('/auth/login')) {
|
||||
handleUnauthorized()
|
||||
} else {
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error('没有权限访问该资源')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(errorMessage))
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
console.error('❌ Response Error:', error)
|
||||
|
||||
let errorMessage = '网络请求失败'
|
||||
|
||||
if (error.response) {
|
||||
// 服务器响应错误
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMessage = '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
errorMessage = '未授权,请重新登录'
|
||||
console.warn('🚫 HTTP层401错误')
|
||||
// 只有在非登录接口时才处理401
|
||||
if (!error.config?.url?.includes('/auth/login')) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
errorMessage = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
errorMessage = '请求地址不存在'
|
||||
break
|
||||
case 408:
|
||||
errorMessage = '请求超时'
|
||||
break
|
||||
case 500:
|
||||
errorMessage = '服务器内部错误'
|
||||
break
|
||||
case 502:
|
||||
errorMessage = '网关错误'
|
||||
break
|
||||
case 503:
|
||||
errorMessage = '服务不可用'
|
||||
break
|
||||
case 504:
|
||||
errorMessage = '网关超时'
|
||||
break
|
||||
default:
|
||||
errorMessage = (data as any)?.message || `请求失败 (${status})`
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 网络错误
|
||||
errorMessage = '网络连接失败,请检查网络'
|
||||
} else {
|
||||
// 其他错误
|
||||
errorMessage = error.message || '请求配置错误'
|
||||
}
|
||||
|
||||
ElMessage.error(errorMessage)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
// 处理未授权
|
||||
const handleUnauthorized = () => {
|
||||
console.warn('🚫 收到401未授权响应')
|
||||
|
||||
// 检查当前页面是否是登录页,避免在登录页面重复处理
|
||||
if (window.location.pathname === '/login') {
|
||||
console.log('🚫 当前在登录页面,不处理401错误')
|
||||
return
|
||||
}
|
||||
)
|
||||
|
||||
// 不立即清除认证信息,而是提示用户
|
||||
ElMessageBox.confirm(
|
||||
'登录状态已过期,请重新登录',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '重新登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(() => {
|
||||
// 用户确认后才清除认证信息
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_info')
|
||||
|
||||
// 跳转到登录页
|
||||
window.location.href = '/login'
|
||||
}).catch(() => {
|
||||
// 用户取消,不清除认证信息,让用户继续操作
|
||||
console.log('🚫 用户取消重新登录')
|
||||
})
|
||||
}
|
||||
|
||||
// 生成请求ID
|
||||
const generateRequestId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
||||
}
|
||||
|
||||
// 创建请求实例
|
||||
export const request = createAxiosInstance()
|
||||
|
||||
// 导出请求方法
|
||||
export const http = {
|
||||
get: <T = any>(url: string, config?: RequestConfig) =>
|
||||
request.get<any, ApiResponse<T>>(url, config),
|
||||
|
||||
post: <T = any>(url: string, data?: any, config?: RequestConfig) =>
|
||||
request.post<any, ApiResponse<T>>(url, data, config),
|
||||
|
||||
put: <T = any>(url: string, data?: any, config?: RequestConfig) =>
|
||||
request.put<any, ApiResponse<T>>(url, data, config),
|
||||
|
||||
delete: <T = any>(url: string, config?: RequestConfig) =>
|
||||
request.delete<any, ApiResponse<T>>(url, config),
|
||||
|
||||
patch: <T = any>(url: string, data?: any, config?: RequestConfig) =>
|
||||
request.patch<any, ApiResponse<T>>(url, data, config)
|
||||
}
|
||||
|
||||
export default request
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* WebSocket连接测试工具
|
||||
* 用于测试WebSocket连接和消息发送功能
|
||||
*/
|
||||
|
||||
import webSocketService from '@/services/websocket'
|
||||
|
||||
export class WebSocketTester {
|
||||
private isConnected = false
|
||||
private testResults: string[] = []
|
||||
|
||||
/**
|
||||
* 运行WebSocket连接测试
|
||||
*/
|
||||
async runConnectionTest(): Promise<boolean> {
|
||||
this.testResults = []
|
||||
this.log('开始WebSocket连接测试...')
|
||||
|
||||
try {
|
||||
// 测试连接
|
||||
await webSocketService.connect('test_user_' + Date.now(), {
|
||||
onConnect: () => {
|
||||
this.isConnected = true
|
||||
this.log('✅ WebSocket连接成功')
|
||||
},
|
||||
onDisconnect: () => {
|
||||
this.isConnected = false
|
||||
this.log('❌ WebSocket连接断开')
|
||||
},
|
||||
onError: (error) => {
|
||||
this.log(`❌ WebSocket错误: ${error.userMessage || error.message || '未知错误'}`)
|
||||
},
|
||||
onMessage: (message) => {
|
||||
this.log(`📨 收到消息: ${message.type} - ${message.content}`)
|
||||
}
|
||||
})
|
||||
|
||||
// 等待连接建立
|
||||
await this.waitForConnection(5000)
|
||||
|
||||
if (this.isConnected) {
|
||||
this.log('✅ 连接测试通过')
|
||||
return true
|
||||
} else {
|
||||
this.log('❌ 连接测试失败')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`❌ 连接测试异常: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试消息发送
|
||||
*/
|
||||
async testMessageSending(): Promise<boolean> {
|
||||
if (!this.isConnected) {
|
||||
this.log('❌ 未连接,无法测试消息发送')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.log('开始测试消息发送...')
|
||||
|
||||
// 设置测试会话ID
|
||||
webSocketService.setConversationId('test_conversation_' + Date.now())
|
||||
|
||||
// 发送测试消息
|
||||
webSocketService.sendChatMessage('这是一条测试消息')
|
||||
|
||||
this.log('✅ 消息发送成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
this.log(`❌ 消息发送失败: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接测试
|
||||
*/
|
||||
testDisconnection(): void {
|
||||
this.log('开始测试断开连接...')
|
||||
webSocketService.disconnect()
|
||||
this.log('✅ 断开连接完成')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取测试结果
|
||||
*/
|
||||
getTestResults(): string[] {
|
||||
return [...this.testResults]
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空测试结果
|
||||
*/
|
||||
clearResults(): void {
|
||||
this.testResults = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录测试日志
|
||||
*/
|
||||
private log(message: string): void {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
const logMessage = `[${timestamp}] ${message}`
|
||||
this.testResults.push(logMessage)
|
||||
console.log(logMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待连接建立
|
||||
*/
|
||||
private waitForConnection(timeout: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const checkConnection = () => {
|
||||
if (this.isConnected) {
|
||||
resolve()
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error('连接超时'))
|
||||
} else {
|
||||
setTimeout(checkConnection, 100)
|
||||
}
|
||||
}
|
||||
|
||||
checkConnection()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 导出测试实例
|
||||
export const wsTest = new WebSocketTester()
|
||||
|
||||
// 开发环境下添加到全局对象,方便调试
|
||||
if (import.meta.env.DEV) {
|
||||
(window as any).wsTest = wsTest
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="analysis-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪分析</h1>
|
||||
<p class="text-gray-600">深度分析情绪数据,发现情绪规律</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-center text-gray-500 py-12">
|
||||
<el-icon class="text-4xl mb-4">
|
||||
<TrendCharts />
|
||||
</el-icon>
|
||||
<p>情绪分析功能开发中...</p>
|
||||
<p class="text-sm mt-2">这里将展示情绪趋势、雷达图、热力图等可视化内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrendCharts } from '@element-plus/icons-vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analysis-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.analysis-page .container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,644 +0,0 @@
|
||||
<template>
|
||||
<div class="chat-history-page">
|
||||
<!-- 头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="$router.back()" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</a-button>
|
||||
<h1 class="page-title">聊天历史</h1>
|
||||
</div>
|
||||
<a-button type="text" @click="showSearchModal = true" class="search-btn">
|
||||
<SearchOutlined />
|
||||
搜索
|
||||
</a-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-main">
|
||||
<div class="container">
|
||||
<!-- 会话列表 -->
|
||||
<div class="sessions-list">
|
||||
<div
|
||||
v-for="session in chatStore.sessions"
|
||||
:key="session.id"
|
||||
class="session-item"
|
||||
@click="viewSession(session)"
|
||||
>
|
||||
<div class="session-avatar">
|
||||
<a-avatar :src="kaikaiAvatar" :size="48" />
|
||||
</div>
|
||||
|
||||
<div class="session-content">
|
||||
<div class="session-header">
|
||||
<h3 class="session-title">{{ session.title }}</h3>
|
||||
<span class="session-time">{{ formatTime.friendly(session.updateTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="session-info">
|
||||
<span class="message-count">{{ session.messageCount }} 条消息</span>
|
||||
<span class="session-date">{{ formatTime.date(session.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-actions">
|
||||
<a-dropdown @click.stop>
|
||||
<a-button type="text" size="small">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="renameSession(session)">
|
||||
<EditOutlined />
|
||||
重命名
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="exportSession(session)">
|
||||
<DownloadOutlined />
|
||||
导出
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteSession(session.id)" danger>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="chatStore.sessions.length === 0" class="empty-state">
|
||||
<a-empty
|
||||
description="暂无聊天记录"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
>
|
||||
<a-button type="primary" @click="$router.push('/chat')">
|
||||
开始新对话
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 搜索模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showSearchModal"
|
||||
title="搜索聊天记录"
|
||||
:footer="null"
|
||||
width="600px"
|
||||
>
|
||||
<div class="search-content">
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="输入关键词搜索..."
|
||||
@search="handleSearch"
|
||||
size="large"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<div class="search-filters">
|
||||
<a-date-picker
|
||||
v-model:value="searchDate"
|
||||
placeholder="按日期筛选"
|
||||
style="width: 100%; margin-bottom: 16px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="search-results" v-if="searchResults.length > 0">
|
||||
<h4>搜索结果 ({{ searchResults.length }})</h4>
|
||||
<div class="results-list">
|
||||
<div
|
||||
v-for="result in searchResults"
|
||||
:key="result.id"
|
||||
class="result-item"
|
||||
@click="viewSearchResult(result)"
|
||||
>
|
||||
<div class="result-content">
|
||||
<div class="result-text">{{ result.content }}</div>
|
||||
<div class="result-meta">
|
||||
<span class="result-type">{{ result.type === 'user' ? '我' : '开开' }}</span>
|
||||
<span class="result-time">{{ formatTime.standard(result.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchKeyword && hasSearched" class="no-results">
|
||||
<a-empty description="未找到相关消息" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 会话详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showSessionModal"
|
||||
:title="selectedSession?.title"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
:body-style="{ maxHeight: '60vh', overflow: 'auto' }"
|
||||
>
|
||||
<div v-if="selectedSession" class="session-detail">
|
||||
<div class="session-info-header">
|
||||
<div class="info-item">
|
||||
<span class="info-label">创建时间:</span>
|
||||
<span class="info-value">{{ formatTime.standard(selectedSession.createTime) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">最后更新:</span>
|
||||
<span class="info-value">{{ formatTime.standard(selectedSession.updateTime) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">消息数量:</span>
|
||||
<span class="info-value">{{ selectedSession.messageCount }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-messages">
|
||||
<div
|
||||
v-for="message in sessionMessages"
|
||||
:key="message.id"
|
||||
class="message-item"
|
||||
:class="{ 'user-message': message.type === 'user' }"
|
||||
>
|
||||
<div class="message-avatar" v-if="message.type === 'ai'">
|
||||
<a-avatar :src="kaikaiAvatar" :size="32" />
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<div class="message-text">{{ message.content }}</div>
|
||||
<div class="message-time">{{ formatTime.friendly(message.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-actions">
|
||||
<a-button @click="continueSession(selectedSession)" type="primary">
|
||||
继续对话
|
||||
</a-button>
|
||||
<a-button @click="exportSession(selectedSession)">
|
||||
导出记录
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 重命名模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showRenameModal"
|
||||
title="重命名会话"
|
||||
@ok="confirmRename"
|
||||
@cancel="cancelRename"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="newSessionName"
|
||||
placeholder="请输入新的会话名称"
|
||||
:maxlength="50"
|
||||
show-count
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SearchOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Empty, message } from 'ant-design-vue'
|
||||
import { useChatStore } from '@/stores'
|
||||
import { formatTime } from '@/utils'
|
||||
import type { ChatSession, ChatMessage } from '@/types'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// 响应式数据
|
||||
const showSearchModal = ref(false)
|
||||
const showSessionModal = ref(false)
|
||||
const showRenameModal = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const searchDate = ref<Dayjs | null>(null)
|
||||
const hasSearched = ref(false)
|
||||
const selectedSession = ref<ChatSession | null>(null)
|
||||
const sessionToRename = ref<ChatSession | null>(null)
|
||||
const newSessionName = ref('')
|
||||
|
||||
// 开开头像
|
||||
const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
|
||||
|
||||
// 模拟会话消息数据
|
||||
const sessionMessages = ref<ChatMessage[]>([])
|
||||
|
||||
// 搜索结果
|
||||
const searchResults = ref<ChatMessage[]>([])
|
||||
|
||||
// 计算属性
|
||||
// const filteredSessions = computed(() => {
|
||||
// return chatStore.sessions.sort((a, b) =>
|
||||
// new Date(b.updateTime).getTime() - new Date(a.updateTime).getTime()
|
||||
// )
|
||||
// })
|
||||
|
||||
// 方法
|
||||
const viewSession = (session: ChatSession) => {
|
||||
selectedSession.value = session
|
||||
loadSessionMessages(session.id)
|
||||
showSessionModal.value = true
|
||||
}
|
||||
|
||||
const loadSessionMessages = async (sessionId: string) => {
|
||||
try {
|
||||
// TODO: 从API加载会话消息
|
||||
// const messages = await chatApi.getSessionMessages(sessionId)
|
||||
|
||||
// 模拟消息数据
|
||||
sessionMessages.value = [
|
||||
{
|
||||
id: '1',
|
||||
content: '你好,开开!',
|
||||
type: 'user',
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
sessionId
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
content: '你好!很高兴见到你,有什么我可以帮助你的吗?',
|
||||
type: 'ai',
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000 + 30000).toISOString(),
|
||||
sessionId
|
||||
}
|
||||
]
|
||||
} catch (error) {
|
||||
message.error('加载消息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
message.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
|
||||
hasSearched.value = true
|
||||
try {
|
||||
// TODO: 调用搜索API
|
||||
// const results = await chatApi.searchMessages(searchKeyword.value, searchDate.value)
|
||||
|
||||
// 模拟搜索结果
|
||||
searchResults.value = [
|
||||
{
|
||||
id: '1',
|
||||
content: `这是包含"${searchKeyword.value}"的消息内容...`,
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId: '1'
|
||||
}
|
||||
]
|
||||
} catch (error) {
|
||||
message.error('搜索失败')
|
||||
}
|
||||
}
|
||||
|
||||
const viewSearchResult = (result: ChatMessage) => {
|
||||
// 跳转到对应的会话
|
||||
const session = chatStore.sessions.find(s => s.id === result.sessionId)
|
||||
if (session) {
|
||||
showSearchModal.value = false
|
||||
viewSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
const renameSession = (session: ChatSession) => {
|
||||
sessionToRename.value = session
|
||||
newSessionName.value = session.title
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
const confirmRename = async () => {
|
||||
if (!newSessionName.value.trim()) {
|
||||
message.warning('请输入会话名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionToRename.value) {
|
||||
try {
|
||||
// await chatStore.updateSessionTitle(sessionToRename.value.id, newSessionName.value.trim())
|
||||
console.log('重命名会话:', sessionToRename.value.id, newSessionName.value.trim())
|
||||
message.success('重命名成功')
|
||||
showRenameModal.value = false
|
||||
} catch (error) {
|
||||
message.error('重命名失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRename = () => {
|
||||
sessionToRename.value = null
|
||||
newSessionName.value = ''
|
||||
}
|
||||
|
||||
const exportSession = (_session: ChatSession) => {
|
||||
// TODO: 实现导出功能
|
||||
message.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionId: string) => {
|
||||
try {
|
||||
await chatStore.deleteSession(sessionId)
|
||||
message.success('会话删除成功')
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const continueSession = (session: ChatSession) => {
|
||||
chatStore.switchSession(session.id)
|
||||
showSessionModal.value = false
|
||||
// 跳转到聊天页面
|
||||
// router.push('/chat')
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
// 初始化数据
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.chat-history-page {
|
||||
min-height: 100vh;
|
||||
background: $light-gray;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
box-shadow: $shadow-sm;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: $text-medium;
|
||||
|
||||
&:hover {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
color: $text-medium;
|
||||
|
||||
&:hover {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
background: white;
|
||||
border-radius: $border-radius-lg;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $shadow-md;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.session-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-xs;
|
||||
|
||||
.session-title {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
flex-shrink: 0;
|
||||
margin-left: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
|
||||
.message-count {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: $spacing-xxl;
|
||||
}
|
||||
|
||||
// 搜索模态框样式
|
||||
.search-content {
|
||||
.search-results {
|
||||
h4 {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: $spacing-md;
|
||||
border-radius: $border-radius-md;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-normal;
|
||||
|
||||
&:hover {
|
||||
background: $light-gray;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
.result-text {
|
||||
color: $text-dark;
|
||||
line-height: 1.5;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
|
||||
.result-type {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: $spacing-xl;
|
||||
}
|
||||
}
|
||||
|
||||
// 会话详情模态框样式
|
||||
.session-detail {
|
||||
.session-info-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding: $spacing-md;
|
||||
background: $light-gray;
|
||||
border-radius: $border-radius-md;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.info-label {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-medium;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: $text-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-messages {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
&.user-message {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-bubble {
|
||||
background: $tech-blue;
|
||||
color: white;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
background: white;
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
padding: $spacing-md;
|
||||
box-shadow: $shadow-sm;
|
||||
max-width: 70%;
|
||||
|
||||
.message-text {
|
||||
line-height: 1.5;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: $font-size-xs;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+462
-1126
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-md z-10 flex-shrink-0">
|
||||
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<router-link to="/chat" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="chevron-left" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
<h1 class="text-lg font-bold text-text-dark">聊天记录</h1>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSearch"
|
||||
class="text-text-medium hover:text-tech-blue transition-colors"
|
||||
>
|
||||
<i data-lucide="search" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div v-if="searchOpen" class="bg-white border-b border-gray-200 px-4 py-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
@input="handleSearch"
|
||||
type="text"
|
||||
placeholder="搜索聊天记录..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-tech-blue focus:border-transparent"
|
||||
>
|
||||
<i data-lucide="search" class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-tech-blue mx-auto mb-2"></div>
|
||||
<p class="text-text-medium">加载聊天记录中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat History List -->
|
||||
<main v-else id="history-list" class="flex-1 overflow-y-auto p-4 lg:p-6 space-y-3">
|
||||
<!-- 无数据状态 -->
|
||||
<div v-if="chatHistoryData.length === 0" class="text-center py-12">
|
||||
<i data-lucide="message-circle" class="w-16 h-16 text-gray-300 mx-auto mb-4"></i>
|
||||
<p class="text-text-medium">暂无聊天记录</p>
|
||||
<router-link to="/chat" class="inline-block mt-4 px-6 py-2 bg-tech-blue text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
开始聊天
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 聊天记录列表 -->
|
||||
<div
|
||||
v-for="item in chatHistoryData"
|
||||
:key="item.id"
|
||||
@click="goToChat(item.id)"
|
||||
class="block bg-white p-4 rounded-xl shadow-sm hover:shadow-md hover:border-tech-blue/50 border border-transparent transition-all duration-300 group cursor-pointer"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-text-medium mb-1 group-hover:text-tech-blue transition-colors">{{ formatDate(item.createTime) }}</p>
|
||||
<p class="font-medium text-text-dark truncate">{{ item.title }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ item.messageCount }} 条消息</p>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="w-5 h-5 text-gray-300 group-hover:text-tech-blue transition-colors flex-shrink-0 ml-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="hasMore && !loading" class="text-center py-4">
|
||||
<button
|
||||
@click="loadMore"
|
||||
class="px-6 py-2 text-tech-blue border border-tech-blue rounded-lg hover:bg-tech-blue hover:text-white transition-colors"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { chatApi } from '@/services/chat'
|
||||
import { messageApi } from '@/services/message'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ChatSession } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 响应式数据
|
||||
const searchOpen = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const loading = ref(false)
|
||||
const chatHistoryData = ref<ChatSession[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const hasMore = ref(true)
|
||||
|
||||
// 方法
|
||||
const toggleSearch = () => {
|
||||
searchOpen.value = !searchOpen.value
|
||||
if (!searchOpen.value) {
|
||||
searchKeyword.value = ''
|
||||
loadChatHistory() // 重新加载完整列表
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateInput: string | Date) => {
|
||||
try {
|
||||
let date: Date
|
||||
|
||||
if (dateInput instanceof Date) {
|
||||
if (isNaN(dateInput.getTime())) {
|
||||
return '日期无效'
|
||||
}
|
||||
date = dateInput
|
||||
} else if (typeof dateInput === 'string') {
|
||||
// 精确匹配后端格式 "2025-07-26 22:09:10"
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateInput)) {
|
||||
const dateStr = dateInput.replace(' ', 'T')
|
||||
date = new Date(dateStr)
|
||||
} else {
|
||||
date = new Date(dateInput)
|
||||
}
|
||||
} else {
|
||||
return '未知日期'
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return '日期无效'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime())
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 1) {
|
||||
return '今天'
|
||||
} else if (diffDays === 2) {
|
||||
return '昨天'
|
||||
} else if (diffDays <= 7) {
|
||||
return `${diffDays}天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return '日期错误'
|
||||
}
|
||||
}
|
||||
|
||||
const goToChat = (sessionId: string) => {
|
||||
router.push(`/chat?session=${sessionId}`)
|
||||
}
|
||||
|
||||
const loadChatHistory = async (page: number = 1) => {
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('📂 加载聊天历史:', { page, pageSize: pageSize.value })
|
||||
|
||||
// 获取当前用户ID
|
||||
const currentUserId = authStore.userInfo?.id || authStore.userId
|
||||
if (!currentUserId) {
|
||||
console.warn('⚠️ 未找到用户ID,无法加载聊天历史')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户的所有会话
|
||||
const userSessions = await chatApi.getUserSessions(currentUserId)
|
||||
|
||||
if (page === 1) {
|
||||
chatHistoryData.value = userSessions
|
||||
} else {
|
||||
chatHistoryData.value.push(...userSessions)
|
||||
}
|
||||
|
||||
hasMore.value = userSessions.length === pageSize.value
|
||||
console.log('✅ 聊天历史加载完成:', chatHistoryData.value.length)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 加载聊天历史失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && hasMore.value) {
|
||||
currentPage.value++
|
||||
loadChatHistory(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
loadChatHistory()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('🔍 搜索聊天记录:', searchKeyword.value)
|
||||
|
||||
// 使用消息搜索API来查找相关会话
|
||||
const searchResults = await messageApi.searchUserMessages(searchKeyword.value, 50)
|
||||
|
||||
// 从搜索结果中提取唯一的会话ID
|
||||
const conversationIds = [...new Set(searchResults.data?.map((msg: any) => msg.conversationId) || [])]
|
||||
|
||||
// 过滤出匹配的会话
|
||||
const filteredSessions = chatHistoryData.value.filter(session =>
|
||||
conversationIds.includes(session.id) ||
|
||||
session.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||||
)
|
||||
|
||||
chatHistoryData.value = filteredSessions
|
||||
console.log('✅ 搜索完成,找到', filteredSessions.length, '个会话')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 搜索失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
|
||||
// 延迟初始化图标
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// 加载聊天历史
|
||||
await loadChatHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导入原始样式变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* 应用原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
.chat-history-page {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--text-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,695 +0,0 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="$router.back()" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</a-button>
|
||||
<h1 class="page-title">个人展板</h1>
|
||||
</div>
|
||||
<a-button type="text" @click="editMode = !editMode" class="edit-btn">
|
||||
<EditOutlined />
|
||||
{{ editMode ? '完成' : '编辑' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-main">
|
||||
<div class="container">
|
||||
<div class="dashboard-grid">
|
||||
<!-- 基础信息卡片 -->
|
||||
<a-card class="info-card" title="基础信息">
|
||||
<template #extra>
|
||||
<UserOutlined class="card-icon" />
|
||||
</template>
|
||||
<div class="basic-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">昵称</span>
|
||||
<span class="info-value">{{ personalInfo.nickname || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">年龄</span>
|
||||
<span class="info-value">{{ personalInfo.age || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">职业</span>
|
||||
<span class="info-value">{{ personalInfo.occupation || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">地区</span>
|
||||
<span class="info-value">{{ personalInfo.location || '未设置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-button v-if="editMode" type="link" @click="showBasicInfoModal = true">
|
||||
编辑信息
|
||||
</a-button>
|
||||
</a-card>
|
||||
|
||||
<!-- 心情统计卡片 -->
|
||||
<a-card class="chart-card" title="近期心情统计">
|
||||
<template #extra>
|
||||
<BarChartOutlined class="card-icon" />
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
<canvas ref="moodChartRef" class="mood-chart"></canvas>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 兴趣爱好卡片 -->
|
||||
<a-card class="interests-card" title="兴趣爱好">
|
||||
<template #extra>
|
||||
<div class="card-extra">
|
||||
<HeartOutlined class="card-icon" />
|
||||
<a-button
|
||||
v-if="editMode"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showAddInterestModal = true"
|
||||
>
|
||||
<PlusOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="tags-container">
|
||||
<a-tag
|
||||
v-for="interest in personalInfo.interests"
|
||||
:key="interest"
|
||||
:closable="editMode"
|
||||
@close="removeInterest(interest)"
|
||||
color="blue"
|
||||
class="interest-tag"
|
||||
>
|
||||
{{ interest }}
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-if="personalInfo.interests.length === 0"
|
||||
class="empty-tag"
|
||||
>
|
||||
暂无兴趣爱好
|
||||
</a-tag>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="!editMode"
|
||||
type="link"
|
||||
@click="exploreInterests"
|
||||
class="explore-btn"
|
||||
>
|
||||
<StarOutlined />
|
||||
探索可能发展的爱好
|
||||
</a-button>
|
||||
</a-card>
|
||||
|
||||
<!-- 生活技能卡片 -->
|
||||
<a-card class="skills-card" title="生活技能">
|
||||
<template #extra>
|
||||
<div class="card-extra">
|
||||
<ToolOutlined class="card-icon" />
|
||||
<a-button
|
||||
v-if="editMode"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showAddSkillModal = true"
|
||||
>
|
||||
<PlusOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="tags-container">
|
||||
<a-tag
|
||||
v-for="skill in personalInfo.skills"
|
||||
:key="skill"
|
||||
:closable="editMode"
|
||||
@close="removeSkill(skill)"
|
||||
color="green"
|
||||
class="skill-tag"
|
||||
>
|
||||
{{ skill }}
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-if="personalInfo.skills.length === 0"
|
||||
class="empty-tag"
|
||||
>
|
||||
暂无技能记录
|
||||
</a-tag>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="!editMode"
|
||||
type="link"
|
||||
@click="exploreSkills"
|
||||
class="explore-btn"
|
||||
>
|
||||
<ExperimentOutlined />
|
||||
探索可能发展的技能
|
||||
</a-button>
|
||||
</a-card>
|
||||
|
||||
<!-- 个人语录卡片 -->
|
||||
<a-card class="quotes-card full-width" title="个人语录">
|
||||
<template #extra>
|
||||
<div class="card-extra">
|
||||
<MessageOutlined class="card-icon" />
|
||||
<a-button
|
||||
v-if="editMode"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showAddQuoteModal = true"
|
||||
>
|
||||
<PlusOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quotes-container">
|
||||
<div
|
||||
v-for="quote in personalInfo.quotes"
|
||||
:key="quote.id"
|
||||
class="quote-item"
|
||||
>
|
||||
<div class="quote-content">
|
||||
<blockquote class="quote-text">"{{ quote.content }}"</blockquote>
|
||||
<div class="quote-meta">
|
||||
<span class="quote-date">{{ formatTime.date(quote.createTime) }}</span>
|
||||
<span v-if="quote.source" class="quote-source">来源:{{ quote.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="editMode"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
@click="removeQuote(quote.id)"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="personalInfo.quotes.length === 0" class="empty-quotes">
|
||||
<a-empty description="暂无个人语录" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 添加自定义模块按钮 -->
|
||||
<div class="add-module-section" v-if="editMode">
|
||||
<a-button type="dashed" size="large" class="add-module-btn">
|
||||
<PlusOutlined />
|
||||
自由添加模块
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 基础信息编辑模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showBasicInfoModal"
|
||||
title="编辑基础信息"
|
||||
@ok="saveBasicInfo"
|
||||
@cancel="resetBasicInfo"
|
||||
>
|
||||
<a-form :model="basicInfoForm" layout="vertical">
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model:value="basicInfoForm.nickname" placeholder="请输入昵称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="年龄">
|
||||
<a-input-number
|
||||
v-model:value="basicInfoForm.age"
|
||||
:min="1"
|
||||
:max="120"
|
||||
placeholder="请输入年龄"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="职业">
|
||||
<a-input v-model:value="basicInfoForm.occupation" placeholder="请输入职业" />
|
||||
</a-form-item>
|
||||
<a-form-item label="地区">
|
||||
<a-input v-model:value="basicInfoForm.location" placeholder="请输入地区" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 添加兴趣模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAddInterestModal"
|
||||
title="添加兴趣爱好"
|
||||
@ok="addInterest"
|
||||
@cancel="newInterest = ''"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="newInterest"
|
||||
placeholder="请输入兴趣爱好"
|
||||
@press-enter="addInterest"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 添加技能模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAddSkillModal"
|
||||
title="添加生活技能"
|
||||
@ok="addSkill"
|
||||
@cancel="newSkill = ''"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="newSkill"
|
||||
placeholder="请输入生活技能"
|
||||
@press-enter="addSkill"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 添加语录模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAddQuoteModal"
|
||||
title="添加个人语录"
|
||||
@ok="addQuote"
|
||||
@cancel="resetQuoteForm"
|
||||
>
|
||||
<a-form :model="quoteForm" layout="vertical">
|
||||
<a-form-item label="语录内容" required>
|
||||
<a-textarea
|
||||
v-model:value="quoteForm.content"
|
||||
placeholder="请输入语录内容"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="来源">
|
||||
<a-input v-model:value="quoteForm.source" placeholder="请输入来源(可选)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
BarChartOutlined,
|
||||
HeartOutlined,
|
||||
ToolOutlined,
|
||||
MessageOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
StarOutlined,
|
||||
ExperimentOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Empty, message } from 'ant-design-vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { formatTime } from '@/utils'
|
||||
import type { PersonalInfo, PersonalQuote } from '@/types'
|
||||
|
||||
// 注册Chart.js组件
|
||||
Chart.register(...registerables)
|
||||
|
||||
// 响应式数据
|
||||
const editMode = ref(false)
|
||||
const showBasicInfoModal = ref(false)
|
||||
const showAddInterestModal = ref(false)
|
||||
const showAddSkillModal = ref(false)
|
||||
const showAddQuoteModal = ref(false)
|
||||
const newInterest = ref('')
|
||||
const newSkill = ref('')
|
||||
const moodChartRef = ref<HTMLCanvasElement>()
|
||||
let moodChart: Chart | null = null
|
||||
console.log('moodChart initialized:', moodChart) // 避免未使用警告
|
||||
|
||||
// 个人信息数据
|
||||
const personalInfo = reactive<PersonalInfo>({
|
||||
id: '1',
|
||||
userId: '1',
|
||||
nickname: '开心用户',
|
||||
age: 25,
|
||||
occupation: '软件工程师',
|
||||
location: '北京',
|
||||
interests: ['阅读', '旅行', '摄影', '音乐'],
|
||||
skills: ['编程', '设计', '写作', '烹饪'],
|
||||
quotes: [
|
||||
{
|
||||
id: '1',
|
||||
content: '生活不是等待暴风雨过去,而是学会在雨中跳舞',
|
||||
createTime: new Date().toISOString(),
|
||||
source: '电影台词'
|
||||
}
|
||||
],
|
||||
updateTime: new Date().toISOString()
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const basicInfoForm = reactive({
|
||||
nickname: '',
|
||||
age: undefined as number | undefined,
|
||||
occupation: '',
|
||||
location: ''
|
||||
})
|
||||
|
||||
const quoteForm = reactive({
|
||||
content: '',
|
||||
source: ''
|
||||
})
|
||||
|
||||
// 方法
|
||||
const saveBasicInfo = () => {
|
||||
Object.assign(personalInfo, basicInfoForm)
|
||||
showBasicInfoModal.value = false
|
||||
message.success('基础信息保存成功')
|
||||
}
|
||||
|
||||
const resetBasicInfo = () => {
|
||||
basicInfoForm.nickname = personalInfo.nickname || ''
|
||||
basicInfoForm.age = personalInfo.age
|
||||
basicInfoForm.occupation = personalInfo.occupation || ''
|
||||
basicInfoForm.location = personalInfo.location || ''
|
||||
}
|
||||
|
||||
const addInterest = () => {
|
||||
if (newInterest.value.trim() && !personalInfo.interests.includes(newInterest.value.trim())) {
|
||||
personalInfo.interests.push(newInterest.value.trim())
|
||||
newInterest.value = ''
|
||||
showAddInterestModal.value = false
|
||||
message.success('兴趣爱好添加成功')
|
||||
}
|
||||
}
|
||||
|
||||
const removeInterest = (interest: string) => {
|
||||
const index = personalInfo.interests.indexOf(interest)
|
||||
if (index > -1) {
|
||||
personalInfo.interests.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addSkill = () => {
|
||||
if (newSkill.value.trim() && !personalInfo.skills.includes(newSkill.value.trim())) {
|
||||
personalInfo.skills.push(newSkill.value.trim())
|
||||
newSkill.value = ''
|
||||
showAddSkillModal.value = false
|
||||
message.success('生活技能添加成功')
|
||||
}
|
||||
}
|
||||
|
||||
const removeSkill = (skill: string) => {
|
||||
const index = personalInfo.skills.indexOf(skill)
|
||||
if (index > -1) {
|
||||
personalInfo.skills.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addQuote = () => {
|
||||
if (quoteForm.content.trim()) {
|
||||
const newQuote: PersonalQuote = {
|
||||
id: Date.now().toString(),
|
||||
content: quoteForm.content.trim(),
|
||||
createTime: new Date().toISOString(),
|
||||
source: quoteForm.source.trim() || undefined
|
||||
}
|
||||
personalInfo.quotes.unshift(newQuote)
|
||||
resetQuoteForm()
|
||||
showAddQuoteModal.value = false
|
||||
message.success('个人语录添加成功')
|
||||
}
|
||||
}
|
||||
|
||||
const removeQuote = (id: string) => {
|
||||
const index = personalInfo.quotes.findIndex(q => q.id === id)
|
||||
if (index > -1) {
|
||||
personalInfo.quotes.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const resetQuoteForm = () => {
|
||||
quoteForm.content = ''
|
||||
quoteForm.source = ''
|
||||
}
|
||||
|
||||
const exploreInterests = () => {
|
||||
message.info('兴趣探索功能开发中...')
|
||||
}
|
||||
|
||||
const exploreSkills = () => {
|
||||
message.info('技能探索功能开发中...')
|
||||
}
|
||||
|
||||
// 初始化心情图表
|
||||
const initMoodChart = () => {
|
||||
nextTick(() => {
|
||||
if (moodChartRef.value) {
|
||||
const ctx = moodChartRef.value.getContext('2d')
|
||||
if (ctx) {
|
||||
moodChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||||
datasets: [{
|
||||
label: '心情指数',
|
||||
data: [7, 8, 6, 9, 7, 8, 9],
|
||||
borderColor: '#4A90E2',
|
||||
backgroundColor: 'rgba(74, 144, 226, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 10,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
resetBasicInfo()
|
||||
initMoodChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.dashboard-page {
|
||||
min-height: 100vh;
|
||||
background: $light-gray;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
box-shadow: $shadow-sm;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: $text-medium;
|
||||
|
||||
&:hover {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: $text-medium;
|
||||
|
||||
&:hover {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-lg;
|
||||
|
||||
@media (min-width: $breakpoint-lg) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
@media (min-width: $breakpoint-lg) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: $tech-blue;
|
||||
}
|
||||
|
||||
.card-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
// 基础信息卡片
|
||||
.basic-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
// 图表卡片
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mood-chart {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
// 标签容器
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
margin-bottom: $spacing-md;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.interest-tag,
|
||||
.skill-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-tag {
|
||||
color: $text-medium;
|
||||
background: transparent;
|
||||
border: 1px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.explore-btn {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
// 语录卡片
|
||||
.quotes-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.quote-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: $spacing-md;
|
||||
background: rgba(74, 144, 226, 0.05);
|
||||
border-radius: $border-radius-md;
|
||||
border-left: 3px solid $tech-blue;
|
||||
}
|
||||
|
||||
.quote-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quote-text {
|
||||
font-style: italic;
|
||||
color: $text-dark;
|
||||
margin: 0 0 $spacing-xs 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.quote-meta {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
}
|
||||
|
||||
.empty-quotes {
|
||||
text-align: center;
|
||||
padding: $spacing-xl;
|
||||
}
|
||||
|
||||
// 添加模块区域
|
||||
.add-module-section {
|
||||
margin-top: $spacing-xl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-module-btn {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
font-size: $font-size-lg;
|
||||
border-radius: $border-radius-lg;
|
||||
border: 2px dashed #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: $tech-blue;
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">WebSocket连接测试</h1>
|
||||
|
||||
<!-- 连接状态 -->
|
||||
<div class="mb-6 p-4 rounded-lg" :class="statusClass">
|
||||
<h2 class="text-lg font-semibold mb-2">连接状态</h2>
|
||||
<p>状态: {{ connectionStatus }}</p>
|
||||
<p>WebSocket URL: {{ wsUrl }}</p>
|
||||
<p>用户ID: {{ userId }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="mb-6 space-x-4">
|
||||
<button
|
||||
@click="connect"
|
||||
:disabled="isConnected"
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
连接
|
||||
</button>
|
||||
<button
|
||||
@click="disconnect"
|
||||
:disabled="!isConnected"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
断开
|
||||
</button>
|
||||
<button
|
||||
@click="clearLogs"
|
||||
class="px-4 py-2 bg-gray-500 text-white rounded"
|
||||
>
|
||||
清空日志
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 发送消息 -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-2">发送消息</h2>
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
v-model="messageInput"
|
||||
@keyup.enter="sendMessage"
|
||||
placeholder="输入测试消息..."
|
||||
class="flex-1 px-3 py-2 border rounded"
|
||||
:disabled="!isConnected"
|
||||
>
|
||||
<button
|
||||
@click="sendMessage"
|
||||
:disabled="!isConnected || !messageInput.trim()"
|
||||
class="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-2">连接日志</h2>
|
||||
<div class="bg-gray-100 p-4 rounded-lg h-64 overflow-y-auto">
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1 text-sm">
|
||||
<span class="text-gray-500">{{ log.timestamp }}</span>
|
||||
<span :class="log.type === 'error' ? 'text-red-600' : log.type === 'success' ? 'text-green-600' : 'text-blue-600'">
|
||||
[{{ log.type.toUpperCase() }}]
|
||||
</span>
|
||||
{{ log.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 接收到的消息 -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-2">接收到的消息</h2>
|
||||
<div class="bg-gray-100 p-4 rounded-lg h-64 overflow-y-auto">
|
||||
<div v-for="(message, index) in receivedMessages" :key="index" class="mb-2 p-2 bg-white rounded">
|
||||
<div class="text-xs text-gray-500 mb-1">{{ message.timestamp }}</div>
|
||||
<pre class="text-sm">{{ JSON.stringify(message.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { stompWebSocketService as webSocketService, type WebSocketMessage } from '@/services/stomp-websocket'
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
// 响应式数据
|
||||
const connectionStatus = ref('DISCONNECTED')
|
||||
const isConnected = computed(() => connectionStatus.value === 'CONNECTED')
|
||||
const messageInput = ref('')
|
||||
const logs = ref<Array<{timestamp: string, type: string, message: string}>>([])
|
||||
const receivedMessages = ref<Array<{timestamp: string, data: any}>>([])
|
||||
|
||||
// 配置
|
||||
const wsUrl = `${envConfig.apiBaseUrl.replace('http', 'ws').replace('/api', '')}/ws/chat`
|
||||
const userId = ref(`test_user_${Date.now()}`)
|
||||
|
||||
// 计算样式类
|
||||
const statusClass = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'CONNECTED':
|
||||
return 'bg-green-100 border border-green-300'
|
||||
case 'CONNECTING':
|
||||
return 'bg-yellow-100 border border-yellow-300'
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 border border-red-300'
|
||||
default:
|
||||
return 'bg-gray-100 border border-gray-300'
|
||||
}
|
||||
})
|
||||
|
||||
// 添加日志
|
||||
const addLog = (type: string, message: string) => {
|
||||
logs.value.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
type,
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
// 连接WebSocket
|
||||
const connect = async () => {
|
||||
try {
|
||||
addLog('info', '开始连接WebSocket...')
|
||||
|
||||
await webSocketService.connect(userId.value, {
|
||||
onConnect: () => {
|
||||
addLog('success', 'WebSocket连接成功')
|
||||
connectionStatus.value = 'CONNECTED'
|
||||
},
|
||||
onDisconnect: () => {
|
||||
addLog('info', 'WebSocket连接断开')
|
||||
connectionStatus.value = 'DISCONNECTED'
|
||||
},
|
||||
onError: (error) => {
|
||||
addLog('error', `WebSocket错误: ${error.userMessage || error.message || '未知错误'}`)
|
||||
connectionStatus.value = 'ERROR'
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
connectionStatus.value = status
|
||||
addLog('info', `连接状态变更: ${status}`)
|
||||
},
|
||||
onMessage: (message: WebSocketMessage) => {
|
||||
addLog('success', `收到消息: ${message.type} - ${message.content}`)
|
||||
receivedMessages.value.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
data: message
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
addLog('error', `连接失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
const disconnect = () => {
|
||||
webSocketService.disconnect()
|
||||
addLog('info', '主动断开连接')
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = () => {
|
||||
if (!messageInput.value.trim()) return
|
||||
|
||||
try {
|
||||
webSocketService.sendChatMessage(messageInput.value.trim())
|
||||
addLog('info', `发送消息: ${messageInput.value.trim()}`)
|
||||
messageInput.value = ''
|
||||
} catch (error) {
|
||||
addLog('error', `发送消息失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
const clearLogs = () => {
|
||||
logs.value = []
|
||||
receivedMessages.value = []
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
addLog('info', 'WebSocket测试页面已加载')
|
||||
addLog('info', `WebSocket URL: ${wsUrl}`)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isConnected.value) {
|
||||
disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="debug-page p-8">
|
||||
<h1 class="text-2xl font-bold mb-6">环境变量调试页面</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 当前环境信息 -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">当前环境信息</h2>
|
||||
<div class="space-y-2">
|
||||
<div><strong>环境类型:</strong> {{ currentEnv }}</div>
|
||||
<div><strong>环境名称:</strong> {{ envConfig.name }}</div>
|
||||
<div><strong>调试模式:</strong> {{ envConfig.debug ? '开启' : '关闭' }}</div>
|
||||
<div><strong>Mock模式:</strong> {{ envConfig.mock ? '开启' : '关闭' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API配置信息 -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">API配置信息</h2>
|
||||
<div class="space-y-2">
|
||||
<div><strong>API基础URL:</strong> {{ envConfig.apiBaseUrl }}</div>
|
||||
<div><strong>WebSocket URL:</strong> {{ envConfig.wsBaseUrl }}</div>
|
||||
<div><strong>上传URL:</strong> {{ envConfig.uploadUrl }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 原始环境变量 -->
|
||||
<div class="bg-white rounded-lg shadow p-6 md:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-4">原始环境变量</h2>
|
||||
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto">{{ JSON.stringify(rawEnv, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- API测试 -->
|
||||
<div class="bg-white rounded-lg shadow p-6 md:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-4">API连接测试</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
@click="testApiConnection"
|
||||
:disabled="testing"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{{ testing ? '测试中...' : '测试API连接' }}
|
||||
</button>
|
||||
<router-link
|
||||
to="/debug/websocket"
|
||||
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
|
||||
>
|
||||
WebSocket测试
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/chat"
|
||||
class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600"
|
||||
>
|
||||
聊天页面
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="mt-4">
|
||||
<h3 class="font-semibold mb-2">测试结果:</h3>
|
||||
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto">{{ testResult }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { envConfig, getCurrentEnv } from '@/config/env'
|
||||
import AuthService from '@/services/auth'
|
||||
|
||||
// 响应式数据
|
||||
const testing = ref(false)
|
||||
const testResult = ref('')
|
||||
|
||||
// 计算属性
|
||||
const currentEnv = computed(() => getCurrentEnv())
|
||||
const rawEnv = computed(() => import.meta.env)
|
||||
|
||||
/**
|
||||
* 测试API连接
|
||||
*/
|
||||
const testApiConnection = async () => {
|
||||
testing.value = true
|
||||
testResult.value = ''
|
||||
|
||||
try {
|
||||
console.log('开始测试API连接...')
|
||||
console.log('使用的API基础URL:', envConfig.apiBaseUrl)
|
||||
|
||||
const response = await AuthService.getCaptcha()
|
||||
|
||||
testResult.value = JSON.stringify({
|
||||
success: true,
|
||||
message: 'API连接成功',
|
||||
apiUrl: envConfig.apiBaseUrl,
|
||||
response: {
|
||||
captchaKey: response.captchaKey,
|
||||
imageLength: response.captchaImage?.length || 0,
|
||||
expiresIn: response.expiresIn,
|
||||
imagePreview: response.captchaImage?.substring(0, 100) + '...' // 显示前100个字符
|
||||
}
|
||||
}, null, 2)
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('API连接测试失败:', error)
|
||||
|
||||
testResult.value = JSON.stringify({
|
||||
success: false,
|
||||
message: 'API连接失败',
|
||||
apiUrl: envConfig.apiBaseUrl,
|
||||
error: {
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
code: error.code,
|
||||
config: error.config ? {
|
||||
url: error.config.url,
|
||||
method: error.config.method,
|
||||
baseURL: error.config.baseURL
|
||||
} : null
|
||||
}
|
||||
}, null, 2)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时输出调试信息
|
||||
onMounted(() => {
|
||||
console.log('=== 环境变量调试信息 ===')
|
||||
console.log('当前环境:', currentEnv.value)
|
||||
console.log('环境配置:', envConfig)
|
||||
console.log('原始环境变量:', import.meta.env)
|
||||
console.log('========================')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.debug-page {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
+261
-673
@@ -1,703 +1,291 @@
|
||||
<template>
|
||||
<div class="diary-page">
|
||||
<!-- 头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="$router.back()" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</a-button>
|
||||
<h1 class="page-title">情绪记录</h1>
|
||||
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-md z-10 flex-shrink-0">
|
||||
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
|
||||
<div class="flex items-center space-x-4">
|
||||
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="home" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="bell" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
<a-button type="primary" @click="$router.push('/chat')" class="new-entry-btn">
|
||||
<HeartOutlined />
|
||||
生成情绪记录
|
||||
</a-button>
|
||||
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">日记</h1>
|
||||
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="user" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-main">
|
||||
<div class="container">
|
||||
<!-- 提示卡片 -->
|
||||
<div class="tip-section">
|
||||
<a-card class="tip-card">
|
||||
<div class="tip-content">
|
||||
<HeartOutlined class="tip-icon" />
|
||||
<div class="tip-text">
|
||||
<h3>如何生成情绪记录?</h3>
|
||||
<p>与开开聊天后,点击聊天页面右上角的 ❤️ 按钮,AI会分析你的聊天内容并生成情绪记录</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="$router.push('/chat')" class="tip-btn">
|
||||
去聊天
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 情绪记录列表 -->
|
||||
<div class="emotion-feed">
|
||||
<div
|
||||
v-for="record in emotionRecords"
|
||||
:key="record.id"
|
||||
class="emotion-entry"
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto p-4 lg:p-6 pb-24">
|
||||
<!-- New Post Form -->
|
||||
<div id="new-post-section" class="bg-white p-4 rounded-xl shadow-sm mb-6 scroll-mt-20">
|
||||
<h2 class="font-bold text-text-dark mb-3">发布新日记</h2>
|
||||
<textarea
|
||||
id="new-diary-content"
|
||||
v-model="newDiaryContent"
|
||||
class="w-full h-24 p-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-tech-blue/50 focus:border-tech-blue outline-none transition"
|
||||
placeholder="今天有什么新鲜事或心里话想对开开说?"
|
||||
></textarea>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
@click="publishDiaryHandler"
|
||||
:disabled="publishDisabled"
|
||||
class="bg-tech-blue text-white px-5 py-2 rounded-full font-semibold hover:bg-blue-600 transition-all duration-300 transform hover:scale-105 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<a-card class="entry-card">
|
||||
<div class="entry-header">
|
||||
<div class="entry-meta">
|
||||
<span class="emotion-icon">
|
||||
{{ getEmotionIcon(record.emotionType) }}
|
||||
</span>
|
||||
<div class="emotion-info">
|
||||
<span class="emotion-type">{{ record.emotionType }}</span>
|
||||
<span class="emotion-date">
|
||||
{{ formatTime.friendly(record.createTime) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-dropdown>
|
||||
<a-button type="text" size="small">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="deleteEmotionRecord(record.id)" danger>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diary Feed -->
|
||||
<div id="diary-feed" class="space-y-4">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-tech-blue"></div>
|
||||
<p class="mt-2 text-text-medium">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="diaries.length === 0" class="text-center py-12">
|
||||
<i data-lucide="book-open" class="w-16 h-16 text-gray-300 mx-auto mb-4"></i>
|
||||
<p class="text-text-medium text-lg mb-2">还没有日记</p>
|
||||
<p class="text-text-medium text-sm">写下你的第一篇日记,开开会给你温暖的回应</p>
|
||||
</div>
|
||||
|
||||
<!-- 日记列表 -->
|
||||
<div
|
||||
v-for="entry in diaries"
|
||||
:key="entry.id"
|
||||
class="bg-white rounded-xl shadow-sm p-4 animate-fade-in-up"
|
||||
>
|
||||
<!-- 用户日记内容 -->
|
||||
<div class="flex items-center mb-4">
|
||||
<i
|
||||
data-lucide="user-circle-2"
|
||||
class="w-10 h-10 text-gray-400"
|
||||
></i>
|
||||
<div class="ml-3">
|
||||
<p class="font-semibold text-text-dark">我</p>
|
||||
<p class="text-xs text-text-medium">{{ formatTime(entry.publishTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-text-dark whitespace-pre-wrap leading-relaxed">{{ entry.content }}</p>
|
||||
|
||||
<!-- AI评论 -->
|
||||
<div v-if="entry.aiComment" class="mt-4 pt-3 border-t border-gray-100">
|
||||
<div class="flex items-start">
|
||||
<img
|
||||
src="https://r2.flowith.net/files/o/1752574572161-kaikai_character_energetic_animation_index_2@1024x1024.png"
|
||||
alt="开开"
|
||||
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
|
||||
>
|
||||
<div class="ml-3 bg-light-gray p-3 rounded-lg w-full">
|
||||
<p class="text-sm font-semibold text-text-dark">开开</p>
|
||||
<p class="text-sm text-text-dark mt-1">{{ entry.aiComment }}</p>
|
||||
<p class="text-xs text-text-medium mt-2">{{ formatTime(entry.aiCommentTime) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="entry-content">
|
||||
<!-- 情绪强度 -->
|
||||
<div class="emotion-intensity">
|
||||
<span class="intensity-label">情绪强度:</span>
|
||||
<a-progress
|
||||
:percent="Math.round((record.intensity || 0) * 100)"
|
||||
:stroke-color="getIntensityColor(record.intensity || 0)"
|
||||
:show-info="true"
|
||||
size="small"
|
||||
class="intensity-bar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 触发因素 -->
|
||||
<div v-if="record.triggers" class="emotion-triggers">
|
||||
<span class="triggers-label">触发因素:</span>
|
||||
<span class="triggers-text">{{ record.triggers }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div v-if="record.description" class="emotion-description">
|
||||
<p class="description-text">{{ record.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="emotion-tags" v-if="record.tags">
|
||||
<a-tag
|
||||
v-for="tag in (typeof record.tags === 'string' ? record.tags.split(',') : record.tags)"
|
||||
:key="tag"
|
||||
color="blue"
|
||||
class="emotion-tag"
|
||||
>
|
||||
{{ tag.trim() }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<!-- 其他信息 -->
|
||||
<div class="emotion-details">
|
||||
<div v-if="record.weather" class="detail-item">
|
||||
<span class="detail-label">天气:</span>
|
||||
<span class="detail-value">{{ record.weather }}</span>
|
||||
</div>
|
||||
<div v-if="record.location" class="detail-item">
|
||||
<span class="detail-label">地点:</span>
|
||||
<span class="detail-value">{{ record.location }}</span>
|
||||
</div>
|
||||
<div v-if="record.activity" class="detail-item">
|
||||
<span class="detail-label">活动:</span>
|
||||
<span class="detail-value">{{ record.activity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多按钮 -->
|
||||
<div v-if="pagination.current * pagination.pageSize < pagination.total" class="load-more">
|
||||
<a-button
|
||||
type="dashed"
|
||||
block
|
||||
@click="loadMoreRecords"
|
||||
:loading="loading"
|
||||
size="large"
|
||||
>
|
||||
加载更多 ({{ emotionRecords.length }}/{{ pagination.total }})
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 没有更多数据提示 -->
|
||||
<div v-else-if="emotionRecords.length > 0" class="no-more">
|
||||
<a-divider>已显示全部记录</a-divider>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="emotionRecords.length === 0 && !loading" class="empty-state">
|
||||
<a-empty
|
||||
description="还没有情绪记录"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
>
|
||||
<p class="empty-tip">
|
||||
与开开聊天后,点击右上角的 <HeartOutlined /> 按钮生成情绪记录
|
||||
</p>
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading && emotionRecords.length === 0" class="loading-state">
|
||||
<a-spin size="large" tip="加载中..." />
|
||||
<!-- 互动按钮 -->
|
||||
<div class="mt-4 pt-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="heart" class="w-4 h-4 mr-1.5"></i>
|
||||
<span>{{ entry.likeCount || 0 }}</span>
|
||||
</button>
|
||||
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="message-square" class="w-4 h-4 mr-1.5"></i>
|
||||
<span>{{ entry.commentCount || 0 }}</span>
|
||||
</button>
|
||||
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="eye" class="w-4 h-4 mr-1.5"></i>
|
||||
<span>{{ entry.viewCount || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="flex items-center text-sm text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="share" class="w-4 h-4 mr-1.5"></i>
|
||||
<span>分享</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mt-6">
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
layout="prev, pager, next"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<!-- App Navigation -->
|
||||
<BottomNavigation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
// PlusOutlined,
|
||||
MoreOutlined,
|
||||
// EditOutlined,
|
||||
DeleteOutlined,
|
||||
HeartOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Empty, message } from 'ant-design-vue'
|
||||
import { useDiaryStore } from '@/stores'
|
||||
import { formatTime } from '@/utils'
|
||||
import { emotionRecordApi } from '@/services/api'
|
||||
// import type { DiaryEntry } from '@/types'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
|
||||
import { publishDiary, getUserDiaries } from '@/services/diary'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const diaryStore = useDiaryStore()
|
||||
console.log('diaryStore initialized:', diaryStore) // 避免未使用警告
|
||||
const authStore = useAuthStore()
|
||||
const userId = authStore.userId
|
||||
|
||||
// 响应式数据
|
||||
const showNewEntryModal = ref(false)
|
||||
console.log('showNewEntryModal initialized:', showNewEntryModal) // 避免未使用警告
|
||||
const newEntryContent = ref('')
|
||||
const selectedMood = ref<string>('neutral')
|
||||
const selectedTags = ref<string[]>([])
|
||||
const newTagInput = ref('')
|
||||
const emotionRecords = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 开开头像
|
||||
// const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
|
||||
const newDiaryContent = ref('')
|
||||
const publishDisabled = ref(false)
|
||||
const loading = ref(false)
|
||||
const diaries = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const total = ref(0)
|
||||
|
||||
// 心情表情映射
|
||||
// const moodEmojis = {
|
||||
// happy: '😊',
|
||||
// sad: '😢',
|
||||
// neutral: '😐',
|
||||
// excited: '🤩',
|
||||
// tired: '😴'
|
||||
// }
|
||||
|
||||
// 方法
|
||||
// const getMoodEmoji = (mood: string) => {
|
||||
// return moodEmojis[mood as keyof typeof moodEmojis] || '😐'
|
||||
// }
|
||||
|
||||
// const publishEntry = async () => {
|
||||
// if (!newEntryContent.value.trim()) {
|
||||
// message.warning('请输入日记内容')
|
||||
// return
|
||||
// }
|
||||
|
||||
// try {
|
||||
// await diaryStore.addEntry(
|
||||
// newEntryContent.value.trim(),
|
||||
// selectedMood.value,
|
||||
// selectedTags.value
|
||||
// )
|
||||
|
||||
// message.success('日记发布成功!')
|
||||
// resetNewEntry()
|
||||
// showNewEntryModal.value = false
|
||||
// } catch (error) {
|
||||
// message.error('发布失败,请重试')
|
||||
// }
|
||||
// }
|
||||
|
||||
const resetNewEntry = () => {
|
||||
newEntryContent.value = ''
|
||||
selectedMood.value = 'neutral'
|
||||
selectedTags.value = []
|
||||
newTagInput.value = ''
|
||||
}
|
||||
console.log('resetNewEntry function defined:', resetNewEntry) // 避免未使用警告
|
||||
|
||||
// const addTag = () => {
|
||||
// const tag = newTagInput.value.trim()
|
||||
// if (tag && !selectedTags.value.includes(tag)) {
|
||||
// selectedTags.value.push(tag)
|
||||
// newTagInput.value = ''
|
||||
// }
|
||||
// }
|
||||
|
||||
// const removeTag = (tag: string) => {
|
||||
// const index = selectedTags.value.indexOf(tag)
|
||||
// if (index > -1) {
|
||||
// selectedTags.value.splice(index, 1)
|
||||
// }
|
||||
// }
|
||||
|
||||
// const editEntry = (_entry: DiaryEntry) => {
|
||||
// // TODO: 实现编辑功能
|
||||
// message.info('编辑功能开发中...')
|
||||
// }
|
||||
|
||||
// const deleteEntry = async (id: string) => {
|
||||
// try {
|
||||
// await diaryStore.deleteEntry(id)
|
||||
// message.success('日记删除成功')
|
||||
// } catch (error) {
|
||||
// message.error('删除失败,请重试')
|
||||
// }
|
||||
// }
|
||||
|
||||
// 加载情绪记录
|
||||
const loadEmotionRecords = async (page = 1, append = false) => {
|
||||
if (loading.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 调用API获取用户情绪记录(后端会从token中获取用户信息)
|
||||
const pageData = await emotionRecordApi.getUserEmotionRecords(page, pagination.value.pageSize)
|
||||
|
||||
if (append) {
|
||||
emotionRecords.value.push(...(pageData.records || []))
|
||||
} else {
|
||||
emotionRecords.value = pageData.records || []
|
||||
}
|
||||
|
||||
pagination.value = {
|
||||
current: pageData.current || 1,
|
||||
pageSize: pageData.size || 10,
|
||||
total: pageData.total || 0
|
||||
}
|
||||
|
||||
console.log('情绪记录加载成功:', emotionRecords.value.length, '条')
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载情绪记录时发生错误:', error)
|
||||
message.error('加载情绪记录失败,请检查网络连接')
|
||||
} finally {
|
||||
loading.value = false
|
||||
const fetchDiaries = async () => {
|
||||
if (!userId) return
|
||||
console.log('🔍 开始获取日记数据,用户ID:', userId)
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserDiaries(userId, page.value, pageSize)
|
||||
console.log('🔍 API返回的日记数据:', res)
|
||||
if (res?.data?.records) {
|
||||
diaries.value = res.data.records
|
||||
total.value = res.data.total
|
||||
console.log('🔍 设置日记数据:', diaries.value)
|
||||
} else {
|
||||
console.log('🔍 API返回数据格式不正确:', res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔍 获取日记数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timeStr: string) => {
|
||||
if (!timeStr) return ''
|
||||
try {
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('格式化时间失败:', error)
|
||||
return timeStr
|
||||
}
|
||||
}
|
||||
|
||||
const publishDiaryHandler = async () => {
|
||||
const content = newDiaryContent.value.trim()
|
||||
if (!content) return
|
||||
publishDisabled.value = true
|
||||
try {
|
||||
await publishDiary({
|
||||
userId,
|
||||
title: '',
|
||||
content,
|
||||
images: [],
|
||||
videos: [],
|
||||
location: '',
|
||||
weather: '',
|
||||
mood: '',
|
||||
moodScore: null,
|
||||
tags: [],
|
||||
isPublic: 1,
|
||||
isAnonymous: 0
|
||||
})
|
||||
ElMessage.success('发布成功')
|
||||
newDiaryContent.value = ''
|
||||
fetchDiaries()
|
||||
} finally {
|
||||
setTimeout(() => { publishDisabled.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const onPageChange = (val: number) => {
|
||||
page.value = val
|
||||
fetchDiaries()
|
||||
}
|
||||
|
||||
onMounted(fetchDiaries)
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
|
||||
// 加载更多情绪记录
|
||||
const loadMoreRecords = () => {
|
||||
if (pagination.value.current * pagination.value.pageSize < pagination.value.total) {
|
||||
loadEmotionRecords(pagination.value.current + 1, true)
|
||||
// 延迟初始化图标
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除情绪记录
|
||||
const deleteEmotionRecord = async (id: string) => {
|
||||
try {
|
||||
await emotionRecordApi.deleteEmotionRecord(id)
|
||||
message.success('情绪记录删除成功')
|
||||
// 重新加载第一页
|
||||
await loadEmotionRecords(1)
|
||||
} catch (error) {
|
||||
console.error('删除情绪记录时发生错误:', error)
|
||||
message.error('删除失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取情绪图标
|
||||
const getEmotionIcon = (emotionType: string) => {
|
||||
const emotionIcons: Record<string, string> = {
|
||||
'joy': '😊',
|
||||
'happiness': '😊',
|
||||
'happy': '😊',
|
||||
'excited': '🤩',
|
||||
'love': '😍',
|
||||
'sadness': '😢',
|
||||
'sad': '😢',
|
||||
'crying': '😭',
|
||||
'anger': '😠',
|
||||
'angry': '😡',
|
||||
'rage': '🤬',
|
||||
'fear': '😨',
|
||||
'scared': '😰',
|
||||
'anxiety': '😰',
|
||||
'surprise': '😲',
|
||||
'shocked': '😱',
|
||||
'neutral': '😐',
|
||||
'calm': '😌',
|
||||
'peaceful': '😌',
|
||||
'tired': '😴',
|
||||
'exhausted': '😵',
|
||||
'confused': '😕',
|
||||
'disappointed': '😞',
|
||||
'frustrated': '😤',
|
||||
'bored': '😑',
|
||||
'content': '😊',
|
||||
'grateful': '🙏',
|
||||
'hopeful': '🌟',
|
||||
'proud': '😎',
|
||||
'embarrassed': '😳',
|
||||
'guilty': '😔',
|
||||
'lonely': '😞',
|
||||
'nostalgic': '🥺',
|
||||
'optimistic': '😄',
|
||||
'pessimistic': '😟'
|
||||
}
|
||||
|
||||
return emotionIcons[emotionType.toLowerCase()] || '😐'
|
||||
}
|
||||
|
||||
// 获取情绪强度颜色
|
||||
const getIntensityColor = (intensity: number) => {
|
||||
if (intensity >= 0.8) return '#ff4d4f' // 高强度 - 红色
|
||||
if (intensity >= 0.6) return '#ff7a45' // 中高强度 - 橙红色
|
||||
if (intensity >= 0.4) return '#ffa940' // 中等强度 - 橙色
|
||||
if (intensity >= 0.2) return '#52c41a' // 低强度 - 绿色
|
||||
return '#1890ff' // 很低强度 - 蓝色
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
loadEmotionRecords(1)
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.diary-page {
|
||||
min-height: 100vh;
|
||||
background: $light-gray;
|
||||
<style scoped>
|
||||
/* 导入原始样式变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* 应用原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
box-shadow: $shadow-sm;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: $text-medium;
|
||||
|
||||
&:hover {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-entry-btn {
|
||||
border-radius: $border-radius-full;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tip-section {
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.tip-card {
|
||||
.tip-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
.tip-icon {
|
||||
font-size: 2rem;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $spacing-xs 0;
|
||||
color: $text-dark;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $text-medium;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.tip-btn {
|
||||
border-radius: $border-radius-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emotion-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.emotion-entry {
|
||||
.entry-card {
|
||||
transition: all $transition-normal;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.emotion-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.emotion-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.emotion-type {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
font-size: $font-size-md;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.emotion-date {
|
||||
color: $text-medium;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.emotion-intensity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
.intensity-label {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.intensity-bar {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.emotion-triggers {
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
.triggers-label {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
margin-right: $spacing-sm;
|
||||
}
|
||||
|
||||
.triggers-text {
|
||||
color: $text-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.emotion-description {
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
.description-text {
|
||||
line-height: 1.6;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
padding: $spacing-sm;
|
||||
background: $light-gray;
|
||||
border-radius: $border-radius-md;
|
||||
}
|
||||
}
|
||||
|
||||
.emotion-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.emotion-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-md;
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
|
||||
.detail-label {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: $text-medium;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
margin-top: $spacing-lg;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-reply {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-md;
|
||||
background: rgba(74, 144, 226, 0.05);
|
||||
border-radius: $border-radius-md;
|
||||
border-left: 3px solid $tech-blue;
|
||||
|
||||
.ai-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-name {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $tech-blue;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.ai-text {
|
||||
color: $text-dark;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: $spacing-xxl;
|
||||
text-align: center;
|
||||
|
||||
.empty-tip {
|
||||
margin-top: $spacing-md;
|
||||
color: $text-medium;
|
||||
font-size: $font-size-sm;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框样式
|
||||
.modal-content {
|
||||
.modal-textarea {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.modal-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.mood-selector,
|
||||
.tags-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mood-label,
|
||||
.tags-label {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="emotion-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪管理</h1>
|
||||
<p class="text-gray-600">记录和管理你的情绪变化</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 情绪记录表单 -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-semibold mb-4">记录今日情绪</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
情绪评分
|
||||
</label>
|
||||
<el-rate v-model="emotionScore" :max="10" show-score />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
情绪类型
|
||||
</label>
|
||||
<el-select v-model="emotionType" placeholder="选择情绪类型" class="w-full">
|
||||
<el-option label="开心" value="happy" />
|
||||
<el-option label="平静" value="calm" />
|
||||
<el-option label="兴奋" value="excited" />
|
||||
<el-option label="焦虑" value="anxious" />
|
||||
<el-option label="悲伤" value="sad" />
|
||||
<el-option label="愤怒" value="angry" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
情绪描述
|
||||
</label>
|
||||
<el-input
|
||||
v-model="emotionDescription"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述一下你今天的情绪..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" class="w-full" @click="saveEmotion">
|
||||
保存记录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 情绪日历 -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-semibold mb-4">情绪日历</h2>
|
||||
<div class="text-center text-gray-500 py-12">
|
||||
<el-icon class="text-4xl mb-4">
|
||||
<Calendar />
|
||||
</el-icon>
|
||||
<p>情绪日历功能开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 情绪趋势图 -->
|
||||
<div class="mt-8">
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-semibold mb-4">情绪趋势</h2>
|
||||
<div class="text-center text-gray-500 py-12">
|
||||
<el-icon class="text-4xl mb-4">
|
||||
<TrendCharts />
|
||||
</el-icon>
|
||||
<p>情绪趋势图表功能开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Calendar, TrendCharts } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const emotionScore = ref(5)
|
||||
const emotionType = ref('')
|
||||
const emotionDescription = ref('')
|
||||
|
||||
const saveEmotion = () => {
|
||||
// TODO: 实现保存情绪记录的逻辑
|
||||
ElMessage.success('情绪记录已保存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.emotion-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.emotion-page .container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
</style>
|
||||
+447
-398
@@ -1,432 +1,415 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 头部导航 -->
|
||||
<AppHeader />
|
||||
<!-- Header -->
|
||||
<header
|
||||
id="main-header"
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg transition-all duration-300"
|
||||
:class="{ 'scrolled': isScrolled }"
|
||||
>
|
||||
<div class="container mx-auto px-6 py-4 flex justify-between items-center">
|
||||
<router-link to="/" class="flex items-center space-x-2">
|
||||
<svg width="32" height="32" viewBox="0 0 100 100" class="text-tech-blue">
|
||||
<path fill="currentColor" d="M85.4,37.3C85.4,37.3,85.4,37.3,85.4,37.3c-2.8-9.9-10-17.7-19.1-21.2c-0.2-0.1-0.5-0.1-0.7-0.2c-0.1,0-0.2-0.1-0.3-0.1 c-1.2-0.4-2.5-0.8-3.7-1.1c-1-0.2-2-0.4-3-0.6c-1.1-0.2-2.1-0.3-3.2-0.4c-1.2-0.1-2.4-0.2-3.6-0.2c-0.1,0-0.2,0-0.3,0h-0.1 c-0.1,0-0.2,0-0.3,0c-1.2,0-2.4,0.1-3.6,0.2c-1.1,0.1-2.1,0.2-3.2,0.4c-1,0.2-2,0.4-3,0.6c-1.3,0.3-2.5,0.6-3.7,1.1 c-0.1,0-0.2,0.1-0.3,0.1c-0.2,0.1-0.5,0.1-0.7,0.2C21.6,19.6,14.4,27.4,11.6,37.3c0,0,0,0.1-0.1,0.1C8,47.7,8,58.8,11.5,69.2 c0,0.1,0.1,0.1,0.1,0.2c2.8,9.9,10,17.7,19.1,21.2c0.2,0.1,0.5,0.1,0.7,0.2c0.1,0,0.2,0.1,0.3,0.1c1.2,0.4,2.5,0.8,3.7,1.1 c1,0.2,2,0.4,3,0.6c1.1,0.2,2.1,0.3,3.2,0.4c1.2,0.1,2.4,0.2,3.6,0.2c0.1,0,0.2,0,0.3,0h0.1c0.1,0,0.2,0,0.3,0 c1.2,0,2.4-0.1,3.6-0.2c-1.1-0.1-2.1-0.2-3.2-0.4c1-0.2,2-0.4,3-0.6c1.3-0.3,2.5-0.6,3.7-1.1c0.1,0,0.2-0.1,0.3-0.1 c0.2-0.1,0.5-0.1,0.7-0.2c9.1-3.5,16.3-11.3,19.1-21.2c0-0.1,0.1-0.1,0.1-0.2C89,58.8,89,47.7,85.4,37.3z M50,77.9 c-15.4,0-27.9-12.5-27.9-27.9S34.6,22.1,50,22.1s27.9,12.5,27.9,27.9S65.4,77.9,50,77.9z"></path>
|
||||
<path fill="var(--warm-orange)" d="M50,88.8c-21.4,0-38.8-17.4-38.8-38.8S28.6,11.2,50,11.2s38.8,17.4,38.8,38.8S71.4,88.8,50,88.8z M50,16.2 c-18.7,0-33.8,15.1-33.8,33.8S31.3,83.8,50,83.8s33.8-15.1,33.8-33.8S68.7,16.2,50,16.2z"></path>
|
||||
</svg>
|
||||
<span class="text-2xl font-bold text-tech-blue">开心APP</span>
|
||||
</router-link>
|
||||
<nav class="hidden lg:flex items-center space-x-8" id="nav-menu">
|
||||
</nav>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 未登录状态 -->
|
||||
<template v-if="!authStore.isLoggedIn">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="hidden sm:inline-block text-text-medium hover:text-tech-blue transition-colors"
|
||||
>
|
||||
登录
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/register"
|
||||
class="hidden sm:inline-block text-text-medium hover:text-tech-blue transition-colors"
|
||||
>
|
||||
注册
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/chat"
|
||||
class="bg-tech-blue text-white px-5 py-2.5 rounded-full font-semibold hover:bg-blue-600 transition-all duration-300 transform hover:scale-105 shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
免费开始
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero-section">
|
||||
<div class="wave-background">
|
||||
<div class="wave"></div>
|
||||
<div class="wave"></div>
|
||||
<div class="wave"></div>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title animate-fade-in-up">
|
||||
你好,我是<span class="highlight">开开</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle animate-fade-in-up delay-300">
|
||||
你的情绪陪伴使者
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-image animate-fade-in-up delay-500">
|
||||
<img
|
||||
src="https://r2.flowith.net/files/1517c93c-849d-4a9b-94b6-d61aa295a8a1/1752600429516-image-1752600425876-cnlfpkbrh@1024x1024.png"
|
||||
alt="欢迎姿态的开开"
|
||||
class="kaikai-image"
|
||||
/>
|
||||
</div>
|
||||
<div class="hero-action animate-fade-in-up delay-700">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="$router.push('/chat')"
|
||||
class="start-chat-btn"
|
||||
<!-- 已登录状态 -->
|
||||
<template v-else>
|
||||
<router-link
|
||||
to="/chat"
|
||||
class="hidden sm:inline-block bg-tech-blue text-white px-4 py-2 rounded-full font-medium hover:bg-blue-600 transition-all duration-300"
|
||||
>
|
||||
开始对话
|
||||
</router-link>
|
||||
<UserDropdown />
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="toggleMobileMenu"
|
||||
class="lg:hidden text-text-dark"
|
||||
>
|
||||
开始一段对话
|
||||
</a-button>
|
||||
<i data-lucide="menu" class="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="features-section">
|
||||
<div class="container">
|
||||
<div class="features-header">
|
||||
<h2 class="features-title scroll-target">核心功能</h2>
|
||||
<p class="features-subtitle scroll-target">
|
||||
开开博学多才、可爱治愈,愿意用最温柔的方式,陪伴每一个需要倾听的生命。
|
||||
</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div
|
||||
v-for="(feature, index) in features"
|
||||
:key="feature.title"
|
||||
class="feature-card scroll-target"
|
||||
:style="{ animationDelay: `${index * 100}ms` }"
|
||||
<!-- Mobile Menu -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="fixed inset-0 bg-white/90 backdrop-blur-xl z-40 p-8 lg:hidden transition-all duration-300"
|
||||
:class="{ 'hidden': !mobileMenuOpen }"
|
||||
>
|
||||
<div class="flex justify-end mb-8">
|
||||
<button @click="toggleMobileMenu" class="text-text-dark">
|
||||
<i data-lucide="x" class="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-col space-y-6 text-center" id="mobile-nav-menu">
|
||||
<!-- 未登录状态 -->
|
||||
<template v-if="!authStore.isLoggedIn">
|
||||
<router-link
|
||||
to="/login"
|
||||
@click="toggleMobileMenu"
|
||||
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
|
||||
>
|
||||
<div class="feature-image-container">
|
||||
<img :src="feature.image" :alt="feature.alt" class="feature-image" />
|
||||
登录
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/register"
|
||||
@click="toggleMobileMenu"
|
||||
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
|
||||
>
|
||||
注册
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/chat"
|
||||
@click="toggleMobileMenu"
|
||||
class="bg-tech-blue text-white px-6 py-3 rounded-full font-semibold mx-auto inline-block"
|
||||
>
|
||||
免费开始
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<!-- 已登录状态 -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center space-y-4 mb-6">
|
||||
<UserAvatar
|
||||
:avatar="authStore.userInfo?.avatar"
|
||||
:nickname="authStore.userInfo?.nickname || '用户'"
|
||||
size="large"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-text-dark">{{ authStore.userInfo?.nickname || '用户' }}</div>
|
||||
<div class="text-sm text-text-medium">{{ authStore.userInfo?.memberLevel || 'Lv.1' }}</div>
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<div class="feature-header">
|
||||
<component :is="feature.icon" class="feature-icon" />
|
||||
<h3 class="feature-title">{{ feature.title }}</h3>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
to="/chat"
|
||||
@click="toggleMobileMenu"
|
||||
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
|
||||
>
|
||||
AI对话
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/profile"
|
||||
@click="toggleMobileMenu"
|
||||
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
|
||||
>
|
||||
个人中心
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/personal-dashboard"
|
||||
@click="toggleMobileMenu"
|
||||
class="text-lg text-text-medium hover:text-tech-blue transition-colors py-2"
|
||||
>
|
||||
个人仪表盘
|
||||
</router-link>
|
||||
<button
|
||||
@click="handleMobileLogout"
|
||||
class="text-lg text-red-500 hover:text-red-600 transition-colors py-2"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<!-- Hero Section -->
|
||||
<section class="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden bg-white">
|
||||
<div class="absolute inset-0 z-0 opacity-20">
|
||||
<div class="wave"></div>
|
||||
<div class="wave"></div>
|
||||
<div class="wave"></div>
|
||||
</div>
|
||||
<div class="container mx-auto px-6 text-center relative z-10">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<h1 class="text-4xl md:text-6xl font-bold text-text-dark leading-tight mb-4 animate-fade-in-up" style="animation-delay: 0.1s;">
|
||||
你好,我是<span class="text-tech-blue">开开</span>
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl text-text-medium mb-8 animate-fade-in-up" style="animation-delay: 0.3s;">
|
||||
你的情绪陪伴使者
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-12 flex justify-center animate-fade-in-up" style="animation-delay: 0.5s;">
|
||||
<img
|
||||
src="https://r2.flowith.net/files/1517c93c-849d-4a9b-94b6-d61aa295a8a1/1752600429516-image-1752600425876-cnlfpkbrh@1024x1024.png"
|
||||
alt="欢迎姿态的开开"
|
||||
class="w-full max-w-sm h-auto drop-shadow-2xl"
|
||||
style="object-fit: contain;"
|
||||
>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<router-link
|
||||
to="/chat"
|
||||
class="bg-warm-orange text-white px-8 py-4 rounded-full font-bold text-lg hover:bg-orange-600 transition-all duration-300 transform hover:scale-105 inline-block shadow-lg shadow-orange-500/30 animate-fade-in-up"
|
||||
style="animation-delay: 0.7s;"
|
||||
>
|
||||
开始一段对话
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="py-20 lg:py-32 bg-light-gray">
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="text-center max-w-3xl mx-auto mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-text-dark mb-4 scroll-target">核心功能</h2>
|
||||
<p class="text-lg text-text-medium scroll-target">
|
||||
开开博学多才、可爱治愈,愿意用最温柔的方式,陪伴每一个需要倾听的生命。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="features-grid" class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
<div
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="feature-card-bg rounded-2xl p-6 flex flex-col items-center text-center scroll-target"
|
||||
:style="{ transitionDelay: `${index * 100}ms` }"
|
||||
>
|
||||
<div class="w-full aspect-square rounded-xl overflow-hidden mb-6 feature-card-image-container flex items-center justify-center">
|
||||
<img
|
||||
:src="feature.image"
|
||||
:alt="feature.alt"
|
||||
class="w-4/5 h-4/5 object-contain drop-shadow-lg"
|
||||
>
|
||||
</div>
|
||||
<p class="feature-description">{{ feature.description }}</p>
|
||||
<div class="flex items-center space-x-2 mb-3">
|
||||
<i :data-lucide="feature.icon" class="w-5 h-5 text-tech-blue"></i>
|
||||
<h3 class="text-xl font-bold text-text-dark">{{ feature.title }}</h3>
|
||||
</div>
|
||||
<p class="text-text-medium text-sm flex-grow">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 底部 -->
|
||||
<AppFooter />
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white">
|
||||
<div class="container mx-auto px-6 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div class="md:col-span-1">
|
||||
<router-link to="/" class="flex items-center space-x-2">
|
||||
<svg width="28" height="28" viewBox="0 0 100 100" class="text-tech-blue">
|
||||
<path fill="currentColor" d="M85.4,37.3C85.4,37.3,85.4,37.3,85.4,37.3c-2.8-9.9-10-17.7-19.1-21.2c-0.2-0.1-0.5-0.1-0.7-0.2c-0.1,0-0.2-0.1-0.3-0.1 c-1.2-0.4-2.5-0.8-3.7-1.1c-1-0.2-2-0.4-3-0.6c-1.1-0.2-2.1-0.3-3.2-0.4c-1.2-0.1-2.4-0.2-3.6-0.2c-0.1,0-0.2,0-0.3,0h-0.1 c-0.1,0-0.2,0-0.3,0c-1.2,0-2.4,0.1-3.6,0.2c-1.1,0.1-2.1,0.2-3.2,0.4c-1,0.2-2,0.4-3,0.6c-1.3,0.3-2.5,0.6-3.7,1.1 c-0.1,0-0.2,0.1-0.3,0.1c-0.2,0.1-0.5,0.1-0.7,0.2C21.6,19.6,14.4,27.4,11.6,37.3c0,0,0,0.1-0.1,0.1C8,47.7,8,58.8,11.5,69.2 c0,0.1,0.1,0.1,0.1,0.2c2.8,9.9,10,17.7,19.1,21.2c0.2,0.1,0.5,0.1,0.7,0.2c0.1,0,0.2,0.1,0.3,0.1c1.2,0.4,2.5,0.8,3.7,1.1 c1,0.2,2,0.4,3,0.6c1.1,0.2,2.1,0.3,3.2,0.4c1.2,0.1,2.4,0.2,3.6,0.2c0.1,0,0.2,0,0.3,0h0.1c0.1,0,0.2,0,0.3,0 c1.2,0,2.4-0.1,3.6-0.2c-1.1-0.1-2.1-0.2-3.2-0.4c1-0.2,2-0.4,3-0.6c1.3-0.3,2.5-0.6,3.7-1.1c0.1,0,0.2-0.1,0.3-0.1 c0.2-0.1,0.5-0.1,0.7-0.2c9.1-3.5,16.3-11.3,19.1-21.2c0-0.1,0.1-0.1,0.1-0.2C89,58.8,89,47.7,85.4,37.3z M50,77.9 c-15.4,0-27.9-12.5-27.9-27.9S34.6,22.1,50,22.1s27.9,12.5,27.9,27.9S65.4,77.9,50,77.9z"></path>
|
||||
<path fill="var(--warm-orange)" d="M50,88.8c-21.4,0-38.8-17.4-38.8-38.8S28.6,11.2,50,11.2s38.8,17.4,38.8,38.8S71.4,88.8,50,88.8z M50,16.2 c-18.7,0-33.8,15.1-33.8,33.8S31.3,83.8,50,83.8s33.8-15.1,33.8-33.8S68.7,16.2,50,16.2z"></path>
|
||||
</svg>
|
||||
<span class="text-xl font-bold text-tech-blue">开心APP</span>
|
||||
</router-link>
|
||||
<p class="mt-4 text-text-medium">陪伴、理解、记录、共同成长。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-dark">产品</h3>
|
||||
<ul class="mt-4 space-y-2">
|
||||
<li><a href="#features-grid" class="text-text-medium hover:text-tech-blue">功能</a></li>
|
||||
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">定价</router-link></li>
|
||||
<li><router-link to="/messages" class="text-text-medium hover:text-tech-blue">更新日志</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-dark">公司</h3>
|
||||
<ul class="mt-4 space-y-2">
|
||||
<li><router-link to="/personal-dashboard" class="text-text-medium hover:text-tech-blue">关于我们</router-link></li>
|
||||
<li><router-link to="/messages" class="text-text-medium hover:text-tech-blue">联系我们</router-link></li>
|
||||
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">加入我们</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-dark">法律</h3>
|
||||
<ul class="mt-4 space-y-2">
|
||||
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">隐私政策</router-link></li>
|
||||
<li><router-link to="/settings" class="text-text-medium hover:text-tech-blue">服务条款</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 border-t border-gray-200 pt-8 text-center text-text-medium">
|
||||
<p>© 2025 开心APP. All Rights Reserved. 来自"开心"星球的温柔科技。</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 登录弹框已移除,统一使用登录页面 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { MessageOutlined, BookOutlined, UserOutlined, LineChartOutlined } from '@ant-design/icons-vue'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
// 功能特性数据
|
||||
const features = [
|
||||
{
|
||||
icon: MessageOutlined,
|
||||
title: '智能对话',
|
||||
description: '从日常闲聊到情感咨询,开开随时倾听,理解并回应你的每个想法,是永不离线的好朋友。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574375721-happy_kaikai_character_design_index_0@1024x1024.png',
|
||||
alt: '开心的开开'
|
||||
},
|
||||
{
|
||||
icon: BookOutlined,
|
||||
title: '情绪日记',
|
||||
description: '记录你的点滴心情与生活,开开会给予温暖的回应。在安全的空间里,回顾与成长。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574488398-kaikai_supportive_comfort_character_index_3@1024x1024.png',
|
||||
alt: '倾听中的开开'
|
||||
},
|
||||
{
|
||||
icon: UserOutlined,
|
||||
title: '个人展板',
|
||||
description: '自由定义你的个性标签,开开还会自动收录你的"精彩语录",构建独一无二的数字人格。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574426392-kaikai_character_working_digital_workspace_index_4@1024x1024.png',
|
||||
alt: '工作中的开开'
|
||||
},
|
||||
{
|
||||
icon: LineChartOutlined,
|
||||
title: '话题追踪',
|
||||
description: '自动总结你关心的事,无论是生活琐事还是工作计划,都用时间线清晰整理,助你洞察自我。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574572161-kaikai_character_energetic_animation_index_2@1024x1024.png',
|
||||
alt: '充满活力的开开'
|
||||
}
|
||||
]
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 滚动动画观察器
|
||||
let scrollObserver: IntersectionObserver | null = null
|
||||
// 响应式数据
|
||||
const isScrolled = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化滚动动画
|
||||
initScrollAnimations()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollObserver) {
|
||||
scrollObserver.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
const initScrollAnimations = () => {
|
||||
scrollObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible')
|
||||
scrollObserver?.unobserve(entry.target)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
|
||||
// 观察所有需要动画的元素
|
||||
document.querySelectorAll('.scroll-target').forEach((target) => {
|
||||
scrollObserver?.observe(target)
|
||||
})
|
||||
// 功能特性数据
|
||||
const features = ref([
|
||||
{
|
||||
icon: 'message-circle',
|
||||
title: '智能对话',
|
||||
description: '从日常闲聊到情感咨询,开开随时倾听,理解并回应你的每个想法,是永不离线的好朋友。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574375721-happy_kaikai_character_design_index_0@1024x1024.png',
|
||||
alt: '开心的开开'
|
||||
},
|
||||
{
|
||||
icon: 'book-open-text',
|
||||
title: '情绪日记',
|
||||
description: '记录你的点滴心情与生活,开开会给予温暖的回应。在安全的空间里,回顾与成长。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574488398-kaikai_supportive_comfort_character_index_3@1024x1024.png',
|
||||
alt: '倾听中的开开'
|
||||
},
|
||||
{
|
||||
icon: 'user-round-cog',
|
||||
title: '个人展板',
|
||||
description: '自由定义你的个性标签,开开还会自动收录你的"精彩语录",构建独一无二的数字人格。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574426392-kaikai_character_working_digital_workspace_index_4@1024x1024.png',
|
||||
alt: '工作中的开开'
|
||||
},
|
||||
{
|
||||
icon: 'trending-up',
|
||||
title: '话题追踪',
|
||||
description: '自动总结你关心的事,无论是生活琐事还是工作计划,都用时间线清晰整理,助你洞察自我。',
|
||||
image: 'https://r2.flowith.net/files/o/1752574572161-kaikai_character_energetic_animation_index_2@1024x1024.png',
|
||||
alt: '充满活力的开开'
|
||||
}
|
||||
])
|
||||
|
||||
// 滚动监听
|
||||
const handleScroll = () => {
|
||||
isScrolled.value = window.scrollY > 10
|
||||
}
|
||||
|
||||
// 移动端菜单切换
|
||||
const toggleMobileMenu = () => {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理移动端登出
|
||||
*/
|
||||
const handleMobileLogout = async () => {
|
||||
try {
|
||||
await authStore.logout()
|
||||
toggleMobileMenu()
|
||||
ElMessage.success('已退出登录')
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
ElMessage.error('登出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 移除登录弹框相关方法
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 静默恢复本地认证状态(不进行API调用)
|
||||
if (!authStore.isLoggedIn) {
|
||||
console.log('🏠 首页静默恢复认证状态')
|
||||
authStore.restoreLocalAuth()
|
||||
}
|
||||
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
|
||||
// 设置滚动观察器
|
||||
const scrollObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible')
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
})
|
||||
}, { threshold: 0.1 })
|
||||
|
||||
// 观察所有滚动目标
|
||||
document.querySelectorAll('.scroll-target').forEach(target => {
|
||||
scrollObserver.observe(target)
|
||||
})
|
||||
|
||||
// 延迟初始化图标,确保DOM已渲染
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
<style scoped>
|
||||
/* 导入原始样式变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
position: relative;
|
||||
padding: 128px 24px 80px;
|
||||
background: white;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 应用原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 100px 16px 60px;
|
||||
min-height: 80vh;
|
||||
}
|
||||
#main-header.scrolled {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.wave-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.2;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.wave {
|
||||
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+");
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 147px;
|
||||
animation: wave 15s linear infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-direction: reverse;
|
||||
animation-duration: 20s;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-duration: 25s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% { transform: translateX(0); }
|
||||
50% { transform: translateX(-50%); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
color: $text-dark;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.5rem;
|
||||
color: $text-medium;
|
||||
margin-bottom: 48px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.kaikai-image {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
border-radius: 20px;
|
||||
filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.1));
|
||||
|
||||
@media (max-width: 768px) {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.start-chat-btn {
|
||||
background: $warm-orange !important;
|
||||
border: none !important;
|
||||
border-radius: 50px !important;
|
||||
padding: 16px 32px !important;
|
||||
font-size: 18px !important;
|
||||
font-weight: 600 !important;
|
||||
height: auto !important;
|
||||
box-shadow: 0 8px 24px rgba(245, 166, 35, 0.3) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
background: #e6951f !important;
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: 0 12px 32px rgba(245, 166, 35, 0.4) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px 24px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features-section {
|
||||
padding: 80px 24px;
|
||||
background: $light-gray;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 60px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.features-header {
|
||||
text-align: center;
|
||||
max-width: 768px;
|
||||
margin: 0 auto 64px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: $text-dark;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.features-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: $text-medium;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 32px;
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-image-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, #eef5fe 0%, #f0f8ff 100%);
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M-10 10 C 20 20, 40 0, 60 10 S 100 0, 120 10" stroke="%234A90E2" fill="none" stroke-width="2" stroke-opacity="0.2"/></svg>');
|
||||
background-size: 50px;
|
||||
background-repeat: repeat;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-image {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: $tech-blue;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: $text-medium;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.delay-300 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.delay-500 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.delay-700 {
|
||||
animation-delay: 0.7s;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -442,10 +425,76 @@
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
||||
}
|
||||
|
||||
&.visible {
|
||||
.scroll-target.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.wave {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 147px;
|
||||
animation: wave 15s linear infinite;
|
||||
}
|
||||
|
||||
.wave:nth-of-type(2) {
|
||||
animation-direction: reverse;
|
||||
animation-duration: 20s;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.wave:nth-of-type(3) {
|
||||
animation-duration: 25s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% { transform: translateX(0); }
|
||||
50% { transform: translateX(-50%); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
#login-modal:not(.hidden) {
|
||||
animation: modal-fade-in 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
#login-modal:not(.hidden) > div {
|
||||
animation: modal-scale-up 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@keyframes modal-scale-up {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移除了验证码相关样式 */
|
||||
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
|
||||
.home-page {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-md z-10 flex-shrink-0">
|
||||
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
|
||||
<div class="flex items-center space-x-4">
|
||||
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="home" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="bell" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">人生轨迹</h1>
|
||||
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="user" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto p-4 lg:p-6 flex items-center justify-center pb-24">
|
||||
<div class="text-center">
|
||||
<i data-lucide="milestone" class="w-16 h-16 mx-auto text-gray-300"></i>
|
||||
<h2 class="mt-4 text-xl font-semibold text-text-dark">记录你的人生轨迹</h2>
|
||||
<p class="mt-2 text-text-medium">重要的时刻、达成的目标、难忘的经历...都在这里汇集。</p>
|
||||
<p class="mt-1 text-text-medium">此功能正在建设中,敬请期待!</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- App Navigation -->
|
||||
<BottomNavigation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导入原始样式变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* 应用原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
.life-milestones-page {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--text-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
+378
-270
@@ -1,328 +1,436 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<!-- Logo和标题 -->
|
||||
<div class="login-header">
|
||||
<router-link to="/" class="logo">
|
||||
<span class="logo-text">开心APP</span>
|
||||
</router-link>
|
||||
<h1 class="login-title">欢迎回来</h1>
|
||||
<p class="login-subtitle">登录您的账户,继续与开开的对话</p>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-md mx-auto card">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">登录</h1>
|
||||
<p class="text-gray-600">欢迎回到情绪博物馆</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<a-form
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
@finish="handleLogin"
|
||||
@finishFailed="handleLoginFailed"
|
||||
layout="vertical"
|
||||
class="login-form"
|
||||
label-width="80px"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<a-form-item label="账号" name="account">
|
||||
<a-input
|
||||
v-model:value="loginForm.account"
|
||||
placeholder="请输入手机号或邮箱"
|
||||
size="large"
|
||||
:prefix="h(UserOutlined)"
|
||||
<el-form-item label="账号" prop="account">
|
||||
<el-input
|
||||
v-model="loginForm.account"
|
||||
placeholder="请输入账号/邮箱/手机号"
|
||||
:prefix-icon="User"
|
||||
clearable
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<a-form-item label="密码" name="password">
|
||||
<a-input-password
|
||||
v-model:value="loginForm.password"
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix="h(LockOutlined)"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<a-form-item label="验证码" name="captcha">
|
||||
<div class="captcha-container">
|
||||
<a-input
|
||||
v-model:value="loginForm.captcha"
|
||||
<el-form-item label="验证码" prop="captcha">
|
||||
<div class="flex gap-2">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
style="flex: 1"
|
||||
:prefix-icon="Key"
|
||||
clearable
|
||||
class="flex-1"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<div class="captcha-image" @click="refreshCaptcha">
|
||||
<div class="captcha-container" @click="refreshCaptcha">
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
style="width: 100%; height: 100%; cursor: pointer;"
|
||||
class="captcha-image"
|
||||
/>
|
||||
<div v-else class="captcha-loading">
|
||||
<a-spin size="small" />
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-tip">点击图片刷新验证码</div>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="login-options">
|
||||
<a-checkbox v-model:checked="loginForm.remember">记住我</a-checkbox>
|
||||
<a href="#" class="forgot-password">忘记密码?</a>
|
||||
<el-form-item>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<el-checkbox v-model="loginForm.rememberMe">
|
||||
记住我
|
||||
</el-checkbox>
|
||||
<router-link to="/forgot-password" class="text-primary-600 hover:underline text-sm">
|
||||
忘记密码?
|
||||
</router-link>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
:loading="loginLoading"
|
||||
class="login-button"
|
||||
block
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 注册链接 -->
|
||||
<div class="register-link">
|
||||
还没有账户?
|
||||
<router-link to="/register" class="register-btn">立即注册</router-link>
|
||||
</el-form>
|
||||
|
||||
<div class="divider">
|
||||
<span>或</span>
|
||||
</div>
|
||||
|
||||
<div class="social-login">
|
||||
<el-button class="social-btn wechat" @click="handleSocialLogin('wechat')">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
微信登录
|
||||
</el-button>
|
||||
<el-button class="social-btn qq" @click="handleSocialLogin('qq')">
|
||||
<el-icon><User /></el-icon>
|
||||
QQ登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<router-link to="/register" class="text-primary-600 hover:underline">
|
||||
还没有账号?立即注册
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { authService } from '@/services/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { LoginRequest } from '@/types/auth'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElForm } from 'element-plus'
|
||||
import {
|
||||
User,
|
||||
Lock,
|
||||
Key,
|
||||
Loading,
|
||||
ChatDotRound
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AuthService from '@/services/auth'
|
||||
import { envConfig } from '@/config/env'
|
||||
import type { LoginRequest } from '@/types/auth'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 表单数据
|
||||
const loginForm = reactive<LoginRequest>({
|
||||
account: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
remember: false
|
||||
})
|
||||
// 表单引用
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules = {
|
||||
account: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||
{ min: 3, message: '账号长度不能少于3位', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 4, message: '验证码长度为4位', trigger: 'blur' }
|
||||
]
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 验证码相关
|
||||
const captchaImage = ref('')
|
||||
const captchaKey = ref('')
|
||||
|
||||
// 登录表单数据
|
||||
const loginForm = reactive<LoginRequest>({
|
||||
account: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
captchaKey: '',
|
||||
rememberMe: false
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const loginRules: FormRules = {
|
||||
account: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度必须在6-20位之间', trigger: 'blur' }
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ min: 4, max: 6, message: '验证码长度不正确', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
const response = await AuthService.getCaptcha()
|
||||
// 后端返回的数据已经包含了 data:image/png;base64, 前缀,直接使用
|
||||
captchaImage.value = response.captchaImage
|
||||
captchaKey.value = response.captchaKey
|
||||
loginForm.captchaKey = response.captchaKey
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 状态
|
||||
const loginLoading = ref(false)
|
||||
const captchaImage = ref('')
|
||||
const captchaKey = ref('')
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
const refreshCaptcha = () => {
|
||||
loginForm.captcha = ''
|
||||
getCaptcha()
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
const response = await authService.getCaptcha()
|
||||
console.log('验证码响应:', response)
|
||||
captchaImage.value = response.captchaImage // 修正字段
|
||||
captchaKey.value = response.captchaKey // 修正字段
|
||||
console.log('验证码图片URL:', captchaImage.value.substring(0, 50) + '...')
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
message.error('获取验证码失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 处理登录
|
||||
*/
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = () => {
|
||||
getCaptcha()
|
||||
}
|
||||
try {
|
||||
console.log('开始登录流程...')
|
||||
console.log('登录表单数据:', loginForm)
|
||||
|
||||
// 登录处理
|
||||
const handleLogin = async (values: LoginRequest) => {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const loginData = {
|
||||
...values,
|
||||
captchaKey: captchaKey.value
|
||||
}
|
||||
// const data = await userStore.loginWithAuth(loginData)
|
||||
await userStore.loginWithAuth(loginData)
|
||||
message.success('登录成功')
|
||||
await nextTick()
|
||||
const redirect = router.currentRoute.value.query.redirect as string
|
||||
const targetPath = redirect || '/'
|
||||
setTimeout(() => {
|
||||
try {
|
||||
router.replace(targetPath).then(() => {
|
||||
console.log('路由跳转完成')
|
||||
}).catch((_error) => {
|
||||
window.location.href = targetPath
|
||||
})
|
||||
} catch (error) {
|
||||
window.location.href = targetPath
|
||||
}
|
||||
}, 100)
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '登录失败,请稍后重试')
|
||||
// 表单验证
|
||||
await loginFormRef.value.validate()
|
||||
console.log('表单验证通过')
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 调用登录接口
|
||||
console.log('调用登录接口...')
|
||||
const success = await authStore.login(loginForm)
|
||||
console.log('登录结果:', success)
|
||||
|
||||
if (success) {
|
||||
// 登录成功,确保认证状态已正确设置
|
||||
console.log('登录成功,当前认证状态:', {
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
hasToken: !!authStore.accessToken,
|
||||
hasUserInfo: !!authStore.userInfo
|
||||
})
|
||||
|
||||
// 跳转到目标页面或首页
|
||||
const redirect = route.query.redirect as string || '/'
|
||||
console.log('登录成功,跳转到:', redirect)
|
||||
|
||||
// 使用路由跳转而不是window.location.href,避免base路径问题
|
||||
await router.push(redirect)
|
||||
} else {
|
||||
// 登录失败,刷新验证码
|
||||
console.log('登录失败,刷新验证码')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录过程中发生错误:', error)
|
||||
ElMessage.error('登录失败,请检查网络连接或稍后重试')
|
||||
// 刷新验证码
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 登录失败处理
|
||||
const handleLoginFailed = (errorInfo: any) => {
|
||||
console.log('Login failed:', errorInfo)
|
||||
/**
|
||||
* 处理第三方登录
|
||||
*/
|
||||
const handleSocialLogin = (platform: 'wechat' | 'qq') => {
|
||||
ElMessage.info(`${platform === 'wechat' ? '微信' : 'QQ'}登录功能开发中...`)
|
||||
// TODO: 实现第三方登录逻辑
|
||||
}
|
||||
|
||||
// 组件挂载时获取验证码
|
||||
onMounted(() => {
|
||||
getCaptcha()
|
||||
|
||||
// 如果已经登录,直接跳转
|
||||
if (authStore.isLoggedIn) {
|
||||
const redirect = route.query.redirect as string || '/'
|
||||
router.push(redirect)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
getCaptcha()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-attachment: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.card {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
.captcha-container {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.captcha-container:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.captcha-loading {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e4e7ed;
|
||||
}
|
||||
|
||||
.divider span {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0 1rem;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.social-login {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.social-btn.wechat {
|
||||
background: #07c160;
|
||||
border-color: #07c160;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.social-btn.wechat:hover {
|
||||
background: #06ad56;
|
||||
border-color: #06ad56;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.social-btn.qq {
|
||||
background: #12b7f5;
|
||||
border-color: #12b7f5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.social-btn.qq:hover {
|
||||
background: #0ea5e9;
|
||||
border-color: #0ea5e9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #dcdfe6 inset;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px #c0c4cc inset;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px #409eff inset;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary) {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
padding: 12px 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary:hover) {
|
||||
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.text-primary-600 {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.text-primary-600:hover {
|
||||
color: #5a6fd8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.logo {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
color: #4A90E2;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.captcha-container {
|
||||
width: 100px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.captcha-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #4A90E2;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.captcha-tip {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.forgot-password {
|
||||
color: #4A90E2;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background: linear-gradient(135deg, #4A90E2 0%, #5BA0F2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.social-login {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
|
||||
.register-btn {
|
||||
color: #4A90E2;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="map-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪地图</h1>
|
||||
<p class="text-gray-600">在地图上标记情绪地点,记录美好回忆</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="text-center text-gray-500 py-12">
|
||||
<el-icon class="text-4xl mb-4">
|
||||
<Location />
|
||||
</el-icon>
|
||||
<p>情绪地图功能开发中...</p>
|
||||
<p class="text-sm mt-2">这里将集成高德地图API,支持情绪地点标记</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Location } from '@element-plus/icons-vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.map-page .container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
</style>
|
||||
+130
-569
@@ -1,590 +1,151 @@
|
||||
<template>
|
||||
<div class="messages-page">
|
||||
<!-- 头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="$router.back()" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</a-button>
|
||||
<h1 class="page-title">消息中心</h1>
|
||||
<div class="bg-light-gray font-sans text-text-dark">
|
||||
<div id="app-container" class="antialiased">
|
||||
<!-- App Header -->
|
||||
<header class="fixed top-0 left-0 right-0 z-40 bg-white/90 backdrop-blur-md border-b border-gray-200/80">
|
||||
<div class="container mx-auto px-4 h-16 flex items-center justify-between relative">
|
||||
<button @click="goBack" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="chevron-left" class="w-6 h-6"></i>
|
||||
</button>
|
||||
<h1 class="text-lg font-semibold text-text-dark absolute left-1/2 -translate-x-1/2">消息中心</h1>
|
||||
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="home" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button type="text" @click="markAllAsRead" :disabled="unreadCount === 0">
|
||||
全部已读
|
||||
</a-button>
|
||||
<a-button type="text" @click="clearAllMessages" danger>
|
||||
<DeleteOutlined />
|
||||
清空
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-main">
|
||||
<div class="container">
|
||||
<!-- 筛选标签 -->
|
||||
<div class="filter-tabs">
|
||||
<a-radio-group v-model:value="activeTab" @change="handleTabChange">
|
||||
<a-radio-button value="all">
|
||||
全部 <a-badge :count="messages.length" :show-zero="false" />
|
||||
</a-radio-button>
|
||||
<a-radio-button value="unread">
|
||||
未读 <a-badge :count="unreadCount" :show-zero="false" />
|
||||
</a-radio-button>
|
||||
<a-radio-button value="system">
|
||||
系统消息 <a-badge :count="systemCount" :show-zero="false" />
|
||||
</a-radio-button>
|
||||
<a-radio-button value="notification">
|
||||
通知 <a-badge :count="notificationCount" :show-zero="false" />
|
||||
</a-radio-button>
|
||||
<a-radio-button value="reminder">
|
||||
提醒 <a-badge :count="reminderCount" :show-zero="false" />
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="messages-list">
|
||||
<div
|
||||
v-for="message in filteredMessages"
|
||||
:key="message.id"
|
||||
class="message-item"
|
||||
:class="{ 'unread': message.status === 'unread' }"
|
||||
@click="handleMessageClick(message)"
|
||||
>
|
||||
<div class="message-icon">
|
||||
<div class="icon-wrapper" :class="`type-${message.type}`">
|
||||
<component :is="getMessageIcon(message.type)" />
|
||||
<main class="pt-20 pb-8 bg-light-gray min-h-screen">
|
||||
<div class="container mx-auto px-6">
|
||||
<div id="message-list" class="max-w-3xl mx-auto space-y-4">
|
||||
<div
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
class="bg-white p-5 rounded-xl shadow-sm border border-gray-200/80 flex items-start space-x-4 hover:shadow-md hover:border-tech-blue/30 transition-all duration-300 animate-fade-in-up"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-light-gray flex items-center justify-center border">
|
||||
<i :data-lucide="msg.icon" class="w-5 h-5" :class="msg.color"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<h3 class="message-title">{{ message.title }}</h3>
|
||||
<div class="message-meta">
|
||||
<a-tag :color="getTypeColor(message.type)" size="small">
|
||||
{{ getTypeText(message.type) }}
|
||||
</a-tag>
|
||||
<span class="message-time">{{ formatTime.friendly(message.createTime) }}</span>
|
||||
<div class="flex-grow">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="font-bold text-text-dark">{{ msg.title }}</h3>
|
||||
<span class="text-xs text-text-medium whitespace-nowrap">{{ msg.timestamp }}</span>
|
||||
</div>
|
||||
<p class="text-text-medium mt-1 pr-4">{{ msg.content }}</p>
|
||||
</div>
|
||||
|
||||
<p class="message-text">{{ message.content }}</p>
|
||||
|
||||
<div class="message-actions" v-if="message.actionUrl">
|
||||
<a-button type="link" size="small" @click.stop="handleAction(message)">
|
||||
查看详情
|
||||
<RightOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-controls">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click.stop="toggleReadStatus(message)"
|
||||
:title="message.status === 'read' ? '标记为未读' : '标记为已读'"
|
||||
>
|
||||
<EyeOutlined v-if="message.status === 'unread'" />
|
||||
<EyeInvisibleOutlined v-else />
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
@click.stop="deleteMessage(message.id)"
|
||||
title="删除消息"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
<button class="flex-shrink-0 text-text-medium hover:text-tech-blue self-center">
|
||||
<i data-lucide="chevron-right" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredMessages.length === 0" class="empty-state">
|
||||
<a-empty
|
||||
:description="getEmptyDescription()"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div class="load-more" v-if="hasMore">
|
||||
<a-button @click="loadMore" :loading="isLoading" block>
|
||||
加载更多
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 消息详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="selectedMessage?.title"
|
||||
:footer="null"
|
||||
width="600px"
|
||||
>
|
||||
<div v-if="selectedMessage" class="message-detail">
|
||||
<div class="detail-header">
|
||||
<a-tag :color="getTypeColor(selectedMessage.type)">
|
||||
{{ getTypeText(selectedMessage.type) }}
|
||||
</a-tag>
|
||||
<span class="detail-time">{{ formatTime.standard(selectedMessage.createTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<p>{{ selectedMessage.content }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions" v-if="selectedMessage.actionUrl">
|
||||
<a-button type="primary" @click="handleAction(selectedMessage)">
|
||||
查看详情
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
RightOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
BellOutlined,
|
||||
InfoCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Empty, message } from 'ant-design-vue'
|
||||
import { formatTime } from '@/utils'
|
||||
import type { Message } from '@/types'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('all')
|
||||
const showDetailModal = ref(false)
|
||||
const selectedMessage = ref<Message | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const hasMore = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
// 消息数据
|
||||
const messages = ref<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
title: '欢迎使用开心APP',
|
||||
content: '感谢您注册开心APP!我是您的情绪陪伴使者开开,很高兴认识您。让我们一起开始这段美好的情绪陪伴之旅吧!',
|
||||
type: 'system',
|
||||
status: 'unread',
|
||||
createTime: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
actionUrl: '/chat'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '每日心情记录提醒',
|
||||
content: '今天还没有记录心情哦~花几分钟写下今天的感受,让开开更好地了解您的情绪变化。',
|
||||
type: 'reminder',
|
||||
status: 'unread',
|
||||
createTime: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
actionUrl: '/diary'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '新功能上线通知',
|
||||
content: '话题追踪功能已上线!现在您可以创建和管理感兴趣的话题,让开开帮您更好地整理思路。',
|
||||
type: 'notification',
|
||||
status: 'read',
|
||||
createTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
actionUrl: '/topic-tracker'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '系统维护通知',
|
||||
content: '系统将于今晚23:00-24:00进行例行维护,期间可能会影响部分功能的使用,请您谅解。',
|
||||
type: 'system',
|
||||
status: 'read',
|
||||
createTime: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: '个人展板完善提醒',
|
||||
content: '完善您的个人展板信息,让开开更好地了解您的兴趣爱好和生活技能,提供更个性化的陪伴。',
|
||||
type: 'reminder',
|
||||
status: 'read',
|
||||
createTime: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
actionUrl: '/dashboard'
|
||||
// 消息数据
|
||||
const messages = ref([
|
||||
{
|
||||
type: 'ai',
|
||||
icon: 'sparkles',
|
||||
color: 'text-warm-orange',
|
||||
title: '开开的每周心情总结',
|
||||
content: '你好呀!上周我们聊了很多关于"新工作的挑战",你表现出了很棒的适应能力和积极心态。记得给自己一些放松的时间哦,比如看看你喜欢的电影。',
|
||||
timestamp: '2025年7月15日 09:30'
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
icon: 'bell',
|
||||
color: 'text-tech-blue',
|
||||
title: '系统通知:欢迎使用日记功能',
|
||||
content: '现在,你可以在日记区记录下你的生活点滴,开开会阅读你的日记并给你温暖的回复和鼓励哦。',
|
||||
timestamp: '2025年7月14日 18:00'
|
||||
},
|
||||
{
|
||||
type: 'ai',
|
||||
icon: 'sparkles',
|
||||
color: 'text-warm-orange',
|
||||
title: '开开的话题追踪提醒',
|
||||
content: '我发现你最近经常提到"学吉他",我已经为你创建了一个话题追踪卡片,帮你记录学习进度和心得。一起加油吧!',
|
||||
timestamp: '2025年7月12日 11:25'
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
icon: 'award',
|
||||
color: 'text-green-500',
|
||||
title: '成就解锁:初次见面',
|
||||
content: '恭喜你完成了与开开的第一次对话,这是共同成长的第一步。',
|
||||
timestamp: '2025年7月10日 20:45'
|
||||
}
|
||||
])
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
|
||||
// 延迟初始化图标
|
||||
setTimeout(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const filteredMessages = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'unread':
|
||||
return messages.value.filter(msg => msg.status === 'unread')
|
||||
case 'system':
|
||||
return messages.value.filter(msg => msg.type === 'system')
|
||||
case 'notification':
|
||||
return messages.value.filter(msg => msg.type === 'notification')
|
||||
case 'reminder':
|
||||
return messages.value.filter(msg => msg.type === 'reminder')
|
||||
default:
|
||||
return messages.value
|
||||
}
|
||||
})
|
||||
|
||||
const unreadCount = computed(() =>
|
||||
messages.value.filter(msg => msg.status === 'unread').length
|
||||
)
|
||||
|
||||
const systemCount = computed(() =>
|
||||
messages.value.filter(msg => msg.type === 'system').length
|
||||
)
|
||||
|
||||
const notificationCount = computed(() =>
|
||||
messages.value.filter(msg => msg.type === 'notification').length
|
||||
)
|
||||
|
||||
const reminderCount = computed(() =>
|
||||
messages.value.filter(msg => msg.type === 'reminder').length
|
||||
)
|
||||
|
||||
// 方法
|
||||
const getMessageIcon = (type: Message['type']) => {
|
||||
const icons = {
|
||||
system: SettingOutlined,
|
||||
notification: BellOutlined,
|
||||
reminder: ClockCircleOutlined
|
||||
}
|
||||
return icons[type] || InfoCircleOutlined
|
||||
}
|
||||
|
||||
const getTypeColor = (type: Message['type']) => {
|
||||
const colors = {
|
||||
system: 'blue',
|
||||
notification: 'green',
|
||||
reminder: 'orange'
|
||||
}
|
||||
return colors[type] || 'default'
|
||||
}
|
||||
|
||||
const getTypeText = (type: Message['type']) => {
|
||||
const texts = {
|
||||
system: '系统消息',
|
||||
notification: '通知',
|
||||
reminder: '提醒'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
const getEmptyDescription = () => {
|
||||
switch (activeTab.value) {
|
||||
case 'unread':
|
||||
return '暂无未读消息'
|
||||
case 'system':
|
||||
return '暂无系统消息'
|
||||
case 'notification':
|
||||
return '暂无通知消息'
|
||||
case 'reminder':
|
||||
return '暂无提醒消息'
|
||||
default:
|
||||
return '暂无消息'
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = () => {
|
||||
// 标签切换逻辑已在计算属性中处理
|
||||
}
|
||||
|
||||
const handleMessageClick = (msg: Message) => {
|
||||
// 标记为已读
|
||||
if (msg.status === 'unread') {
|
||||
msg.status = 'read'
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
selectedMessage.value = msg
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
const toggleReadStatus = (msg: Message) => {
|
||||
msg.status = msg.status === 'read' ? 'unread' : 'read'
|
||||
message.success(`已${msg.status === 'read' ? '标记为已读' : '标记为未读'}`)
|
||||
}
|
||||
|
||||
const deleteMessage = (id: string) => {
|
||||
const index = messages.value.findIndex(msg => msg.id === id)
|
||||
if (index > -1) {
|
||||
messages.value.splice(index, 1)
|
||||
message.success('消息删除成功')
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
messages.value.forEach(msg => {
|
||||
if (msg.status === 'unread') {
|
||||
msg.status = 'read'
|
||||
}
|
||||
})
|
||||
message.success('所有消息已标记为已读')
|
||||
}
|
||||
|
||||
const clearAllMessages = () => {
|
||||
messages.value.length = 0
|
||||
message.success('所有消息已清空')
|
||||
}
|
||||
|
||||
const handleAction = (msg: Message) => {
|
||||
if (msg.actionUrl) {
|
||||
// 这里可以使用路由跳转
|
||||
message.info(`跳转到:${msg.actionUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
isLoading.value = true
|
||||
// 模拟加载更多
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
hasMore.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
// 初始化消息数据
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.messages-page {
|
||||
min-height: 100vh;
|
||||
background: $light-gray;
|
||||
<style scoped>
|
||||
/* 导入原始样式变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* 应用原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-warm-orange { color: var(--warm-orange); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
box-shadow: $shadow-sm;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: $text-medium;
|
||||
|
||||
&:hover {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
:deep(.ant-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
background: white;
|
||||
border-radius: $border-radius-lg;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
border-left: 4px solid transparent;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $shadow-md;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.unread {
|
||||
border-left-color: $tech-blue;
|
||||
background: linear-gradient(90deg, rgba(74, 144, 226, 0.02) 0%, white 100%);
|
||||
}
|
||||
|
||||
.message-icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
.icon-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: $font-size-lg;
|
||||
|
||||
&.type-system {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
&.type-notification {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
&.type-reminder {
|
||||
background: #fa8c16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-sm;
|
||||
gap: $spacing-md;
|
||||
|
||||
.message-title {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-shrink: 0;
|
||||
|
||||
.message-time {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-text {
|
||||
color: $text-medium;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 $spacing-sm 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
.ant-btn-link {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: $spacing-xxl;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin-top: $spacing-xl;
|
||||
}
|
||||
|
||||
// 消息详情模态框
|
||||
.message-detail {
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding-bottom: $spacing-md;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.detail-time {
|
||||
color: $text-medium;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
p {
|
||||
color: $text-dark;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<div class="not-found-page">
|
||||
<div class="not-found-content">
|
||||
<div class="error-illustration">
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-icon">
|
||||
<FrownOutlined />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="error-title">页面未找到</h1>
|
||||
<p class="error-description">
|
||||
抱歉,您访问的页面不存在或已被移动。
|
||||
</p>
|
||||
|
||||
<div class="error-actions">
|
||||
<a-button type="primary" @click="$router.push('/')" size="large">
|
||||
<HomeOutlined />
|
||||
返回首页
|
||||
</a-button>
|
||||
<a-button @click="$router.back()" size="large">
|
||||
<ArrowLeftOutlined />
|
||||
返回上页
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FrownOutlined, HomeOutlined, ArrowLeftOutlined } from '@ant-design/icons-vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.not-found-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, $light-gray 0%, white 100%);
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.error-illustration {
|
||||
position: relative;
|
||||
margin-bottom: $spacing-xxl;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $tech-blue;
|
||||
opacity: 0.1;
|
||||
line-height: 1;
|
||||
|
||||
@media (min-width: $breakpoint-md) {
|
||||
font-size: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 4rem;
|
||||
color: $text-medium;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
@media (min-width: $breakpoint-md) {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-medium;
|
||||
margin-bottom: $spacing-xxl;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="notfound-page">
|
||||
<div class="container mx-auto px-4 py-24 text-center">
|
||||
<h1 class="text-6xl font-bold text-primary-600 mb-4">404</h1>
|
||||
<p class="text-2xl text-gray-700 mb-6">页面未找到</p>
|
||||
<router-link to="/" class="btn-primary">返回首页</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 404页面逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notfound-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f857a6 0%, #ff5858 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.notfound-page .container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="personal-dashboard-page">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-md z-10 flex-shrink-0">
|
||||
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
|
||||
<div class="flex items-center space-x-4">
|
||||
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="home" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="bell" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">个人展板</h1>
|
||||
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="user" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto p-4 lg:p-6 pb-24">
|
||||
<div id="dashboard-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Basic Info Card -->
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="font-bold text-text-dark text-lg">基础信息</h2>
|
||||
<i data-lucide="user-round" class="text-tech-blue"></i>
|
||||
</div>
|
||||
<div id="basic-info-container" class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-text-medium">用户名</span>
|
||||
<span class="font-semibold text-text-dark">开心用户</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-text-medium">注册时间</span>
|
||||
<span class="font-semibold text-text-dark">2024年1月</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-text-medium">日记数量</span>
|
||||
<span class="font-semibold text-text-dark">42篇</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-text-medium">连续记录</span>
|
||||
<span class="font-semibold text-text-dark">15天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mood Chart Card -->
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="font-bold text-text-dark text-lg">近期心情统计</h2>
|
||||
<i data-lucide="activity" class="text-warm-orange"></i>
|
||||
</div>
|
||||
<div class="relative h-48">
|
||||
<canvas id="moodChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interests Card -->
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="font-bold text-text-dark text-lg">兴趣爱好</h2>
|
||||
<button
|
||||
@click="addInterest"
|
||||
class="text-text-medium hover:text-tech-blue transition-colors"
|
||||
title="添加兴趣"
|
||||
>
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="interests-container" class="flex flex-wrap gap-2 text-sm min-h-[36px]">
|
||||
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">阅读</span>
|
||||
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">音乐</span>
|
||||
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">旅行</span>
|
||||
<span class="bg-tech-blue/10 text-tech-blue px-3 py-1 rounded-full">摄影</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="exploreInterests"
|
||||
class="w-full text-sm bg-tech-blue/10 text-tech-blue font-semibold py-2 px-4 rounded-lg hover:bg-tech-blue/20 transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||
<span>探索可能发展的爱好</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Card -->
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="font-bold text-text-dark text-lg">生活技能</h2>
|
||||
<button
|
||||
@click="addSkill"
|
||||
class="text-text-medium hover:text-tech-blue transition-colors"
|
||||
title="添加技能"
|
||||
>
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="skills-container" class="flex flex-wrap gap-2 text-sm min-h-[36px]">
|
||||
<span class="bg-warm-orange/10 text-warm-orange px-3 py-1 rounded-full">烹饪</span>
|
||||
<span class="bg-warm-orange/10 text-warm-orange px-3 py-1 rounded-full">绘画</span>
|
||||
<span class="bg-warm-orange/10 text-warm-orange px-3 py-1 rounded-full">编程</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="exploreSkills"
|
||||
class="w-full text-sm bg-tech-blue/10 text-tech-blue font-semibold py-2 px-4 rounded-lg hover:bg-tech-blue/20 transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
<i data-lucide="flask-conical" class="w-4 h-4"></i>
|
||||
<span>探索可能发展的技能</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal Quotes Module -->
|
||||
<div class="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="font-bold text-text-dark text-lg">个人语录</h2>
|
||||
<button
|
||||
@click="addQuote"
|
||||
class="text-text-medium hover:text-tech-blue transition-colors"
|
||||
title="添加语录"
|
||||
>
|
||||
<i data-lucide="plus-square" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="quotes-container" class="space-y-4">
|
||||
<div class="bg-gray-50 p-4 rounded-lg border-l-4 border-tech-blue">
|
||||
<p class="text-text-dark italic">"每一天都是新的开始,每一次尝试都是成长的机会。"</p>
|
||||
<p class="text-text-medium text-xs mt-2">2024年1月15日</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg border-l-4 border-warm-orange">
|
||||
<p class="text-text-dark italic">"困难是成长路上的垫脚石,而不是绊脚石。"</p>
|
||||
<p class="text-text-medium text-xs mt-2">2024年1月10日</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic modules will be added here -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Add custom module button -->
|
||||
<div class="mt-6 text-center">
|
||||
<button
|
||||
@click="addCustomModule"
|
||||
class="bg-warm-orange text-white px-6 py-3 rounded-full font-semibold hover:bg-orange-600 transition-all duration-300 transform hover:scale-105 shadow-lg shadow-orange-500/30 flex items-center justify-center space-x-2 mx-auto"
|
||||
>
|
||||
<i data-lucide="layout-template" class="w-5 h-5"></i>
|
||||
<span>自由添加模块</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- App Navigation -->
|
||||
<BottomNavigation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
|
||||
|
||||
// 响应式数据
|
||||
const interests = ref(['阅读', '音乐', '旅行', '摄影'])
|
||||
const skills = ref(['烹饪', '绘画', '编程'])
|
||||
const quotes = ref([
|
||||
{
|
||||
text: '"每一天都是新的开始,每一次尝试都是成长的机会。"',
|
||||
date: '2024年1月15日',
|
||||
color: 'tech-blue'
|
||||
},
|
||||
{
|
||||
text: '"困难是成长路上的垫脚石,而不是绊脚石。"',
|
||||
date: '2024年1月10日',
|
||||
color: 'warm-orange'
|
||||
}
|
||||
])
|
||||
|
||||
// 添加兴趣
|
||||
const addInterest = () => {
|
||||
// TODO: 实现添加兴趣逻辑
|
||||
console.log('添加兴趣')
|
||||
}
|
||||
|
||||
// 探索兴趣
|
||||
const exploreInterests = () => {
|
||||
// TODO: 实现探索兴趣逻辑
|
||||
console.log('探索兴趣')
|
||||
}
|
||||
|
||||
// 添加技能
|
||||
const addSkill = () => {
|
||||
// TODO: 实现添加技能逻辑
|
||||
console.log('添加技能')
|
||||
}
|
||||
|
||||
// 探索技能
|
||||
const exploreSkills = () => {
|
||||
// TODO: 实现探索技能逻辑
|
||||
console.log('探索技能')
|
||||
}
|
||||
|
||||
// 添加语录
|
||||
const addQuote = () => {
|
||||
// TODO: 实现添加语录逻辑
|
||||
console.log('添加语录')
|
||||
}
|
||||
|
||||
// 添加自定义模块
|
||||
const addCustomModule = () => {
|
||||
// TODO: 实现添加自定义模块逻辑
|
||||
console.log('添加自定义模块')
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
|
||||
// TODO: 初始化心情图表
|
||||
// 这里可以集成Chart.js来绘制心情统计图表
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导入原始样式变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* 应用原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
.personal-dashboard-page {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--text-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
+23
-547
@@ -1,563 +1,39 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<!-- 头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="$router.back()" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</a-button>
|
||||
<h1 class="page-title">个人中心</h1>
|
||||
</div>
|
||||
<a-button type="text" @click="handleLogout" class="logout-btn">
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</a-button>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">个人中心</h1>
|
||||
<p class="text-gray-600">管理个人信息,查看情绪档案</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-main">
|
||||
<div class="container">
|
||||
<!-- 用户信息卡片 -->
|
||||
<a-card class="user-info-card" :loading="loading">
|
||||
<div class="user-header">
|
||||
<div class="avatar-section">
|
||||
<a-avatar :size="80" :src="userInfo?.avatar" class="user-avatar">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<a-button type="link" size="small" @click="showAvatarModal = true">
|
||||
更换头像
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<h2 class="username">{{ userInfo?.nickname || userInfo?.username || '未设置昵称' }}</h2>
|
||||
<p class="user-account">账号:{{ userInfo?.account }}</p>
|
||||
<p class="user-status">
|
||||
<a-tag color="green">
|
||||
正常
|
||||
</a-tag>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 功能菜单 -->
|
||||
<div class="menu-section">
|
||||
<a-card title="账户管理" class="menu-card">
|
||||
<div class="menu-list">
|
||||
<div class="menu-item" @click="showEditProfileModal = true">
|
||||
<EditOutlined class="menu-icon" />
|
||||
<span class="menu-text">编辑个人信息</span>
|
||||
<RightOutlined class="menu-arrow" />
|
||||
</div>
|
||||
<div class="menu-item" @click="showChangePasswordModal = true">
|
||||
<LockOutlined class="menu-icon" />
|
||||
<span class="menu-text">修改密码</span>
|
||||
<RightOutlined class="menu-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="应用设置" class="menu-card">
|
||||
<div class="menu-list">
|
||||
<div class="menu-item" @click="$router.push('/settings')">
|
||||
<SettingOutlined class="menu-icon" />
|
||||
<span class="menu-text">系统设置</span>
|
||||
<RightOutlined class="menu-arrow" />
|
||||
</div>
|
||||
<div class="menu-item" @click="showAboutModal = true">
|
||||
<InfoCircleOutlined class="menu-icon" />
|
||||
<span class="menu-text">关于应用</span>
|
||||
<RightOutlined class="menu-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<a-card title="使用统计" class="stats-card">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ stats.loginCount || 0 }}</div>
|
||||
<div class="stat-label">登录次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ stats.chatCount || 0 }}</div>
|
||||
<div class="stat-label">聊天次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ stats.diaryCount || 0 }}</div>
|
||||
<div class="stat-label">日记数量</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ formatDate(userInfo?.createTime || '') }}</div>
|
||||
<div class="stat-label">注册时间</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 编辑个人信息模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showEditProfileModal"
|
||||
title="编辑个人信息"
|
||||
@ok="handleUpdateProfile"
|
||||
:confirm-loading="updateLoading"
|
||||
>
|
||||
<a-form :model="profileForm" layout="vertical">
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model:value="profileForm.nickname" placeholder="请输入昵称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱">
|
||||
<a-input v-model:value="profileForm.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号">
|
||||
<a-input v-model:value="profileForm.phone" placeholder="请输入手机号" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 修改密码模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showChangePasswordModal"
|
||||
title="修改密码"
|
||||
@ok="handleChangePassword"
|
||||
:confirm-loading="passwordLoading"
|
||||
>
|
||||
<a-form :model="passwordForm" layout="vertical">
|
||||
<a-form-item label="当前密码">
|
||||
<a-input-password v-model:value="passwordForm.oldPassword" placeholder="请输入当前密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password v-model:value="passwordForm.newPassword" placeholder="请输入新密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码">
|
||||
<a-input-password v-model:value="passwordForm.confirmPassword" placeholder="请再次输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 更换头像模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAvatarModal"
|
||||
title="更换头像"
|
||||
@ok="handleUpdateAvatar"
|
||||
:confirm-loading="avatarLoading"
|
||||
>
|
||||
<div class="avatar-upload">
|
||||
<a-upload
|
||||
v-model:file-list="avatarFileList"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
>
|
||||
<div v-if="avatarUrl">
|
||||
<img :src="avatarUrl" alt="avatar" style="width: 100%; height: 100%; object-fit: cover;" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">上传头像</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 关于应用模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAboutModal"
|
||||
title="关于应用"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="about-content">
|
||||
<div class="app-info">
|
||||
<h3>情感博物馆</h3>
|
||||
<p>版本:v1.0.0</p>
|
||||
<p>一个专注于情感记录与分析的智能应用</p>
|
||||
<div class="card">
|
||||
<div class="text-center text-gray-500 py-12">
|
||||
<el-icon class="text-4xl mb-4">
|
||||
<User />
|
||||
</el-icon>
|
||||
<p>个人中心功能开发中...</p>
|
||||
<p class="text-sm mt-2">这里将展示用户信息、情绪统计等内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
UserOutlined,
|
||||
EditOutlined,
|
||||
LockOutlined,
|
||||
SettingOutlined,
|
||||
InfoCircleOutlined,
|
||||
RightOutlined,
|
||||
LogoutOutlined,
|
||||
PlusOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
// import { authService } from '@/services/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const updateLoading = ref(false)
|
||||
const passwordLoading = ref(false)
|
||||
const avatarLoading = ref(false)
|
||||
|
||||
// 模态框显示状态
|
||||
const showEditProfileModal = ref(false)
|
||||
const showChangePasswordModal = ref(false)
|
||||
const showAvatarModal = ref(false)
|
||||
const showAboutModal = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const profileForm = reactive({
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 头像相关
|
||||
const avatarFileList = ref([])
|
||||
const avatarUrl = ref('')
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
loginCount: 0,
|
||||
chatCount: 0,
|
||||
diaryCount: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
// 方法
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '未知'
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
message.success('退出登录成功')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
message.error('退出登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
updateLoading.value = true
|
||||
try {
|
||||
// TODO: 调用更新个人信息API
|
||||
message.success('个人信息更新成功')
|
||||
showEditProfileModal.value = false
|
||||
} catch (error) {
|
||||
message.error('更新失败')
|
||||
} finally {
|
||||
updateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
message.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
// TODO: 调用修改密码API
|
||||
message.success('密码修改成功')
|
||||
showChangePasswordModal.value = false
|
||||
// 清空表单
|
||||
Object.assign(passwordForm, {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
} catch (error) {
|
||||
message.error('密码修改失败')
|
||||
} finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const beforeAvatarUpload = (file: File) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isJpgOrPng) {
|
||||
message.error('只能上传 JPG/PNG 格式的图片!')
|
||||
return false
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过 2MB!')
|
||||
return false
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
avatarUrl.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
const handleUpdateAvatar = async () => {
|
||||
avatarLoading.value = true
|
||||
try {
|
||||
// TODO: 调用上传头像API
|
||||
message.success('头像更新成功')
|
||||
showAvatarModal.value = false
|
||||
} catch (error) {
|
||||
message.error('头像更新失败')
|
||||
} finally {
|
||||
avatarLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
const initData = () => {
|
||||
if (userInfo.value) {
|
||||
profileForm.nickname = userInfo.value.nickname || ''
|
||||
profileForm.email = userInfo.value.email || ''
|
||||
profileForm.phone = userInfo.value.phone || ''
|
||||
avatarUrl.value = userInfo.value.avatar || ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initData()
|
||||
// TODO: 加载统计数据
|
||||
})
|
||||
import { User } from '@element-plus/icons-vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
padding: 0 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.back-btn, .logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
.profile-page .container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 24px 16px;
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.user-avatar {
|
||||
border: 2px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
|
||||
.username {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.user-account {
|
||||
margin: 0 0 8px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.menu-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.menu-list {
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fafafa;
|
||||
margin: 0 -16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 20px;
|
||||
color: #666;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
flex: 1;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #bfbfbf;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
:deep(.ant-upload-select) {
|
||||
width: 120px !important;
|
||||
height: 120px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.about-content {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
|
||||
.app-info {
|
||||
h3 {
|
||||
color: #1890ff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.page-main {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
+426
-250
@@ -1,97 +1,106 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<div class="register-container">
|
||||
<div class="register-card">
|
||||
<!-- Logo和标题 -->
|
||||
<div class="register-header">
|
||||
<router-link to="/" class="logo">
|
||||
<span class="logo-text">开心APP</span>
|
||||
</router-link>
|
||||
<h1 class="register-title">创建账户</h1>
|
||||
<p class="register-subtitle">加入开心APP,开始您的情绪陪伴之旅</p>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-md mx-auto card">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">加入情绪博物馆</h1>
|
||||
<p class="text-white/80">只需几步,开启你的情绪探索之旅</p>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<a-form
|
||||
<el-form
|
||||
ref="registerFormRef"
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
@finish="handleRegister"
|
||||
@finishFailed="handleRegisterFailed"
|
||||
layout="vertical"
|
||||
class="register-form"
|
||||
label-width="0"
|
||||
@submit.prevent="handleRegister"
|
||||
>
|
||||
<a-form-item label="账号" name="account">
|
||||
<a-input
|
||||
v-model:value="registerForm.account"
|
||||
placeholder="请输入手机号或邮箱"
|
||||
<el-form-item prop="nickname">
|
||||
<el-input
|
||||
v-model="registerForm.nickname"
|
||||
placeholder="请输入昵称"
|
||||
:prefix-icon="Avatar"
|
||||
size="large"
|
||||
:prefix="h(UserOutlined)"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
/>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<a-form-item label="密码" name="password">
|
||||
<a-input-password
|
||||
v-model:value="registerForm.password"
|
||||
placeholder="请输入密码"
|
||||
<el-form-item prop="account">
|
||||
<el-input
|
||||
v-model="registerForm.account"
|
||||
placeholder="请输入账号(4-20位字母数字下划线)"
|
||||
:prefix-icon="User"
|
||||
size="large"
|
||||
:prefix="h(LockOutlined)"
|
||||
autocomplete="new-password"
|
||||
clearable
|
||||
@blur="checkAccountExists"
|
||||
/>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<a-form-item label="确认密码" name="confirmPassword">
|
||||
<a-input-password
|
||||
v-model:value="registerForm.confirmPassword"
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码(6-20位)"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
:prefix="h(LockOutlined)"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<a-form-item label="验证码" name="captcha">
|
||||
<div class="captcha-container">
|
||||
<a-input
|
||||
v-model:value="registerForm.captcha"
|
||||
<el-form-item prop="captcha">
|
||||
<div class="flex gap-3">
|
||||
<el-input
|
||||
v-model="registerForm.captcha"
|
||||
placeholder="请输入验证码"
|
||||
:prefix-icon="Key"
|
||||
size="large"
|
||||
style="flex: 1"
|
||||
clearable
|
||||
class="flex-1"
|
||||
/>
|
||||
<div class="captcha-image" @click="refreshCaptcha">
|
||||
<div class="captcha-container" @click="refreshCaptcha">
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
style="width: 100%; height: 100%; cursor: pointer;"
|
||||
class="captcha-image"
|
||||
/>
|
||||
<div v-else class="captcha-loading">
|
||||
<a-spin size="small" />
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-tip">点击图片刷新验证码</div>
|
||||
</a-form-item>
|
||||
</el-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
:loading="registerLoading"
|
||||
class="register-button"
|
||||
block
|
||||
class="w-full register-btn"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
>
|
||||
注册
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{ loading ? '注册中...' : '立即注册' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
<div class="login-link">
|
||||
已有账户?
|
||||
<router-link to="/login" class="login-btn">立即登录</router-link>
|
||||
<div class="text-center mt-6">
|
||||
<span class="text-white/70">已有账号?</span>
|
||||
<router-link to="/login" class="text-white font-medium hover:underline ml-1">
|
||||
立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,219 +108,386 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { authService } from '@/services/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { RegisterRequest } from '@/types/auth'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElForm } from 'element-plus'
|
||||
import {
|
||||
User,
|
||||
UserFilled,
|
||||
Avatar,
|
||||
Message,
|
||||
Phone,
|
||||
Lock,
|
||||
Key,
|
||||
Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AuthService from '@/services/auth'
|
||||
import { envConfig } from '@/config/env'
|
||||
import type { RegisterRequest } from '@/types/auth'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 表单数据
|
||||
const registerForm = reactive<RegisterRequest>({
|
||||
account: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
captcha: ''
|
||||
})
|
||||
// 表单引用
|
||||
const registerFormRef = ref<FormInstance>()
|
||||
|
||||
// 表单验证规则
|
||||
const registerRules = {
|
||||
account: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||
{ min: 3, message: '账号长度不能少于3位', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
if (value !== registerForm.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 4, message: '验证码长度为4位', trigger: 'blur' }
|
||||
]
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 验证码相关
|
||||
const captchaImage = ref('')
|
||||
const captchaKey = ref('')
|
||||
|
||||
// 注册表单数据(简化版)
|
||||
const registerForm = reactive({
|
||||
account: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
nickname: '',
|
||||
captcha: '',
|
||||
captchaKey: ''
|
||||
})
|
||||
|
||||
// 自定义验证规则
|
||||
const validateAccount = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入账号'))
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]{4,20}$/.test(value)) {
|
||||
callback(new Error('账号只能包含字母、数字和下划线,长度4-20位'))
|
||||
return
|
||||
}
|
||||
// 账号唯一性校验建议在@blur时单独提示,不在rules中异步校验
|
||||
callback()
|
||||
}
|
||||
|
||||
const validatePassword = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入密码'))
|
||||
return
|
||||
}
|
||||
if (value.length < 6 || value.length > 20) {
|
||||
callback(new Error('密码长度必须在6-20位之间'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请再次输入密码'))
|
||||
return
|
||||
}
|
||||
if (value !== registerForm.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
// 移除了邮箱和手机号验证,简化注册流程
|
||||
|
||||
// 表单验证规则(简化版)
|
||||
const registerRules: FormRules = {
|
||||
nickname: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
||||
{ min: 1, max: 20, message: '昵称长度必须在1-20位之间', trigger: 'blur' }
|
||||
],
|
||||
account: [{ validator: validateAccount, trigger: 'blur' }],
|
||||
password: [{ validator: validatePassword, trigger: 'blur' }],
|
||||
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ min: 4, max: 6, message: '验证码长度不正确', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
const response = await AuthService.getCaptcha()
|
||||
// 后端返回的数据已经包含了 data:image/png;base64, 前缀,直接使用
|
||||
captchaImage.value = response.captchaImage
|
||||
captchaKey.value = response.captchaKey
|
||||
registerForm.captchaKey = response.captchaKey
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
ElMessage.error('获取验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新验证码
|
||||
*/
|
||||
const refreshCaptcha = () => {
|
||||
registerForm.captcha = ''
|
||||
getCaptcha()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号是否存在
|
||||
*/
|
||||
const checkAccountExists = async () => {
|
||||
if (!registerForm.account || !/^[a-zA-Z0-9_]{4,20}$/.test(registerForm.account)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 状态
|
||||
const registerLoading = ref(false)
|
||||
const captchaImage = ref('')
|
||||
const captchaKey = ref('')
|
||||
|
||||
// 获取验证码
|
||||
const getCaptcha = async () => {
|
||||
try {
|
||||
const response = await authService.getCaptcha()
|
||||
console.log('验证码响应:', response)
|
||||
captchaImage.value = response.captchaImage // 修正字段
|
||||
captchaKey.value = response.captchaKey // 修正字段
|
||||
console.log('验证码图片URL:', captchaImage.value.substring(0, 50) + '...')
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
message.error('获取验证码失败')
|
||||
try {
|
||||
const exists = await AuthService.checkAccountExists(registerForm.account)
|
||||
if (exists) {
|
||||
ElMessage.warning('该账号已存在')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查账号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = () => {
|
||||
getCaptcha()
|
||||
}
|
||||
// 移除了邮箱和手机号检查方法,简化注册流程
|
||||
|
||||
// 注册处理
|
||||
const handleRegister = async (values: RegisterRequest) => {
|
||||
registerLoading.value = true
|
||||
try {
|
||||
const registerData = {
|
||||
...values,
|
||||
captchaKey: captchaKey.value
|
||||
}
|
||||
const data = await authService.register(registerData)
|
||||
message.success('注册成功,已自动登录')
|
||||
userStore.setToken(data.accessToken)
|
||||
userStore.setUserInfo(data.userInfo)
|
||||
/**
|
||||
* 处理注册
|
||||
*/
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
|
||||
try {
|
||||
console.log('开始注册流程...')
|
||||
console.log('注册表单数据:', registerForm)
|
||||
|
||||
// 表单验证
|
||||
await registerFormRef.value.validate()
|
||||
console.log('表单验证通过')
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 构造注册请求数据
|
||||
const registerData = {
|
||||
account: registerForm.account,
|
||||
password: registerForm.password,
|
||||
confirmPassword: registerForm.confirmPassword,
|
||||
username: registerForm.nickname, // 使用昵称作为用户名
|
||||
nickname: registerForm.nickname,
|
||||
email: '', // 暂时为空,后续在个人页面完善
|
||||
phone: '', // 暂时为空,后续在个人页面完善
|
||||
captcha: registerForm.captcha,
|
||||
captchaKey: registerForm.captchaKey
|
||||
}
|
||||
|
||||
// 调用注册接口
|
||||
console.log('调用注册接口...')
|
||||
const success = await authStore.register(registerData)
|
||||
console.log('注册结果:', success)
|
||||
|
||||
if (success) {
|
||||
// 注册成功,跳转到首页
|
||||
console.log('注册成功,跳转到首页')
|
||||
ElMessage.success('注册成功,欢迎加入情绪博物馆!')
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '注册失败,请稍后重试')
|
||||
} else {
|
||||
// 注册失败,刷新验证码
|
||||
console.log('注册失败,刷新验证码')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
registerLoading.value = false
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('注册过程中发生错误:', error)
|
||||
|
||||
// 注册失败处理
|
||||
const handleRegisterFailed = (errorInfo: any) => {
|
||||
console.log('Register failed:', errorInfo)
|
||||
}
|
||||
// 根据错误类型显示不同的提示
|
||||
let errorMessage = '注册失败,请稍后重试'
|
||||
if (error.message) {
|
||||
if (error.message.includes('账号已存在')) {
|
||||
errorMessage = '该账号已被注册,请更换账号或直接登录'
|
||||
} else if (error.message.includes('邮箱已存在')) {
|
||||
errorMessage = '该邮箱已被注册,请更换邮箱或直接登录'
|
||||
} else if (error.message.includes('手机号已存在')) {
|
||||
errorMessage = '该手机号已被注册,请更换手机号或直接登录'
|
||||
} else if (error.message.includes('验证码')) {
|
||||
errorMessage = '验证码错误,请重新输入'
|
||||
} else {
|
||||
errorMessage = error.message
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
getCaptcha()
|
||||
})
|
||||
ElMessage.error(errorMessage)
|
||||
// 刷新验证码
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取验证码
|
||||
onMounted(() => {
|
||||
getCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
<style scoped>
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-attachment: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
padding: 3rem 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.captcha-container {
|
||||
width: 140px;
|
||||
height: 48px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.captcha-container:hover {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.captcha-loading {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-input--large .el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
:deep(.el-input--large .el-input__wrapper:hover) {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
:deep(.el-input--large .el-input__wrapper.is-focus) {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:deep(.el-input--large .el-input__inner) {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-input--large .el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:deep(.el-input--large .el-input__prefix) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:deep(.el-input--large .el-input__suffix) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
height: 48px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
:deep(.register-btn:hover) {
|
||||
background: linear-gradient(135deg, #ff5252 0%, #d63031 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
:deep(.register-btn:active) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__error) {
|
||||
color: #ffcdd2;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
.container {
|
||||
padding: 2rem 1.5rem;
|
||||
border-radius: 20px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
.captcha-container {
|
||||
width: 120px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.logo {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
color: #4A90E2;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.register-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.register-subtitle {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.register-form {
|
||||
.captcha-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #4A90E2;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.captcha-tip {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.register-button {
|
||||
background: linear-gradient(135deg, #4A90E2 0%, #5BA0F2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
|
||||
.login-btn {
|
||||
color: #4A90E2;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.container {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
</style>
|
||||
+546
-551
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="social-page">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">社交分享</h1>
|
||||
<p class="text-gray-600">与朋友分享情绪故事,获得支持</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 发布动态 -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">发布动态</h2>
|
||||
<div class="space-y-4">
|
||||
<el-input
|
||||
v-model="postContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="分享你的情绪故事..."
|
||||
/>
|
||||
<div class="flex justify-between items-center">
|
||||
<el-button type="primary" @click="publishPost">
|
||||
发布动态
|
||||
</el-button>
|
||||
<div class="text-sm text-gray-500">
|
||||
还可以输入 {{ 500 - postContent.length }} 字
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态列表 -->
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-semibold mb-4">最新动态</h2>
|
||||
<div class="text-center text-gray-500 py-12">
|
||||
<el-icon class="text-4xl mb-4">
|
||||
<Share />
|
||||
</el-icon>
|
||||
<p>社交动态功能开发中...</p>
|
||||
<p class="text-sm mt-2">这里将显示用户发布的情绪动态</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Share } from '@element-plus/icons-vue'
|
||||
|
||||
const postContent = ref('')
|
||||
|
||||
const publishPost = () => {
|
||||
// TODO: 实现发布动态的逻辑
|
||||
console.log('发布动态:', postContent.value)
|
||||
postContent.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.social-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.social-page .container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,700 +1,299 @@
|
||||
<template>
|
||||
<div class="topic-tracker-page">
|
||||
<!-- 头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="$router.back()" class="back-btn">
|
||||
<ArrowLeftOutlined />
|
||||
</a-button>
|
||||
<h1 class="page-title">话题追踪</h1>
|
||||
<div class="topic-tracker-page antialiased flex flex-col min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-md z-10 flex-shrink-0">
|
||||
<div class="container mx-auto px-4 py-3 flex items-center justify-between relative">
|
||||
<div class="flex items-center space-x-4">
|
||||
<router-link to="/" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="home" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
<router-link to="/messages" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="bell" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
<a-button type="primary" @click="showNewTopicModal = true" class="new-topic-btn">
|
||||
<PlusOutlined />
|
||||
新建话题
|
||||
</a-button>
|
||||
<h1 class="text-lg font-bold text-text-dark absolute left-1/2 -translate-x-1/2">话题追踪</h1>
|
||||
<router-link to="/settings" class="text-text-medium hover:text-tech-blue transition-colors">
|
||||
<i data-lucide="user" class="w-6 h-6"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="page-main">
|
||||
<div class="container">
|
||||
<!-- 筛选和搜索 -->
|
||||
<div class="filter-section">
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索话题..."
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="statusFilter"
|
||||
placeholder="状态筛选"
|
||||
style="width: 120px"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="">全部</a-select-option>
|
||||
<a-select-option value="active">进行中</a-select-option>
|
||||
<a-select-option value="completed">已完成</a-select-option>
|
||||
<a-select-option value="paused">已暂停</a-select-option>
|
||||
</a-select>
|
||||
<main class="flex-grow pt-8 pb-28">
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="text-center mb-12 animate-fade-in-up">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-text-dark">洞察你的思绪,整理你的生活</h1>
|
||||
<p class="text-base text-text-medium mt-3 max-w-2xl mx-auto">开开会自动梳理你最近关心的事,你也可以手动创建任何想追踪的话题,见证自己的思考与成长。</p>
|
||||
</div>
|
||||
|
||||
<!-- 话题列表 -->
|
||||
<div class="topics-grid">
|
||||
<div
|
||||
v-for="topic in filteredTopics"
|
||||
:key="topic.id"
|
||||
class="topic-card"
|
||||
@click="viewTopicDetail(topic)"
|
||||
>
|
||||
<a-card :hoverable="true">
|
||||
<div class="topic-header">
|
||||
<div class="topic-status">
|
||||
<a-tag :color="getStatusColor(topic.status)">
|
||||
{{ getStatusText(topic.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<a-dropdown @click.stop>
|
||||
<a-button type="text" size="small">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="editTopic(topic)">
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="toggleTopicStatus(topic)">
|
||||
<PlayCircleOutlined v-if="topic.status === 'paused'" />
|
||||
<PauseCircleOutlined v-else />
|
||||
{{ topic.status === 'paused' ? '继续' : '暂停' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="deleteTopic(topic.id)" danger>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="topic-content">
|
||||
<h3 class="topic-title">{{ topic.title }}</h3>
|
||||
<p class="topic-description" v-if="topic.description">
|
||||
{{ topic.description }}
|
||||
</p>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="topic-progress" v-if="topic.progress !== undefined">
|
||||
<div class="progress-info">
|
||||
<span class="progress-label">进度</span>
|
||||
<span class="progress-value">{{ topic.progress }}%</span>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-12">
|
||||
<div class="lg:col-span-3 animate-fade-in-up" style="animation-delay: 0.2s;">
|
||||
<div class="flex items-center mb-6 space-x-3">
|
||||
<i data-lucide="brain-circuit" class="w-8 h-8 text-tech-blue"></i>
|
||||
<h2 class="text-2xl font-bold text-text-dark">AI 自动总结</h2>
|
||||
</div>
|
||||
<div id="ai-summary-list" class="space-y-6">
|
||||
<!-- AI总结的话题将在这里显示 -->
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-200/50">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-3 h-3 bg-tech-blue rounded-full"></div>
|
||||
<h3 class="text-lg font-semibold text-text-dark">工作计划</h3>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="topic.progress"
|
||||
:stroke-color="getStatusColor(topic.status)"
|
||||
:show-info="false"
|
||||
size="small"
|
||||
/>
|
||||
<span class="text-xs text-text-medium bg-gray-100 px-2 py-1 rounded-full">AI 总结</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="topic-tags" v-if="topic.tags && topic.tags.length">
|
||||
<a-tag
|
||||
v-for="tag in topic.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
size="small"
|
||||
class="topic-tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
<span v-if="topic.tags.length > 3" class="more-tags">
|
||||
+{{ topic.tags.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<div class="topic-meta">
|
||||
<span class="topic-date">
|
||||
<CalendarOutlined />
|
||||
{{ formatTime.friendly(topic.createTime) }}
|
||||
</span>
|
||||
<span class="topic-update" v-if="topic.updateTime !== topic.createTime">
|
||||
更新于 {{ formatTime.friendly(topic.updateTime) }}
|
||||
</span>
|
||||
<p class="text-text-medium text-sm mb-4">基于你最近的对话,开开发现你在关注工作效率和时间管理相关的话题。</p>
|
||||
<div class="space-y-3">
|
||||
<div class="border-l-2 border-tech-blue pl-4">
|
||||
<p class="text-sm text-text-dark">讨论了新项目的时间安排</p>
|
||||
<span class="text-xs text-text-medium">3天前</span>
|
||||
</div>
|
||||
<div class="border-l-2 border-tech-blue pl-4">
|
||||
<p class="text-sm text-text-dark">分享了提高工作效率的方法</p>
|
||||
<span class="text-xs text-text-medium">1周前</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredTopics.length === 0" class="empty-state">
|
||||
<a-empty
|
||||
description="暂无话题记录"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
>
|
||||
<a-button type="primary" @click="showNewTopicModal = true">
|
||||
创建第一个话题
|
||||
</a-button>
|
||||
</a-empty>
|
||||
<div class="lg:col-span-2 animate-fade-in-up" style="animation-delay: 0.4s;">
|
||||
<div class="bg-white p-6 sm:p-8 rounded-2xl shadow-lg border border-gray-200/50 scroll-mt-24">
|
||||
<div class="flex items-center mb-6 space-x-3">
|
||||
<i data-lucide="plus-circle" class="w-8 h-8 text-warm-orange"></i>
|
||||
<h2 class="text-2xl font-bold text-text-dark">我的话题</h2>
|
||||
</div>
|
||||
<form @submit.prevent="createTopic" class="space-y-4">
|
||||
<div>
|
||||
<label for="topic-title" class="block text-sm font-medium text-text-medium mb-1">话题标题</label>
|
||||
<input
|
||||
type="text"
|
||||
id="topic-title"
|
||||
v-model="newTopic.title"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-tech-blue focus:border-transparent transition"
|
||||
placeholder="例如:暑期健身计划"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="topic-content" class="block text-sm font-medium text-text-medium mb-1">初始内容</label>
|
||||
<textarea
|
||||
id="topic-content"
|
||||
v-model="newTopic.content"
|
||||
rows="4"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-tech-blue focus:border-transparent transition"
|
||||
placeholder="写下你的计划、想法或任何琐事..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-warm-orange text-white px-5 py-3 rounded-lg font-semibold hover:bg-orange-600 transition-all duration-300 transform hover:scale-105 shadow-lg shadow-orange-500/30"
|
||||
>
|
||||
创建新话题
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 border-t border-gray-200 pt-6">
|
||||
<div class="flex items-center mb-4 space-x-3">
|
||||
<i data-lucide="list" class="w-6 h-6 text-text-medium"></i>
|
||||
<h3 class="text-xl font-semibold text-text-dark">已创建的话题</h3>
|
||||
</div>
|
||||
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
|
||||
<!-- 用户创建的话题列表 -->
|
||||
<div
|
||||
v-for="topic in userTopics"
|
||||
:key="topic.id"
|
||||
@click="openTopicDetail(topic)"
|
||||
class="p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<h4 class="font-semibold text-text-dark text-sm">{{ topic.title }}</h4>
|
||||
<p class="text-xs text-text-medium mt-1">{{ topic.date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 新建话题模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showNewTopicModal"
|
||||
title="新建话题"
|
||||
@ok="createTopic"
|
||||
@cancel="resetTopicForm"
|
||||
:confirm-loading="isCreating"
|
||||
width="600px"
|
||||
<!-- App Navigation -->
|
||||
<BottomNavigation />
|
||||
|
||||
<!-- Topic Detail Modal -->
|
||||
<div
|
||||
v-if="showTopicModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
@click="closeTopicModal"
|
||||
>
|
||||
<a-form :model="topicForm" layout="vertical">
|
||||
<a-form-item label="话题标题" required>
|
||||
<a-input
|
||||
v-model:value="topicForm.title"
|
||||
placeholder="请输入话题标题"
|
||||
:maxlength="50"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="话题描述">
|
||||
<a-textarea
|
||||
v-model:value="topicForm.description"
|
||||
placeholder="请输入话题描述(可选)"
|
||||
:rows="3"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="标签">
|
||||
<div class="tags-input-section">
|
||||
<a-input
|
||||
v-model:value="newTagInput"
|
||||
placeholder="添加标签,按回车确认"
|
||||
@press-enter="addTag"
|
||||
style="margin-bottom: 8px"
|
||||
/>
|
||||
<div class="selected-tags" v-if="topicForm.tags.length">
|
||||
<a-tag
|
||||
v-for="tag in topicForm.tags"
|
||||
:key="tag"
|
||||
closable
|
||||
@close="removeTag(tag)"
|
||||
color="blue"
|
||||
>
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
<div
|
||||
class="bg-light-gray rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col"
|
||||
@click.stop
|
||||
>
|
||||
<div class="p-5 border-b bg-white rounded-t-2xl flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-text-dark">{{ selectedTopic?.title }}</h2>
|
||||
<p class="text-sm text-text-medium">{{ selectedTopic?.date }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="closeTopicModal"
|
||||
class="text-text-medium hover:text-tech-blue transition-colors p-1"
|
||||
>
|
||||
<i data-lucide="x" class="w-7 h-7"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div class="space-y-8">
|
||||
<div class="border-l-2 border-tech-blue pl-4">
|
||||
<p class="text-sm text-text-dark">{{ selectedTopic?.content }}</p>
|
||||
<span class="text-xs text-text-medium">{{ selectedTopic?.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="初始进度">
|
||||
<a-slider
|
||||
v-model:value="topicForm.progress"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:marks="{ 0: '0%', 25: '25%', 50: '50%', 75: '75%', 100: '100%' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 话题详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="selectedTopic?.title"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<div v-if="selectedTopic" class="topic-detail">
|
||||
<div class="detail-header">
|
||||
<a-tag :color="getStatusColor(selectedTopic.status)" size="large">
|
||||
{{ getStatusText(selectedTopic.status) }}
|
||||
</a-tag>
|
||||
<span class="detail-date">
|
||||
创建于 {{ formatTime.standard(selectedTopic.createTime) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-description" v-if="selectedTopic.description">
|
||||
<h4>描述</h4>
|
||||
<p>{{ selectedTopic.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-progress" v-if="selectedTopic.progress !== undefined">
|
||||
<h4>进度</h4>
|
||||
<a-progress
|
||||
:percent="selectedTopic.progress"
|
||||
:stroke-color="getStatusColor(selectedTopic.status)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="detail-tags" v-if="selectedTopic.tags && selectedTopic.tags.length">
|
||||
<h4>标签</h4>
|
||||
<div class="tags-list">
|
||||
<a-tag
|
||||
v-for="tag in selectedTopic.tags"
|
||||
:key="tag"
|
||||
color="blue"
|
||||
<div class="p-6 border-t bg-white rounded-b-2xl">
|
||||
<h3 class="text-base font-semibold text-text-dark mb-2">添加新进展</h3>
|
||||
<form @submit.prevent="addTopicEntry" class="flex items-start space-x-3">
|
||||
<textarea
|
||||
v-model="newEntry"
|
||||
rows="2"
|
||||
class="flex-1 w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-tech-blue transition-shadow text-sm"
|
||||
placeholder="为这个话题添加新进展..."
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-tech-blue text-white rounded-lg px-4 py-2 h-full font-semibold hover:bg-blue-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<a-button type="primary" @click="editTopic(selectedTopic)">
|
||||
<EditOutlined />
|
||||
编辑话题
|
||||
</a-button>
|
||||
<a-button @click="toggleTopicStatus(selectedTopic)">
|
||||
<PlayCircleOutlined v-if="selectedTopic.status === 'paused'" />
|
||||
<PauseCircleOutlined v-else />
|
||||
{{ selectedTopic.status === 'paused' ? '继续追踪' : '暂停追踪' }}
|
||||
</a-button>
|
||||
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航栏 -->
|
||||
<BottomNavigation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CalendarOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Empty, message } from 'ant-design-vue'
|
||||
import { formatTime } from '@/utils'
|
||||
import type { Topic } from '@/types'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BottomNavigation from '@/components/layout/BottomNavigation.vue'
|
||||
|
||||
// 响应式数据
|
||||
const showNewTopicModal = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const newTagInput = ref('')
|
||||
const selectedTopic = ref<Topic | null>(null)
|
||||
// 响应式数据
|
||||
const newTopic = ref({
|
||||
title: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
// 话题数据
|
||||
const topics = ref<Topic[]>([
|
||||
{
|
||||
id: '1',
|
||||
title: '学习Vue 3',
|
||||
description: '深入学习Vue 3的新特性和最佳实践',
|
||||
tags: ['前端', '学习', 'Vue'],
|
||||
createTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updateTime: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'active',
|
||||
progress: 65
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '健身计划',
|
||||
description: '制定并执行每周3次的健身计划',
|
||||
tags: ['健康', '运动', '计划'],
|
||||
createTime: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updateTime: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'active',
|
||||
progress: 40
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '读书笔记',
|
||||
description: '阅读《深度工作》并记录读书笔记',
|
||||
tags: ['阅读', '笔记', '自我提升'],
|
||||
createTime: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updateTime: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'completed',
|
||||
progress: 100
|
||||
const userTopics = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '暑期健身计划',
|
||||
content: '制定了详细的健身计划,包括每周的运动安排和饮食控制。',
|
||||
date: '2024年1月15日'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '学习新技能',
|
||||
content: '开始学习Vue.js和TypeScript,希望能在3个月内掌握基础知识。',
|
||||
date: '2024年1月10日'
|
||||
}
|
||||
])
|
||||
|
||||
const showTopicModal = ref(false)
|
||||
const selectedTopic = ref(null)
|
||||
const newEntry = ref('')
|
||||
|
||||
// 创建新话题
|
||||
const createTopic = () => {
|
||||
if (newTopic.value.title.trim() && newTopic.value.content.trim()) {
|
||||
const topic = {
|
||||
id: Date.now(),
|
||||
title: newTopic.value.title,
|
||||
content: newTopic.value.content,
|
||||
date: new Date().toLocaleDateString('zh-CN')
|
||||
}
|
||||
])
|
||||
|
||||
// 表单数据
|
||||
const topicForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [] as string[],
|
||||
progress: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const filteredTopics = computed(() => {
|
||||
let result = topics.value
|
||||
|
||||
// 关键词搜索
|
||||
if (searchKeyword.value) {
|
||||
result = result.filter(topic =>
|
||||
topic.title.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
|
||||
topic.description?.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
|
||||
topic.tags?.some(tag => tag.toLowerCase().includes(searchKeyword.value.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(topic => topic.status === statusFilter.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 方法
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
active: 'blue',
|
||||
completed: 'green',
|
||||
paused: 'orange'
|
||||
}
|
||||
return colors[status as keyof typeof colors] || 'default'
|
||||
userTopics.value.unshift(topic)
|
||||
newTopic.value = { title: '', content: '' }
|
||||
console.log('创建新话题:', topic)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts = {
|
||||
active: '进行中',
|
||||
completed: '已完成',
|
||||
paused: '已暂停'
|
||||
}
|
||||
return texts[status as keyof typeof texts] || status
|
||||
// 打开话题详情
|
||||
const openTopicDetail = (topic: any) => {
|
||||
selectedTopic.value = topic
|
||||
showTopicModal.value = true
|
||||
}
|
||||
|
||||
// 关闭话题详情
|
||||
const closeTopicModal = () => {
|
||||
showTopicModal.value = false
|
||||
selectedTopic.value = null
|
||||
newEntry.value = ''
|
||||
}
|
||||
|
||||
// 添加话题进展
|
||||
const addTopicEntry = () => {
|
||||
if (newEntry.value.trim()) {
|
||||
// TODO: 实现添加话题进展逻辑
|
||||
console.log('添加话题进展:', newEntry.value)
|
||||
newEntry.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在计算属性中处理
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
// 筛选逻辑已在计算属性中处理
|
||||
}
|
||||
|
||||
const createTopic = async () => {
|
||||
if (!topicForm.title.trim()) {
|
||||
message.warning('请输入话题标题')
|
||||
return
|
||||
}
|
||||
|
||||
isCreating.value = true
|
||||
try {
|
||||
const newTopic: Topic = {
|
||||
id: Date.now().toString(),
|
||||
title: topicForm.title.trim(),
|
||||
description: topicForm.description.trim() || undefined,
|
||||
tags: topicForm.tags.length ? topicForm.tags : undefined,
|
||||
createTime: new Date().toISOString(),
|
||||
updateTime: new Date().toISOString(),
|
||||
status: 'active',
|
||||
progress: topicForm.progress
|
||||
}
|
||||
|
||||
topics.value.unshift(newTopic)
|
||||
message.success('话题创建成功')
|
||||
showNewTopicModal.value = false
|
||||
resetTopicForm()
|
||||
} catch (error) {
|
||||
message.error('创建失败,请重试')
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetTopicForm = () => {
|
||||
topicForm.title = ''
|
||||
topicForm.description = ''
|
||||
topicForm.tags = []
|
||||
topicForm.progress = 0
|
||||
newTagInput.value = ''
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const tag = newTagInput.value.trim()
|
||||
if (tag && !topicForm.tags.includes(tag)) {
|
||||
topicForm.tags.push(tag)
|
||||
newTagInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
const index = topicForm.tags.indexOf(tag)
|
||||
if (index > -1) {
|
||||
topicForm.tags.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const viewTopicDetail = (topic: Topic) => {
|
||||
selectedTopic.value = topic
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
const editTopic = (_topic: Topic) => {
|
||||
// TODO: 实现编辑功能
|
||||
message.info('编辑功能开发中...')
|
||||
}
|
||||
|
||||
const toggleTopicStatus = (topic: Topic) => {
|
||||
if (topic.status === 'active') {
|
||||
topic.status = 'paused'
|
||||
message.success('话题已暂停')
|
||||
} else if (topic.status === 'paused') {
|
||||
topic.status = 'active'
|
||||
message.success('话题已继续')
|
||||
}
|
||||
topic.updateTime = new Date().toISOString()
|
||||
}
|
||||
|
||||
const deleteTopic = (id: string) => {
|
||||
const index = topics.value.findIndex(t => t.id === id)
|
||||
if (index > -1) {
|
||||
topics.value.splice(index, 1)
|
||||
message.success('话题删除成功')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
// 初始化数据加载
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
.topic-tracker-page {
|
||||
min-height: 100vh;
|
||||
background: $light-gray;
|
||||
<style scoped>
|
||||
/* 导入原始样式变量 */
|
||||
:root {
|
||||
--tech-blue: #4A90E2;
|
||||
--warm-orange: #F5A623;
|
||||
--white: #FFFFFF;
|
||||
--light-gray: #F7F8FA;
|
||||
--text-dark: #333333;
|
||||
--text-medium: #888888;
|
||||
}
|
||||
|
||||
/* 应用原始样式类 */
|
||||
.bg-tech-blue { background-color: var(--tech-blue); }
|
||||
.bg-warm-orange { background-color: var(--warm-orange); }
|
||||
.bg-light-gray { background-color: var(--light-gray); }
|
||||
.text-tech-blue { color: var(--tech-blue); }
|
||||
.text-text-dark { color: var(--text-dark); }
|
||||
.text-text-medium { color: var(--text-medium); }
|
||||
.border-tech-blue { border-color: var(--tech-blue); }
|
||||
|
||||
.topic-tracker-page {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
box-shadow: $shadow-sm;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: $text-medium;
|
||||
|
||||
&:hover {
|
||||
color: $tech-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-topic-btn {
|
||||
border-radius: $border-radius-full;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-lg;
|
||||
|
||||
@media (min-width: $breakpoint-md) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: $breakpoint-lg) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.topic-card {
|
||||
cursor: pointer;
|
||||
transition: transform $transition-normal;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.topic-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.topic-content {
|
||||
.topic-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-sm;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.topic-description {
|
||||
color: $text-medium;
|
||||
line-height: 1.5;
|
||||
margin-bottom: $spacing-md;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topic-progress {
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-xs;
|
||||
|
||||
.progress-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topic-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
.topic-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.more-tags {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-medium;
|
||||
|
||||
.topic-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: $spacing-xxl;
|
||||
}
|
||||
|
||||
// 模态框样式
|
||||
.tags-input-section {
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-detail {
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding-bottom: $spacing-md;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.detail-date {
|
||||
color: $text-medium;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-description,
|
||||
.detail-progress,
|
||||
.detail-tags {
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
h4 {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $text-dark;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user