重命名前端项目目录:web-flowith -> web

- 将前端项目目录从 web-flowith 重命名为 web,使目录结构更简洁
- 保持所有前端代码和配置文件不变
- 统一项目目录命名规范
This commit is contained in:
2025-07-24 22:20:19 +08:00
parent ca42a7d9a4
commit bbe8fcd776
57 changed files with 0 additions and 0 deletions
+642
View File
@@ -0,0 +1,642 @@
<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, computed, 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())
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>
.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>
+636
View File
@@ -0,0 +1,636 @@
<template>
<div class="chat-page">
<!-- 聊天头部 -->
<header class="chat-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<div class="chat-info">
<a-avatar
:src="kaikaiAvatar"
:size="40"
class="kaikai-avatar"
/>
<div class="chat-details">
<h1 class="chat-title">开开</h1>
<p class="chat-status">
<span
class="status-dot"
:class="{
'connected': chatStore.wsConnected,
'connecting': chatStore.connectionStatus === 'CONNECTING',
'disconnected': !chatStore.wsConnected
}"
></span>
{{ getConnectionStatusText() }}
</p>
</div>
</div>
</div>
<div class="header-right">
<a-button type="text" @click="showHistory = true" class="action-btn">
<HistoryOutlined />
</a-button>
</div>
</div>
</header>
<!-- 连接状态提示 -->
<div
v-if="!chatStore.wsConnected"
class="connection-alert"
:class="{ 'connecting': chatStore.connectionStatus === 'CONNECTING' }"
>
<div class="alert-content">
<span v-if="chatStore.connectionStatus === 'CONNECTING'">正在连接...</span>
<span v-else-if="chatStore.connectionStatus === 'ERROR'">连接失败正在重试...</span>
<span v-else>连接已断开正在重连...</span>
<a-button
v-if="chatStore.connectionStatus === 'DISCONNECTED'"
type="link"
size="small"
@click="chatStore.connectWebSocket()"
>
手动重连
</a-button>
</div>
</div>
<!-- 聊天消息区域 -->
<main class="chat-main" ref="chatMainRef">
<div class="messages-container">
<div
v-for="message in chatStore.messages"
:key="message.id"
class="message-wrapper"
:class="{ 'user-message': message.type === 'user' }"
>
<div class="message-bubble">
<div v-if="message.type === 'ai'" class="message-avatar">
<a-avatar :src="kaikaiAvatar" :size="32" />
</div>
<div class="message-content">
<div class="message-text">{{ message.content }}</div>
<div class="message-meta">
<span class="message-time">{{ formatTime.friendly(message.timestamp) }}</span>
<span v-if="message.type === 'user' && message.status" class="message-status" :class="message.status">
<template v-if="message.status === 'sending'">发送中</template>
<template v-else-if="message.status === 'sent'">已发送</template>
<template v-else-if="message.status === 'delivered'">已送达</template>
<template v-else-if="message.status === 'read'">已读</template>
<template v-else-if="message.status === 'failed'">发送失败</template>
</span>
</div>
</div>
</div>
</div>
<!-- AI 正在输入指示器 -->
<div v-if="chatStore.isTyping" class="message-wrapper">
<div class="message-bubble">
<div class="message-avatar">
<a-avatar :src="kaikaiAvatar" :size="32" />
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 消息输入区域 -->
<footer class="chat-footer">
<div class="input-container">
<a-input
v-model:value="messageInput"
:placeholder="getInputPlaceholder()"
class="message-input"
@press-enter="sendMessage"
:disabled="chatStore.isTyping || !chatStore.wsConnected"
/>
<a-button
type="primary"
@click="sendMessage"
:loading="chatStore.isTyping"
:disabled="!messageInput.trim() || !chatStore.wsConnected"
class="send-btn"
:title="chatStore.wsConnected ? '发送消息' : '连接已断开'"
>
<SendOutlined />
</a-button>
</div>
</footer>
<!-- 聊天历史抽屉 -->
<a-drawer
v-model:open="showHistory"
title="聊天记录"
placement="right"
:width="320"
>
<div class="history-content">
<div class="search-section">
<a-input
v-model:value="searchKeyword"
placeholder="搜索关键词..."
class="search-input"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-date-picker
v-model:value="searchDate"
placeholder="按日期查询"
class="date-picker"
style="width: 100%; margin-top: 12px;"
/>
</div>
<div class="history-messages">
<div
v-for="message in filteredMessages"
:key="message.id"
class="history-message"
:class="{ 'user': message.type === 'user' }"
>
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatTime.standard(message.timestamp) }}</div>
</div>
</div>
</div>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import {
ArrowLeftOutlined,
HistoryOutlined,
SendOutlined,
SearchOutlined,
} from '@ant-design/icons-vue'
import { useChatStore } from '@/stores'
import { formatTime } from '@/utils'
import type { Dayjs } from 'dayjs'
const chatStore = useChatStore()
// 响应式数据
const messageInput = ref('')
const showHistory = ref(false)
const searchKeyword = ref('')
const searchDate = ref<Dayjs | null>(null)
const chatMainRef = ref<HTMLElement>()
// 开开头像
const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
// 计算属性
const filteredMessages = computed(() => {
let messages = chatStore.messages
// 关键词搜索
if (searchKeyword.value) {
messages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
}
// 日期筛选
if (searchDate.value) {
const targetDate = searchDate.value.format('YYYY-MM-DD')
messages = messages.filter(msg => {
const msgDate = new Date(msg.timestamp).toISOString().split('T')[0]
return msgDate === targetDate
})
}
return messages
})
// 方法
const sendMessage = async () => {
if (!messageInput.value.trim() || chatStore.isTyping || !chatStore.wsConnected) return
const content = messageInput.value.trim()
messageInput.value = ''
await chatStore.sendMessage(content)
scrollToBottom()
}
// 获取连接状态文本
const getConnectionStatusText = () => {
switch (chatStore.connectionStatus) {
case 'CONNECTED':
return '在线'
case 'CONNECTING':
return '连接中...'
case 'DISCONNECTED':
return '离线'
case 'ERROR':
return '连接错误'
default:
return '未知状态'
}
}
// 获取输入框占位符
const getInputPlaceholder = () => {
if (!chatStore.wsConnected) {
return '连接已断开,请等待重连...'
}
if (chatStore.isTyping) {
return '开开正在输入...'
}
return '和开开说点什么...'
}
const scrollToBottom = () => {
nextTick(() => {
if (chatMainRef.value) {
chatMainRef.value.scrollTop = chatMainRef.value.scrollHeight
}
})
}
// 监听消息变化,自动滚动到底部
watch(
() => chatStore.messages.length,
() => {
scrollToBottom()
}
)
// 组件挂载
onMounted(() => {
chatStore.initChat()
scrollToBottom()
})
// 组件卸载
onUnmounted(() => {
// 断开WebSocket连接
chatStore.disconnectWebSocket()
})
</script>
<style lang="scss" scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background: $light-gray;
}
.chat-header {
background: white;
box-shadow: $shadow-sm;
z-index: 10;
flex-shrink: 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;
}
}
.chat-info {
display: flex;
align-items: center;
gap: $spacing-md;
}
.kaikai-avatar {
border: 2px solid white;
box-shadow: $shadow-sm;
}
.chat-details {
.chat-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.chat-status {
display: flex;
align-items: center;
gap: 6px;
font-size: $font-size-xs;
color: $text-medium;
margin: 0;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: background-color 0.3s ease;
&.connected {
background: #52c41a;
}
&.connecting {
background: #faad14;
animation: pulse 1.5s infinite;
}
&.disconnected {
background: #ff4d4f;
}
}
}
}
.header-right {
.action-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
}
.connection-alert {
background: #fff2f0;
border: 1px solid #ffccc7;
color: #ff4d4f;
padding: $spacing-sm $spacing-lg;
text-align: center;
font-size: $font-size-sm;
flex-shrink: 0;
&.connecting {
background: #fffbe6;
border-color: #ffe58f;
color: #faad14;
}
.alert-content {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
max-width: 800px;
margin: 0 auto;
}
}
.chat-main {
flex: 1;
overflow-y: auto;
padding: $spacing-lg;
}
.messages-container {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.message-wrapper {
display: flex;
&.user-message {
justify-content: flex-end;
.message-bubble {
flex-direction: row-reverse;
.message-content {
background: $tech-blue;
color: white;
border-radius: 18px 18px 4px 18px;
}
}
}
}
.message-bubble {
display: flex;
align-items: flex-start;
gap: $spacing-sm;
max-width: 70%;
}
.message-avatar {
flex-shrink: 0;
}
.message-content {
background: white;
border-radius: 18px 18px 18px 4px;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-sm;
.message-text {
line-height: 1.5;
word-wrap: break-word;
}
.message-meta {
display: flex;
align-items: center;
gap: $spacing-xs;
margin-top: $spacing-xs;
}
.message-time {
font-size: $font-size-xs;
color: rgba(255, 255, 255, 0.7);
.user-message & {
color: rgba(255, 255, 255, 0.7);
}
.message-wrapper:not(.user-message) & {
color: $text-medium;
}
}
.message-status {
font-size: $font-size-xs;
padding: 2px 6px;
border-radius: 10px;
&.sending {
color: #faad14;
background: rgba(250, 173, 20, 0.1);
}
&.sent {
color: #52c41a;
background: rgba(82, 196, 26, 0.1);
}
&.delivered {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
&.read {
color: #722ed1;
background: rgba(114, 46, 209, 0.1);
}
&.failed {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.1);
}
.user-message & {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.2);
}
}
}
.typing-indicator {
display: flex;
gap: 4px;
span {
width: 8px;
height: 8px;
background: $text-medium;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.chat-footer {
background: white;
border-top: 1px solid #e8e8e8;
padding: $spacing-md $spacing-lg;
flex-shrink: 0;
}
.input-container {
display: flex;
gap: $spacing-sm;
max-width: 800px;
margin: 0 auto;
align-items: flex-end;
}
.message-input {
flex: 1;
border-radius: $border-radius-full;
:deep(.ant-input) {
border-radius: $border-radius-full;
padding: $spacing-md $spacing-lg;
}
}
.send-btn {
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-md;
&:hover {
transform: scale(1.05);
}
}
// 历史记录样式
.history-content {
.search-section {
margin-bottom: $spacing-lg;
padding-bottom: $spacing-lg;
border-bottom: 1px solid #e8e8e8;
}
.history-messages {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.history-message {
padding: $spacing-md;
background: #f5f5f5;
border-radius: $border-radius-md;
&.user {
background: rgba(74, 144, 226, 0.1);
margin-left: $spacing-lg;
}
.message-text {
font-size: $font-size-sm;
line-height: 1.4;
margin-bottom: $spacing-xs;
}
.message-time {
font-size: $font-size-xs;
color: $text-medium;
}
}
}
</style>
+693
View File
@@ -0,0 +1,693 @@
<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
// 个人信息数据
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>
.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>
+502
View File
@@ -0,0 +1,502 @@
<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>
<a-button type="primary" @click="showNewEntryModal = true" class="new-entry-btn">
<PlusOutlined />
写日记
</a-button>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<!-- 快速写日记卡片 -->
<div class="quick-entry-section">
<a-card class="quick-entry-card" @click="showNewEntryModal = true">
<div class="quick-entry-content">
<div class="quick-entry-text">
<span class="placeholder-text">记录今天的心情...</span>
</div>
<a-button type="primary" size="small" class="quick-btn">
<PlusOutlined />
</a-button>
</div>
</a-card>
</div>
<!-- 日记列表 -->
<div class="diary-feed">
<div
v-for="entry in diaryStore.entries"
:key="entry.id"
class="diary-entry"
>
<a-card class="entry-card">
<div class="entry-header">
<div class="entry-meta">
<span class="entry-mood" v-if="entry.mood">
{{ getMoodEmoji(entry.mood) }}
</span>
<span class="entry-date">
{{ formatTime.friendly(entry.createTime) }}
</span>
</div>
<a-dropdown>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="editEntry(entry)">
<EditOutlined />
编辑
</a-menu-item>
<a-menu-item @click="deleteEntry(entry.id)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="entry-content">
<p class="entry-text">{{ entry.content }}</p>
<div class="entry-tags" v-if="entry.tags && entry.tags.length">
<a-tag
v-for="tag in entry.tags"
:key="tag"
color="blue"
class="entry-tag"
>
{{ tag }}
</a-tag>
</div>
</div>
<!-- AI回复 -->
<div class="ai-reply" v-if="entry.aiReply">
<div class="ai-avatar">
<a-avatar
:src="kaikaiAvatar"
:size="32"
/>
</div>
<div class="ai-content">
<div class="ai-name">开开的回复</div>
<p class="ai-text">{{ entry.aiReply }}</p>
</div>
</div>
</a-card>
</div>
<!-- 空状态 -->
<div v-if="diaryStore.entries.length === 0 && !diaryStore.isLoading" class="empty-state">
<a-empty
description="还没有日记记录"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
>
<a-button type="primary" @click="showNewEntryModal = true">
写第一篇日记
</a-button>
</a-empty>
</div>
<!-- 加载状态 -->
<div v-if="diaryStore.isLoading" class="loading-state">
<a-spin size="large" />
</div>
</div>
</div>
</main>
<!-- 新日记模态框 -->
<a-modal
v-model:open="showNewEntryModal"
title="写日记"
:width="600"
@ok="publishEntry"
@cancel="resetNewEntry"
:confirm-loading="diaryStore.isLoading"
:ok-button-props="{ disabled: !newEntryContent.trim() }"
>
<div class="modal-content">
<a-textarea
v-model:value="newEntryContent"
placeholder="今天有什么新鲜事或心里话想对开开说?"
:rows="6"
class="modal-textarea"
/>
<div class="modal-options">
<div class="mood-selector">
<span class="mood-label">心情</span>
<a-radio-group v-model:value="selectedMood" class="mood-options">
<a-radio-button value="happy">😊</a-radio-button>
<a-radio-button value="sad">😢</a-radio-button>
<a-radio-button value="neutral">😐</a-radio-button>
<a-radio-button value="excited">🤩</a-radio-button>
<a-radio-button value="tired">😴</a-radio-button>
</a-radio-group>
</div>
<div class="tags-input">
<span class="tags-label">标签</span>
<a-input
v-model:value="newTagInput"
placeholder="添加标签,按回车确认"
@press-enter="addTag"
class="tag-input"
/>
<div class="selected-tags" v-if="selectedTags.length">
<a-tag
v-for="tag in selectedTags"
:key="tag"
closable
@close="removeTag(tag)"
color="blue"
>
{{ tag }}
</a-tag>
</div>
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
ArrowLeftOutlined,
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { useDiaryStore } from '@/stores'
import { formatTime } from '@/utils'
import type { DiaryEntry } from '@/types'
const diaryStore = useDiaryStore()
// 响应式数据
const showNewEntryModal = ref(false)
const newEntryContent = ref('')
const selectedMood = ref<string>('neutral')
const selectedTags = ref<string[]>([])
const newTagInput = ref('')
// 开开头像
const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
// 心情表情映射
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 = ''
}
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('删除失败,请重试')
}
}
// 组件挂载
onMounted(() => {
diaryStore.loadEntries()
})
</script>
<style lang="scss" scoped>
.diary-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;
}
.new-entry-btn {
border-radius: $border-radius-full;
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.new-entry-section {
margin-bottom: $spacing-xl;
}
.new-entry-card {
.card-title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-md;
}
.entry-textarea {
margin-bottom: $spacing-md;
}
.entry-actions {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: $spacing-md;
flex-wrap: wrap;
}
.mood-selector {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.mood-label {
font-weight: $font-weight-medium;
color: $text-dark;
}
}
.diary-feed {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.diary-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-sm;
}
.entry-mood {
font-size: $font-size-lg;
}
.entry-date {
color: $text-medium;
font-size: $font-size-sm;
}
.entry-content {
margin-bottom: $spacing-md;
}
.entry-text {
line-height: 1.6;
color: $text-dark;
margin-bottom: $spacing-sm;
}
.entry-tags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
}
.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;
justify-content: center;
align-items: center;
padding: $spacing-xxl;
}
// 模态框样式
.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>
+75
View File
@@ -0,0 +1,75 @@
<template>
<div class="home-page">
<!-- 头部导航 -->
<AppHeader />
<div style="padding: 100px 20px 20px; background: white; text-align: center;">
<h1 style="color: #4A90E2; font-size: 3rem; margin-bottom: 20px;">
你好我是开开
</h1>
<p style="font-size: 1.5rem; color: #888; margin-bottom: 40px;">
你的情绪陪伴使者
</p>
<img
src="https://r2.flowith.net/files/1517c93c-849d-4a9b-94b6-d61aa295a8a1/1752600429516-image-1752600425876-cnlfpkbrh@1024x1024.png"
alt="开开"
style="width: 300px; height: auto; margin-bottom: 40px; border-radius: 20px;"
/>
<div>
<a-button
type="primary"
size="large"
@click="$router.push('/chat')"
style="background: #F5A623; border: none; border-radius: 20px; padding: 12px 32px; font-size: 18px;"
>
开始一段对话
</a-button>
</div>
</div>
<div style="padding: 80px 20px; background: #F7F8FA;">
<div style="text-align: center; margin-bottom: 60px;">
<h2 style="font-size: 2rem; color: #333; margin-bottom: 16px;">发现你的专属陪伴</h2>
<p style="font-size: 18px; color: #888;">
开开博学多才从不炫耀愿意用最温柔的方式陪伴每一个需要倾听的生命
</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 32px; max-width: 1200px; margin: 0 auto;">
<div style="background: white; padding: 32px; border-radius: 16px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h3 style="color: #333; margin-bottom: 16px;">智能对话</h3>
<p style="color: #888; line-height: 1.6;">从日常闲聊到情感咨询开开随时倾听理解并回应你的每个想法</p>
</div>
<div style="background: white; padding: 32px; border-radius: 16px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h3 style="color: #333; margin-bottom: 16px;">情绪日记</h3>
<p style="color: #888; line-height: 1.6;">记录你的点滴心情与生活开开会给予温暖的回应</p>
</div>
<div style="background: white; padding: 32px; border-radius: 16px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h3 style="color: #333; margin-bottom: 16px;">个人展板</h3>
<p style="color: #888; line-height: 1.6;">自由定义你的个性标签构建独一无二的数字人格</p>
</div>
<div style="background: white; padding: 32px; border-radius: 16px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h3 style="color: #333; margin-bottom: 16px;">话题追踪</h3>
<p style="color: #888; line-height: 1.6;">自动总结你关心的事助你洞察自我</p>
</div>
</div>
</div>
<!-- 底部 -->
<AppFooter />
</div>
</template>
<script setup lang="ts">
import AppHeader from '@/components/layout/AppHeader.vue'
import AppFooter from '@/components/layout/AppFooter.vue'
</script>
<style scoped>
.home-page {
min-height: 100vh;
background: #f5f5f5;
}
</style>
+778
View File
@@ -0,0 +1,778 @@
<template>
<div class="life-trajectory-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="primary" @click="showNewEventModal = true" class="new-event-btn">
<PlusOutlined />
添加事件
</a-button>
</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="typeFilter"
placeholder="类型筛选"
style="width: 120px"
@change="handleFilterChange"
>
<a-select-option value="">全部</a-select-option>
<a-select-option value="milestone">里程碑</a-select-option>
<a-select-option value="achievement">成就</a-select-option>
<a-select-option value="memory">回忆</a-select-option>
<a-select-option value="goal">目标</a-select-option>
</a-select>
<a-range-picker
v-model:value="dateRange"
@change="handleDateRangeChange"
style="width: 240px"
/>
</div>
<!-- 时间线视图 -->
<div class="timeline-container">
<a-timeline class="life-timeline">
<a-timeline-item
v-for="event in filteredEvents"
:key="event.id"
:color="getEventColor(event.type)"
class="timeline-item"
>
<template #dot>
<div class="timeline-dot" :class="`dot-${event.type}`">
<component :is="getEventIcon(event.type)" />
</div>
</template>
<div class="event-card" @click="viewEventDetail(event)">
<div class="event-header">
<div class="event-meta">
<a-tag :color="getEventColor(event.type)" size="small">
{{ getEventTypeText(event.type) }}
</a-tag>
<span class="event-date">{{ formatTime.date(event.date) }}</span>
<div class="importance-stars">
<StarFilled
v-for="i in event.importance"
:key="i"
class="star-filled"
/>
<StarOutlined
v-for="i in (5 - event.importance)"
:key="i + event.importance"
class="star-empty"
/>
</div>
</div>
<a-dropdown @click.stop>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="editEvent(event)">
<EditOutlined />
编辑
</a-menu-item>
<a-menu-item @click="deleteEvent(event.id)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="event-content">
<h3 class="event-title">{{ event.title }}</h3>
<p class="event-description" v-if="event.description">
{{ event.description }}
</p>
<div class="event-tags" v-if="event.tags && event.tags.length">
<a-tag
v-for="tag in event.tags.slice(0, 3)"
:key="tag"
size="small"
class="event-tag"
>
{{ tag }}
</a-tag>
<span v-if="event.tags.length > 3" class="more-tags">
+{{ event.tags.length - 3 }}
</span>
</div>
</div>
</div>
</a-timeline-item>
</a-timeline>
</div>
<!-- 空状态 -->
<div v-if="filteredEvents.length === 0" class="empty-state">
<a-empty
description="暂无人生事件记录"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
>
<a-button type="primary" @click="showNewEventModal = true">
记录第一个事件
</a-button>
</a-empty>
</div>
</div>
</main>
<!-- 新建事件模态框 -->
<a-modal
v-model:open="showNewEventModal"
title="添加人生事件"
@ok="createEvent"
@cancel="resetEventForm"
:confirm-loading="isCreating"
width="600px"
>
<a-form :model="eventForm" layout="vertical">
<a-form-item label="事件标题" required>
<a-input
v-model:value="eventForm.title"
placeholder="请输入事件标题"
:maxlength="50"
show-count
/>
</a-form-item>
<a-form-item label="事件描述">
<a-textarea
v-model:value="eventForm.description"
placeholder="请输入事件描述(可选)"
:rows="3"
:maxlength="300"
show-count
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="事件类型" required>
<a-select v-model:value="eventForm.type" placeholder="选择事件类型">
<a-select-option value="milestone">里程碑</a-select-option>
<a-select-option value="achievement">成就</a-select-option>
<a-select-option value="memory">回忆</a-select-option>
<a-select-option value="goal">目标</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="事件日期" required>
<a-date-picker
v-model:value="eventForm.date"
placeholder="选择日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="重要程度">
<a-rate v-model:value="eventForm.importance" :count="5" />
<div class="importance-desc">
<span v-if="eventForm.importance === 1">一般重要</span>
<span v-else-if="eventForm.importance === 2">比较重要</span>
<span v-else-if="eventForm.importance === 3">重要</span>
<span v-else-if="eventForm.importance === 4">非常重要</span>
<span v-else-if="eventForm.importance === 5">极其重要</span>
<span v-else>请选择重要程度</span>
</div>
</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="eventForm.tags.length">
<a-tag
v-for="tag in eventForm.tags"
:key="tag"
closable
@close="removeTag(tag)"
color="blue"
>
{{ tag }}
</a-tag>
</div>
</div>
</a-form-item>
</a-form>
</a-modal>
<!-- 事件详情模态框 -->
<a-modal
v-model:open="showDetailModal"
:title="selectedEvent?.title"
:footer="null"
width="700px"
>
<div v-if="selectedEvent" class="event-detail">
<div class="detail-header">
<div class="detail-meta">
<a-tag :color="getEventColor(selectedEvent.type)" size="large">
{{ getEventTypeText(selectedEvent.type) }}
</a-tag>
<span class="detail-date">{{ formatTime.standard(selectedEvent.date) }}</span>
</div>
<div class="detail-importance">
<span class="importance-label">重要程度</span>
<a-rate :value="selectedEvent.importance" disabled />
</div>
</div>
<div class="detail-description" v-if="selectedEvent.description">
<h4>详细描述</h4>
<p>{{ selectedEvent.description }}</p>
</div>
<div class="detail-tags" v-if="selectedEvent.tags && selectedEvent.tags.length">
<h4>相关标签</h4>
<div class="tags-list">
<a-tag
v-for="tag in selectedEvent.tags"
:key="tag"
color="blue"
>
{{ tag }}
</a-tag>
</div>
</div>
<div class="detail-actions">
<a-button type="primary" @click="editEvent(selectedEvent)">
<EditOutlined />
编辑事件
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import {
ArrowLeftOutlined,
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
StarFilled,
StarOutlined,
TrophyOutlined,
FlagOutlined,
HeartOutlined,
BulbOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { formatTime } from '@/utils'
import type { LifeEvent } from '@/types'
import type { Dayjs } from 'dayjs'
// 响应式数据
const showNewEventModal = ref(false)
const showDetailModal = ref(false)
const isCreating = ref(false)
const searchKeyword = ref('')
const typeFilter = ref('')
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const newTagInput = ref('')
const selectedEvent = ref<LifeEvent | null>(null)
// 事件数据
const events = ref<LifeEvent[]>([
{
id: '1',
title: '大学毕业',
description: '完成了四年的大学学习,获得了计算机科学学士学位',
date: '2020-06-15',
type: 'milestone',
importance: 5,
tags: ['教育', '毕业', '成长']
},
{
id: '2',
title: '第一份工作',
description: '加入了一家科技公司,开始了职业生涯',
date: '2020-08-01',
type: 'achievement',
importance: 4,
tags: ['工作', '职业', '新开始']
},
{
id: '3',
title: '学会游泳',
description: '终于克服了对水的恐惧,学会了游泳',
date: '2021-07-20',
type: 'achievement',
importance: 3,
tags: ['运动', '技能', '突破']
},
{
id: '4',
title: '第一次独自旅行',
description: '一个人去了云南,体验了不同的文化和风景',
date: '2022-03-10',
type: 'memory',
importance: 4,
tags: ['旅行', '独立', '体验']
}
])
// 表单数据
const eventForm = reactive({
title: '',
description: '',
type: 'milestone' as LifeEvent['type'],
date: null as Dayjs | null,
importance: 3 as LifeEvent['importance'],
tags: [] as string[]
})
// 计算属性
const filteredEvents = computed(() => {
let result = [...events.value]
// 关键词搜索
if (searchKeyword.value) {
result = result.filter(event =>
event.title.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
event.description?.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
event.tags?.some(tag => tag.toLowerCase().includes(searchKeyword.value.toLowerCase()))
)
}
// 类型筛选
if (typeFilter.value) {
result = result.filter(event => event.type === typeFilter.value)
}
// 日期范围筛选
if (dateRange.value && dateRange.value.length === 2) {
const [start, end] = dateRange.value
result = result.filter(event => {
const eventDate = new Date(event.date)
return eventDate >= start.toDate() && eventDate <= end.toDate()
})
}
// 按日期排序(最新的在前)
return result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
})
// 方法
const getEventColor = (type: LifeEvent['type']) => {
const colors = {
milestone: 'blue',
achievement: 'green',
memory: 'orange',
goal: 'purple'
}
return colors[type]
}
const getEventTypeText = (type: LifeEvent['type']) => {
const texts = {
milestone: '里程碑',
achievement: '成就',
memory: '回忆',
goal: '目标'
}
return texts[type]
}
const getEventIcon = (type: LifeEvent['type']) => {
const icons = {
milestone: FlagOutlined,
achievement: TrophyOutlined,
memory: HeartOutlined,
goal: BulbOutlined
}
return icons[type]
}
const handleSearch = () => {
// 搜索逻辑已在计算属性中处理
}
const handleFilterChange = () => {
// 筛选逻辑已在计算属性中处理
}
const handleDateRangeChange = () => {
// 日期范围筛选逻辑已在计算属性中处理
}
const createEvent = async () => {
if (!eventForm.title.trim()) {
message.warning('请输入事件标题')
return
}
if (!eventForm.date) {
message.warning('请选择事件日期')
return
}
isCreating.value = true
try {
const newEvent: LifeEvent = {
id: Date.now().toString(),
title: eventForm.title.trim(),
description: eventForm.description.trim() || undefined,
date: eventForm.date.format('YYYY-MM-DD'),
type: eventForm.type,
importance: eventForm.importance,
tags: eventForm.tags.length ? eventForm.tags : undefined
}
events.value.push(newEvent)
message.success('事件添加成功')
showNewEventModal.value = false
resetEventForm()
} catch (error) {
message.error('添加失败,请重试')
} finally {
isCreating.value = false
}
}
const resetEventForm = () => {
eventForm.title = ''
eventForm.description = ''
eventForm.type = 'milestone'
eventForm.date = null
eventForm.importance = 3
eventForm.tags = []
newTagInput.value = ''
}
const addTag = () => {
const tag = newTagInput.value.trim()
if (tag && !eventForm.tags.includes(tag)) {
eventForm.tags.push(tag)
newTagInput.value = ''
}
}
const removeTag = (tag: string) => {
const index = eventForm.tags.indexOf(tag)
if (index > -1) {
eventForm.tags.splice(index, 1)
}
}
const viewEventDetail = (event: LifeEvent) => {
selectedEvent.value = event
showDetailModal.value = true
}
const editEvent = (event: LifeEvent) => {
// TODO: 实现编辑功能
message.info('编辑功能开发中...')
}
const deleteEvent = (id: string) => {
const index = events.value.findIndex(e => e.id === id)
if (index > -1) {
events.value.splice(index, 1)
message.success('事件删除成功')
}
}
// 组件挂载
onMounted(() => {
// 初始化数据
})
</script>
<style lang="scss" scoped>
.life-trajectory-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: 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;
}
.new-event-btn {
border-radius: $border-radius-full;
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.filter-section {
display: flex;
gap: $spacing-md;
margin-bottom: $spacing-xl;
flex-wrap: wrap;
}
.timeline-container {
.life-timeline {
:deep(.ant-timeline-item-tail) {
border-left: 2px solid #e8e8e8;
}
}
.timeline-item {
margin-bottom: $spacing-xl;
}
.timeline-dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: $font-size-base;
&.dot-milestone {
background: #1890ff;
}
&.dot-achievement {
background: #52c41a;
}
&.dot-memory {
background: #fa8c16;
}
&.dot-goal {
background: #722ed1;
}
}
.event-card {
background: white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
cursor: pointer;
transition: all $transition-normal;
margin-left: $spacing-md;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2px);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
.event-meta {
display: flex;
flex-direction: column;
gap: $spacing-xs;
.event-date {
color: $text-medium;
font-size: $font-size-sm;
}
.importance-stars {
display: flex;
gap: 2px;
.star-filled {
color: #faad14;
}
.star-empty {
color: #d9d9d9;
}
}
}
}
.event-content {
.event-title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-sm;
line-height: 1.4;
}
.event-description {
color: $text-medium;
line-height: 1.6;
margin-bottom: $spacing-md;
}
.event-tags {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
.event-tag {
margin: 0;
}
.more-tags {
font-size: $font-size-sm;
color: $text-medium;
}
}
}
}
}
.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;
}
}
.importance-desc {
margin-top: $spacing-xs;
font-size: $font-size-sm;
color: $text-medium;
}
.event-detail {
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 1px solid #f0f0f0;
.detail-meta {
display: flex;
flex-direction: column;
gap: $spacing-xs;
.detail-date {
color: $text-medium;
font-size: $font-size-sm;
}
}
.detail-importance {
display: flex;
align-items: center;
gap: $spacing-sm;
.importance-label {
font-size: $font-size-sm;
color: $text-medium;
}
}
}
.detail-description,
.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 {
padding-top: $spacing-md;
border-top: 1px solid #f0f0f0;
}
}
</style>
+326
View File
@@ -0,0 +1,326 @@
<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>
<!-- 登录表单 -->
<a-form
:model="loginForm"
:rules="loginRules"
@finish="handleLogin"
@finishFailed="handleLoginFailed"
layout="vertical"
class="login-form"
>
<a-form-item label="账号" name="account">
<a-input
v-model:value="loginForm.account"
placeholder="请输入手机号或邮箱"
size="large"
:prefix="h(UserOutlined)"
/>
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="loginForm.password"
placeholder="请输入密码"
size="large"
:prefix="h(LockOutlined)"
/>
</a-form-item>
<!-- 验证码 -->
<a-form-item label="验证码" name="captcha">
<div class="captcha-container">
<a-input
v-model:value="loginForm.captcha"
placeholder="请输入验证码"
size="large"
style="flex: 1"
/>
<div class="captcha-image" @click="refreshCaptcha">
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
style="width: 100%; height: 100%; cursor: pointer;"
/>
<div v-else class="captcha-loading">
<a-spin size="small" />
</div>
</div>
</div>
<div class="captcha-tip">点击图片刷新验证码</div>
</a-form-item>
<a-form-item>
<div class="login-options">
<a-checkbox v-model:checked="loginForm.remember">记住我</a-checkbox>
<a href="#" class="forgot-password">忘记密码</a>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
:loading="loginLoading"
class="login-button"
block
>
登录
</a-button>
</a-form-item>
</a-form>
<!-- 注册链接 -->
<div class="register-link">
还没有账户
<router-link to="/register" class="register-btn">立即注册</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'
const router = useRouter()
const userStore = useUserStore()
// 表单数据
const loginForm = reactive<LoginRequest>({
account: '',
password: '',
captcha: '',
remember: false
})
// 表单验证规则
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 loginLoading = 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('获取验证码失败')
}
}
// 刷新验证码
const refreshCaptcha = () => {
getCaptcha()
}
// 登录处理
const handleLogin = async (values: LoginRequest) => {
loginLoading.value = true
try {
const loginData = {
...values,
captchaKey: captchaKey.value
}
const data = 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 || '登录失败,请稍后重试')
refreshCaptcha()
} finally {
loginLoading.value = false
}
}
// 登录失败处理
const handleLoginFailed = (errorInfo: any) => {
console.log('Login failed:', errorInfo)
}
// 初始化
onMounted(() => {
getCaptcha()
})
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
width: 100%;
max-width: 400px;
}
.login-card {
background: white;
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;
}
}
.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;
}
}
.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>
+589
View File
@@ -0,0 +1,589 @@
<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>
<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>
<!-- 主要内容 -->
<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)" />
</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>
</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>
</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>
</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'
// 响应式数据
const activeTab = ref('all')
const showDetailModal = ref(false)
const selectedMessage = ref<Message | null>(null)
const isLoading = ref(false)
const hasMore = ref(false)
// 消息数据
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 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(() => {
// 初始化消息数据
})
</script>
<style lang="scss" scoped>
.messages-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: 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>
+99
View File
@@ -0,0 +1,99 @@
<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>
.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>
+562
View File
@@ -0,0 +1,562 @@
<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>
</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="userInfo?.status === 'ACTIVE' ? 'green' : 'red'">
{{ userInfo?.status === 'ACTIVE' ? '正常' : '禁用' }}
</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>
</div>
</a-modal>
</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: 加载统计数据
})
</script>
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
background: #f5f5f5;
}
.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;
}
}
}
.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>
+316
View File
@@ -0,0 +1,316 @@
<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>
<!-- 注册表单 -->
<a-form
:model="registerForm"
:rules="registerRules"
@finish="handleRegister"
@finishFailed="handleRegisterFailed"
layout="vertical"
class="register-form"
>
<a-form-item label="账号" name="account">
<a-input
v-model:value="registerForm.account"
placeholder="请输入手机号或邮箱"
size="large"
:prefix="h(UserOutlined)"
autocomplete="off"
/>
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="registerForm.password"
placeholder="请输入密码"
size="large"
:prefix="h(LockOutlined)"
autocomplete="new-password"
/>
</a-form-item>
<a-form-item label="确认密码" name="confirmPassword">
<a-input-password
v-model:value="registerForm.confirmPassword"
placeholder="请再次输入密码"
size="large"
:prefix="h(LockOutlined)"
autocomplete="new-password"
/>
</a-form-item>
<!-- 验证码 -->
<a-form-item label="验证码" name="captcha">
<div class="captcha-container">
<a-input
v-model:value="registerForm.captcha"
placeholder="请输入验证码"
size="large"
style="flex: 1"
/>
<div class="captcha-image" @click="refreshCaptcha">
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
style="width: 100%; height: 100%; cursor: pointer;"
/>
<div v-else class="captcha-loading">
<a-spin size="small" />
</div>
</div>
</div>
<div class="captcha-tip">点击图片刷新验证码</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
:loading="registerLoading"
class="register-button"
block
>
注册
</a-button>
</a-form-item>
</a-form>
<!-- 登录链接 -->
<div class="login-link">
已有账户
<router-link to="/login" class="login-btn">立即登录</router-link>
</div>
</div>
</div>
</div>
</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'
const router = useRouter()
const userStore = useUserStore()
// 表单数据
const registerForm = reactive<RegisterRequest>({
account: '',
password: '',
confirmPassword: '',
captcha: ''
})
// 表单验证规则
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 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('获取验证码失败')
}
}
// 刷新验证码
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)
router.push('/')
} catch (error: any) {
message.error(error.message || '注册失败,请稍后重试')
refreshCaptcha()
} finally {
registerLoading.value = false
}
}
// 注册失败处理
const handleRegisterFailed = (errorInfo: any) => {
console.log('Register failed:', errorInfo)
}
// 初始化
onMounted(() => {
getCaptcha()
})
</script>
<style lang="scss" scoped>
.register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-container {
width: 100%;
max-width: 400px;
}
.register-card {
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.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;
}
}
.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;
}
}
.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>
+601
View File
@@ -0,0 +1,601 @@
<template>
<div class="settings-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>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<div class="settings-content">
<!-- 用户信息区域 -->
<a-card class="user-info-card">
<div class="user-profile">
<a-avatar :size="80" :src="userAvatar" class="user-avatar">
<UserOutlined />
</a-avatar>
<div class="user-details">
<h2 class="user-name">{{ userStore.user?.nickname || '未登录用户' }}</h2>
<p class="user-email">{{ userStore.user?.email || '未绑定邮箱' }}</p>
<a-button type="primary" size="small" @click="showLoginModal = true">
{{ userStore.isLoggedIn ? '切换账号' : '登录/注册' }}
</a-button>
</div>
</div>
</a-card>
<!-- 设置选项 -->
<div class="settings-sections">
<!-- 账户设置 -->
<a-card title="账户设置" class="settings-card">
<div class="settings-list">
<div class="setting-item" @click="showProfileModal = true">
<div class="setting-info">
<UserOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">个人资料</div>
<div class="setting-desc">管理您的个人信息</div>
</div>
</div>
<RightOutlined class="setting-arrow" />
</div>
<div class="setting-item" @click="showSecurityModal = true">
<div class="setting-info">
<SafetyOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">账户安全</div>
<div class="setting-desc">密码手机号等安全设置</div>
</div>
</div>
<RightOutlined class="setting-arrow" />
</div>
</div>
</a-card>
<!-- 应用设置 -->
<a-card title="应用设置" class="settings-card">
<div class="settings-list">
<div class="setting-item">
<div class="setting-info">
<BellOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">消息通知</div>
<div class="setting-desc">管理推送通知设置</div>
</div>
</div>
<a-switch v-model:checked="notificationEnabled" />
</div>
<div class="setting-item">
<div class="setting-info">
<EyeOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">隐私模式</div>
<div class="setting-desc">保护您的隐私数据</div>
</div>
</div>
<a-switch v-model:checked="privacyMode" />
</div>
<div class="setting-item">
<div class="setting-info">
<BgColorsOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">主题设置</div>
<div class="setting-desc">选择您喜欢的主题色彩</div>
</div>
</div>
<a-select
v-model:value="selectedTheme"
style="width: 120px"
@change="changeTheme"
>
<a-select-option value="default">默认蓝</a-select-option>
<a-select-option value="orange">温暖橙</a-select-option>
<a-select-option value="green">自然绿</a-select-option>
<a-select-option value="purple">优雅紫</a-select-option>
</a-select>
</div>
</div>
</a-card>
<!-- 数据管理 -->
<a-card title="数据管理" class="settings-card">
<div class="settings-list">
<div class="setting-item" @click="exportData">
<div class="setting-info">
<DownloadOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">导出数据</div>
<div class="setting-desc">导出您的聊天记录和日记</div>
</div>
</div>
<RightOutlined class="setting-arrow" />
</div>
<div class="setting-item" @click="clearCache">
<div class="setting-info">
<ClearOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">清除缓存</div>
<div class="setting-desc">清理应用缓存数据</div>
</div>
</div>
<RightOutlined class="setting-arrow" />
</div>
</div>
</a-card>
<!-- 关于应用 -->
<a-card title="关于应用" class="settings-card">
<div class="settings-list">
<div class="setting-item" @click="showAboutModal = true">
<div class="setting-info">
<InfoCircleOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">关于开心APP</div>
<div class="setting-desc">版本信息和开发团队</div>
</div>
</div>
<RightOutlined class="setting-arrow" />
</div>
<div class="setting-item" @click="showPrivacyModal = true">
<div class="setting-info">
<FileProtectOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">隐私政策</div>
<div class="setting-desc">了解我们如何保护您的隐私</div>
</div>
</div>
<RightOutlined class="setting-arrow" />
</div>
<div class="setting-item" @click="showTermsModal = true">
<div class="setting-info">
<FileTextOutlined class="setting-icon" />
<div class="setting-text">
<div class="setting-title">服务条款</div>
<div class="setting-desc">使用条款和服务协议</div>
</div>
</div>
<RightOutlined class="setting-arrow" />
</div>
</div>
</a-card>
<!-- 退出登录 -->
<div class="logout-section" v-if="userStore.isLoggedIn">
<a-button type="primary" danger block size="large" @click="logout">
<LogoutOutlined />
退出登录
</a-button>
</div>
</div>
</div>
</div>
</main>
<!-- 登录模态框 -->
<a-modal
v-model:open="showLoginModal"
title="登录/注册"
:footer="null"
width="400px"
>
<a-tabs v-model:activeKey="loginTab" centered>
<a-tab-pane key="login" tab="登录">
<a-form :model="loginForm" layout="vertical" @finish="handleLogin">
<a-form-item label="用户名" name="username" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="loginForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="密码" name="password" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model:value="loginForm.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" block :loading="loginLoading">
登录
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="register" tab="注册">
<a-form :model="registerForm" layout="vertical" @finish="handleRegister">
<a-form-item label="用户名" name="username" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="registerForm.username" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="邮箱" name="email" :rules="[{ required: true, type: 'email', message: '请输入有效邮箱' }]">
<a-input v-model:value="registerForm.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="密码" name="password" :rules="[{ required: true, min: 6, message: '密码至少6位' }]">
<a-input-password v-model:value="registerForm.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" block :loading="registerLoading">
注册
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-modal>
<!-- 其他模态框 -->
<a-modal v-model:open="showProfileModal" title="个人资料" @ok="saveProfile">
<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="showAboutModal" title="关于开心APP" :footer="null">
<div class="about-content">
<div class="app-info">
<img src="https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png" alt="开心APP" class="app-logo" />
<h3>开心APP</h3>
<p>版本 1.0.0</p>
<p>你的情绪陪伴使者</p>
</div>
<div class="app-description">
<p>开心APP致力于为用户提供温暖的情绪陪伴服务通过AI助手"开开"与用户进行智能对话帮助用户记录情绪管理生活共同成长</p>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import {
ArrowLeftOutlined,
UserOutlined,
SafetyOutlined,
BellOutlined,
EyeOutlined,
BgColorsOutlined,
DownloadOutlined,
ClearOutlined,
InfoCircleOutlined,
FileProtectOutlined,
FileTextOutlined,
LogoutOutlined,
RightOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useUserStore, useAppStore } from '@/stores'
const userStore = useUserStore()
const appStore = useAppStore()
// 响应式数据
const showLoginModal = ref(false)
const showProfileModal = ref(false)
const showSecurityModal = ref(false)
const showAboutModal = ref(false)
const showPrivacyModal = ref(false)
const showTermsModal = ref(false)
const loginTab = ref('login')
const loginLoading = ref(false)
const registerLoading = ref(false)
const notificationEnabled = ref(true)
const privacyMode = ref(false)
const selectedTheme = ref('default')
const userAvatar = ref('https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png')
// 表单数据
const loginForm = reactive({
username: '',
password: ''
})
const registerForm = reactive({
username: '',
email: '',
password: ''
})
const profileForm = reactive({
nickname: '',
email: '',
phone: ''
})
// 方法
const handleLogin = async () => {
loginLoading.value = true
try {
const success = await userStore.login(loginForm)
if (success) {
message.success('登录成功')
showLoginModal.value = false
resetLoginForm()
} else {
message.error('登录失败,请检查用户名和密码')
}
} catch (error) {
message.error('登录失败,请重试')
} finally {
loginLoading.value = false
}
}
const handleRegister = async () => {
registerLoading.value = true
try {
// TODO: 实现注册逻辑
message.success('注册成功')
showLoginModal.value = false
resetRegisterForm()
} catch (error) {
message.error('注册失败,请重试')
} finally {
registerLoading.value = false
}
}
const resetLoginForm = () => {
loginForm.username = ''
loginForm.password = ''
}
const resetRegisterForm = () => {
registerForm.username = ''
registerForm.email = ''
registerForm.password = ''
}
const saveProfile = () => {
userStore.updateProfile(profileForm)
showProfileModal.value = false
message.success('个人资料保存成功')
}
const changeTheme = (theme: string) => {
const themeColors = {
default: '#4A90E2',
orange: '#F5A623',
green: '#52c41a',
purple: '#722ed1'
}
appStore.setTheme({
primaryColor: themeColors[theme as keyof typeof themeColors]
})
message.success('主题切换成功')
}
const exportData = () => {
message.info('数据导出功能开发中...')
}
const clearCache = () => {
localStorage.clear()
message.success('缓存清理成功')
}
const logout = () => {
userStore.logout()
message.success('已退出登录')
}
// 组件挂载
onMounted(() => {
if (userStore.user) {
profileForm.nickname = userStore.user.nickname || ''
profileForm.email = userStore.user.email || ''
profileForm.phone = userStore.user.phone || ''
}
})
</script>
<style lang="scss" scoped>
.settings-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;
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.settings-content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
// 用户信息卡片
.user-info-card {
.user-profile {
display: flex;
align-items: center;
gap: $spacing-lg;
}
.user-avatar {
flex-shrink: 0;
}
.user-details {
flex: 1;
}
.user-name {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-dark;
margin: 0 0 $spacing-xs 0;
}
.user-email {
color: $text-medium;
margin: 0 0 $spacing-md 0;
}
}
// 设置卡片
.settings-card {
.settings-list {
display: flex;
flex-direction: column;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md 0;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color $transition-normal;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: rgba(74, 144, 226, 0.05);
margin: 0 (-$spacing-lg);
padding-left: $spacing-lg;
padding-right: $spacing-lg;
border-radius: $border-radius-md;
}
}
.setting-info {
display: flex;
align-items: center;
gap: $spacing-md;
flex: 1;
}
.setting-icon {
font-size: $font-size-lg;
color: $tech-blue;
}
.setting-text {
.setting-title {
font-weight: $font-weight-medium;
color: $text-dark;
margin-bottom: 2px;
}
.setting-desc {
font-size: $font-size-sm;
color: $text-medium;
}
}
.setting-arrow {
color: $text-medium;
font-size: $font-size-sm;
}
}
// 退出登录区域
.logout-section {
margin-top: $spacing-lg;
}
// 关于应用模态框
.about-content {
text-align: center;
.app-info {
margin-bottom: $spacing-lg;
.app-logo {
width: 80px;
height: 80px;
border-radius: $border-radius-lg;
margin-bottom: $spacing-md;
}
h3 {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-dark;
margin-bottom: $spacing-xs;
}
p {
color: $text-medium;
margin-bottom: $spacing-xs;
}
}
.app-description {
text-align: left;
padding: $spacing-md;
background: $light-gray;
border-radius: $border-radius-md;
p {
line-height: 1.6;
color: $text-dark;
margin: 0;
}
}
}
</style>
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="token-test">
<a-card title="Token和身份验证测试">
<div class="test-section">
<h3>当前状态</h3>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="登录状态">
<a-tag :color="userStore.isLoggedIn ? 'green' : 'red'">
{{ userStore.isLoggedIn ? '已登录' : '未登录' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="Token">
<a-typography-text :code="true" :copyable="true">
{{ userStore.token || '无' }}
</a-typography-text>
</a-descriptions-item>
<a-descriptions-item label="用户信息">
<pre>{{ JSON.stringify(userStore.userInfo || userStore.user, null, 2) }}</pre>
</a-descriptions-item>
<a-descriptions-item label="WebSocket状态">
<a-tag :color="chatStore.wsConnected ? 'green' : 'red'">
{{ chatStore.wsConnected ? '已连接' : '未连接' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
</div>
<div class="test-section">
<h3>操作测试</h3>
<a-space direction="vertical" style="width: 100%">
<a-button type="primary" @click="testLogin" :loading="loginLoading">
测试登录
</a-button>
<a-button @click="testWebSocketConnect" :loading="wsLoading">
测试WebSocket连接
</a-button>
<a-button @click="testSendMessage" :disabled="!chatStore.wsConnected">
发送测试消息
</a-button>
<a-button @click="checkLocalStorage">
检查本地存储
</a-button>
<a-button @click="testApiCall" :loading="apiLoading">
测试API调用
</a-button>
</a-space>
</div>
<div class="test-section">
<h3>测试结果</h3>
<a-textarea
v-model:value="testResults"
:rows="10"
readonly
placeholder="测试结果将显示在这里..."
/>
<a-button @click="clearResults" style="margin-top: 8px">
清空结果
</a-button>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore, useChatStore } from '@/stores'
import { request } from '@/services/api'
const userStore = useUserStore()
const chatStore = useChatStore()
const loginLoading = ref(false)
const wsLoading = ref(false)
const apiLoading = ref(false)
const testResults = ref('')
const addResult = (message: string) => {
const timestamp = new Date().toLocaleTimeString()
testResults.value += `[${timestamp}] ${message}\n`
}
const testLogin = async () => {
loginLoading.value = true
try {
addResult('开始测试登录...')
const result = await userStore.loginWithAuth({
account: 'test@example.com',
password: '123456',
captcha: '1234'
})
addResult(`登录成功: ${JSON.stringify(result)}`)
addResult(`Token: ${userStore.token}`)
addResult(`用户信息: ${JSON.stringify(userStore.userInfo)}`)
} catch (error: any) {
addResult(`登录失败: ${error.message}`)
} finally {
loginLoading.value = false
}
}
const testWebSocketConnect = async () => {
wsLoading.value = true
try {
addResult('开始测试WebSocket连接...')
await chatStore.connectWebSocket()
addResult(`WebSocket连接状态: ${chatStore.wsConnected}`)
addResult(`连接状态: ${chatStore.connectionStatus}`)
} catch (error: any) {
addResult(`WebSocket连接失败: ${error.message}`)
} finally {
wsLoading.value = false
}
}
const testSendMessage = async () => {
try {
addResult('发送测试消息...')
await chatStore.sendMessage('这是一条测试消息,用于验证用户身份识别')
addResult('消息发送成功')
} catch (error: any) {
addResult(`消息发送失败: ${error.message}`)
}
}
const checkLocalStorage = () => {
addResult('检查本地存储...')
addResult(`localStorage.token: ${localStorage.getItem('token')}`)
addResult(`localStorage.userInfo: ${localStorage.getItem('userInfo')}`)
}
const testApiCall = async () => {
apiLoading.value = true
try {
addResult('测试API调用...')
const response = await request.get('/health')
addResult(`API调用成功: ${JSON.stringify(response)}`)
} catch (error: any) {
addResult(`API调用失败: ${error.message}`)
} finally {
apiLoading.value = false
}
}
const clearResults = () => {
testResults.value = ''
}
</script>
<style lang="scss" scoped>
.token-test {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.test-section {
margin-bottom: 24px;
h3 {
margin-bottom: 16px;
color: #1890ff;
}
}
pre {
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
}
</style>
+699
View File
@@ -0,0 +1,699 @@
<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>
<a-button type="primary" @click="showNewTopicModal = true" class="new-topic-btn">
<PlusOutlined />
新建话题
</a-button>
</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>
</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>
<a-progress
:percent="topic.progress"
:stroke-color="getStatusColor(topic.status)"
:show-info="false"
size="small"
/>
</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>
</div>
</div>
</a-card>
</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>
</div>
</main>
<!-- 新建话题模态框 -->
<a-modal
v-model:open="showNewTopicModal"
title="新建话题"
@ok="createTopic"
@cancel="resetTopicForm"
:confirm-loading="isCreating"
width="600px"
>
<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>
</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"
>
{{ 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>
</div>
</div>
</a-modal>
</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'
// 响应式数据
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 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 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'
}
const getStatusText = (status: string) => {
const texts = {
active: '进行中',
completed: '已完成',
paused: '已暂停'
}
return texts[status as keyof typeof texts] || status
}
const handleSearch = () => {
// 搜索逻辑已在计算属性中处理
}
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>
.topic-tracker-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;
}
.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>
+333
View File
@@ -0,0 +1,333 @@
<template>
<div class="websocket-test">
<div class="test-container">
<h1>WebSocket连接测试</h1>
<!-- 连接状态 -->
<div class="status-section">
<h3>连接状态</h3>
<div class="status-info">
<span class="status-label">状态:</span>
<span
class="status-value"
:class="{
'connected': chatStore.wsConnected,
'connecting': chatStore.connectionStatus === 'CONNECTING',
'disconnected': !chatStore.wsConnected
}"
>
{{ getConnectionStatusText() }}
</span>
</div>
<div class="status-actions">
<a-button
type="primary"
@click="chatStore.connectWebSocket()"
:loading="chatStore.connectionStatus === 'CONNECTING'"
:disabled="chatStore.wsConnected"
>
连接
</a-button>
<a-button
@click="chatStore.disconnectWebSocket()"
:disabled="!chatStore.wsConnected"
>
断开
</a-button>
</div>
</div>
<!-- 消息测试 -->
<div class="message-section">
<h3>消息测试</h3>
<div class="message-input">
<a-input
v-model:value="testMessage"
placeholder="输入测试消息..."
@press-enter="sendTestMessage"
:disabled="!chatStore.wsConnected"
/>
<a-button
type="primary"
@click="sendTestMessage"
:disabled="!chatStore.wsConnected || !testMessage.trim()"
>
发送
</a-button>
</div>
</div>
<!-- 消息历史 -->
<div class="messages-section">
<h3>消息历史</h3>
<div class="messages-list">
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ 'user': message.type === 'user', 'ai': message.type === 'ai' }"
>
<div class="message-header">
<span class="message-sender">{{ message.type === 'user' ? '用户' : 'AI' }}</span>
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="message-content">{{ message.content }}</div>
</div>
</div>
<div class="messages-actions">
<a-button @click="clearMessages">清空消息</a-button>
</div>
</div>
<!-- 配置信息 -->
<div class="config-section">
<h3>配置信息</h3>
<div class="config-info">
<div class="config-item">
<span class="config-label">WebSocket URL:</span>
<span class="config-value">{{ wsUrl }}</span>
</div>
<div class="config-item">
<span class="config-label">用户ID:</span>
<span class="config-value">{{ userId }}</span>
</div>
<div class="config-item">
<span class="config-label">会话ID:</span>
<span class="config-value">{{ chatStore.currentSession?.id || '未设置' }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useUserStore } from '@/stores/user'
import dayjs from 'dayjs'
const chatStore = useChatStore()
const userStore = useUserStore()
const testMessage = ref('')
const messages = ref<Array<{
id: string
type: 'user' | 'ai'
content: string
timestamp: number
}>>([])
const wsUrl = computed(() => import.meta.env.VITE_WS_URL)
const userId = computed(() => userStore.user?.id || `guest_${Date.now()}`)
// 获取连接状态文本
const getConnectionStatusText = () => {
switch (chatStore.connectionStatus) {
case 'CONNECTED':
return '已连接'
case 'CONNECTING':
return '连接中...'
case 'DISCONNECTED':
return '已断开'
case 'ERROR':
return '连接错误'
default:
return '未知状态'
}
}
// 发送测试消息
const sendTestMessage = () => {
if (!testMessage.value.trim() || !chatStore.wsConnected) return
const message = {
id: Date.now().toString(),
type: 'user' as const,
content: testMessage.value.trim(),
timestamp: Date.now()
}
messages.value.push(message)
chatStore.sendMessage(testMessage.value.trim())
testMessage.value = ''
}
// 清空消息
const clearMessages = () => {
messages.value = []
chatStore.clearMessages()
}
// 格式化时间
const formatTime = (timestamp: number) => {
return dayjs(timestamp).format('HH:mm:ss')
}
// 监听AI回复
const handleAiMessage = (content: string) => {
const message = {
id: Date.now().toString(),
type: 'ai' as const,
content,
timestamp: Date.now()
}
messages.value.push(message)
}
onMounted(() => {
// 监听聊天store中的消息变化
chatStore.$subscribe((mutation, state) => {
if (mutation.events && Array.isArray(mutation.events)) {
mutation.events.forEach((event: any) => {
if (event.key === 'messages' && event.type === 'add') {
const newMessage = event.newValue
if (newMessage && newMessage.type === 'ai') {
handleAiMessage(newMessage.content)
}
}
})
}
})
})
onUnmounted(() => {
chatStore.disconnectWebSocket()
})
</script>
<style lang="scss" scoped>
.websocket-test {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.test-container {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.status-section,
.message-section,
.messages-section,
.config-section {
margin-bottom: 32px;
h3 {
margin-bottom: 16px;
color: #1890ff;
border-bottom: 2px solid #f0f0f0;
padding-bottom: 8px;
}
}
.status-info {
margin-bottom: 16px;
.status-label {
font-weight: 500;
margin-right: 8px;
}
.status-value {
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
&.connected {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
&.connecting {
background: #fffbe6;
color: #faad14;
border: 1px solid #ffe58f;
}
&.disconnected {
background: #fff2f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
}
}
.status-actions {
display: flex;
gap: 12px;
}
.message-input {
display: flex;
gap: 12px;
.ant-input {
flex: 1;
}
}
.messages-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
.message-item {
margin-bottom: 12px;
padding: 8px;
border-radius: 4px;
&.user {
background: #e6f7ff;
border-left: 3px solid #1890ff;
}
&.ai {
background: #f6ffed;
border-left: 3px solid #52c41a;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
font-size: 12px;
color: #666;
.message-sender {
font-weight: 500;
}
}
.message-content {
color: #333;
line-height: 1.5;
}
}
}
.config-info {
.config-item {
display: flex;
margin-bottom: 8px;
.config-label {
font-weight: 500;
min-width: 120px;
color: #666;
}
.config-value {
color: #333;
word-break: break-all;
}
}
}
</style>