首个可运行的版本

This commit is contained in:
2026-06-12 23:14:12 +08:00
commit b3d90c65f8
86 changed files with 4808 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-alpine
WORKDIR /app
# 复制 package 文件
COPY package.json package-lock.json* ./
# 安装依赖(使用国内镜像源)
RUN npm config set registry https://registry.npmmirror.com && npm install
# 复制项目代码(开发时通过 volume 挂载覆盖)
COPY . .
EXPOSE 5173
# 开发模式:Vite dev server,允许外部访问
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_WS_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>青叶 - QingYe</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "qingye-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.5.0",
"pinia": "^3.0.0",
"naive-ui": "^2.40.0",
"axios": "^1.7.0",
"dayjs": "^1.11.0",
"echarts": "^5.5.0",
"vue-echarts": "^7.0.0",
"@vicons/ionicons5": "^0.12.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0"
}
}
+68
View File
@@ -0,0 +1,68 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<n-message-provider>
<n-dialog-provider>
<router-view />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import type { GlobalThemeOverrides } from 'naive-ui'
/** 青绿色主题配置 */
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#009688',
primaryColorHover: '#26A69A',
primaryColorPressed: '#00796B',
primaryColorSuppl: '#00897B',
successColor: '#4CAF50',
borderRadius: '8px',
borderRadiusSmall: '6px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
},
Button: {
colorPrimary: '#009688',
colorHoverPrimary: '#26A69A',
colorPressedPrimary: '#00796B',
textColorPrimary: '#FFFFFF',
borderRadiusMedium: '8px',
},
Input: {
borderColorFocus: '#009688',
boxShadowFocus: '0 0 0 2px rgba(0, 150, 136, 0.2)',
caretColor: '#009688',
borderRadius: '8px',
},
Card: {
borderColor: '#D4E8E3',
borderRadius: '12px',
},
Menu: {
itemTextColorActive: '#009688',
itemTextColorActiveHover: '#26A69A',
itemIconColorActive: '#009688',
borderRadius: '8px',
},
Tabs: {
tabTextColorActiveLine: '#009688',
tabTextColorHoverLine: '#26A69A',
barColor: '#009688',
},
Tag: {
borderRadius: '6px',
},
DataTable: {
thColor: '#E0F2F1',
},
}
</script>
<style>
body {
margin: 0;
background-color: #F5FBF9;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
import api from './client'
export const adminApi = {
login: (password: string) =>
api.post('/admin/login', { password }),
getDashboard: () => api.get('/admin/dashboard'),
getStats: (metric: string, days = 7) =>
api.get(`/admin/stats/${metric}`, { params: { days } }),
getUsers: (params?: { page?: number; page_size?: number; search?: string; status?: string }) =>
api.get('/admin/users', { params }),
banUser: (userId: string, isBanned: boolean, reason?: string) =>
api.put(`/admin/users/${userId}/ban`, { is_banned: isBanned, reason }),
deleteUser: (userId: string) =>
api.delete(`/admin/users/${userId}`),
getMessages: (params?: Record<string, string>) =>
api.get('/admin/messages', { params }),
getConfig: () => api.get('/admin/config'),
updateConfig: (configs: Record<string, string>) =>
api.put('/admin/config', { configs }),
}
+14
View File
@@ -0,0 +1,14 @@
import api from './client'
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
register: (username: string, email: string, password: string) =>
api.post('/auth/register', { username, email, password }),
refresh: (refreshToken: string) =>
api.post('/auth/refresh', { refresh_token: refreshToken }),
getProfile: () => api.get('/users/me'),
}
+25
View File
@@ -0,0 +1,25 @@
import api from './client'
export const chatApi = {
getConversations: () => api.get('/conversations/'),
createPrivateConversation: (userId: string) =>
api.post('/conversations/', { type: 'private', member_ids: [userId] }),
createGroup: (name: string, memberIds: string[], description?: string) =>
api.post('/conversations/group', { name, member_ids: memberIds, description }),
getConversationDetail: (id: string) => api.get(`/conversations/${id}`),
getMessages: (conversationId: string, before?: string, limit = 50) => {
const params: Record<string, any> = { limit }
if (before) params.before = before
return api.get(`/conversations/${conversationId}/messages`, { params })
},
markAsRead: (conversationId: string, messageId: string) =>
api.put(`/conversations/${conversationId}/messages/${messageId}/read`),
deleteMessage: (conversationId: string, messageId: string) =>
api.delete(`/conversations/${conversationId}/messages/${messageId}`),
}
+49
View File
@@ -0,0 +1,49 @@
import axios from 'axios'
import type { AxiosInstance } from 'axios'
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
const api: AxiosInstance = axios.create({
baseURL: `${API_BASE}/api/v1`,
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器:附加 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:处理 401 自动刷新
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
try {
const { data } = await axios.post(`${API_BASE}/api/v1/auth/refresh`, {
refresh_token: refreshToken,
})
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
originalRequest.headers.Authorization = `Bearer ${data.access_token}`
return api(originalRequest)
} catch {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
}
}
return Promise.reject(error)
},
)
export default api
+19
View File
@@ -0,0 +1,19 @@
import api from './client'
export const friendsApi = {
getFriends: () => api.get('/friends/'),
getPendingRequests: () => api.get('/friends/requests'),
sendRequest: (toUserId: string, message?: string) =>
api.post('/friends/request', { to_user_id: toUserId, message }),
acceptRequest: (requestId: string) =>
api.put(`/friends/request/${requestId}/accept`),
rejectRequest: (requestId: string) =>
api.put(`/friends/request/${requestId}/reject`),
removeFriend: (friendId: string) =>
api.delete(`/friends/${friendId}`),
}
+93
View File
@@ -0,0 +1,93 @@
/* 青叶全局样式 */
:root {
/* 青绿色主题色 */
--color-primary: #009688;
--color-primary-light: #26A69A;
--color-primary-lighter: #80CBC4;
--color-primary-lightest: #E0F2F1;
--color-primary-dark: #00796B;
--color-primary-darker: #004D40;
/* 背景 */
--color-bg: #F5FBF9;
--color-surface: #FFFFFF;
--color-surface-elevated: #FAFFFE;
/* 文字 */
--color-text-primary: #1A2E2A;
--color-text-secondary: #5F7A74;
--color-text-hint: #9DB5AE;
/* 边框 */
--color-border: #D4E8E3;
/* 状态色 */
--color-success: #4CAF50;
--color-warning: #FF9800;
--color-error: #EF5350;
--color-unread: #FF6B6B;
/* 聊天气泡 */
--color-bubble-self: #009688;
--color-bubble-self-text: #FFFFFF;
--color-bubble-other: #E8F5F1;
--color-bubble-other-text: #1A2E2A;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--color-text-primary);
background-color: var(--color-bg);
}
#app {
height: 100%;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--color-primary-lighter);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-primary-light);
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 淡入动画 */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* 滑入动画 */
.slide-enter-active, .slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(20px);
opacity: 0;
}
+88
View File
@@ -0,0 +1,88 @@
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8000'
let ws: WebSocket | null = null
let reconnectAttempts = 0
const maxReconnectAttempts = 10
export function useWebSocket() {
const connected = ref(false)
function connect() {
const auth = useAuthStore()
if (!auth.accessToken) return
const url = `${WS_BASE}/ws?token=${auth.accessToken}`
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
reconnectAttempts = 0
console.log('🌿 WebSocket 已连接')
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
handleEvent(msg)
} catch (e) {
console.error('WebSocket 消息解析失败:', e)
}
}
ws.onclose = () => {
connected.value = false
console.log('🌿 WebSocket 已断开')
// 自动重连
if (reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
reconnectAttempts++
setTimeout(connect, delay)
}
}
ws.onerror = (error) => {
console.error('WebSocket 错误:', error)
}
}
function disconnect() {
if (ws) {
ws.close()
ws = null
}
connected.value = false
}
function send(type: string, data: any) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, data }))
}
}
function handleEvent(event: { type: string; data: any }) {
const chatStore = useChatStore()
switch (event.type) {
case 'chat.message':
chatStore.addMessage(event.data)
break
case 'presence.online':
case 'presence.offline':
// 可以在这里更新联系人在线状态
console.log(`用户 ${event.data.user_id} ${event.type === 'presence.online' ? '上线' : '下线'}`)
break
case 'friend.request':
console.log('收到好友请求:', event.data)
break
case 'error':
console.error('服务端错误:', event.data.message)
break
}
}
return { connected, connect, disconnect, send }
}
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="admin-logo">
<span style="font-size: 24px">🌿</span>
<span style="font-weight: 600; color: var(--color-primary-dark)">管理后台</span>
</div>
<nav class="admin-nav">
<router-link to="/admin/dashboard" class="admin-nav-item" active-class="active">📊 仪表盘</router-link>
<router-link to="/admin/users" class="admin-nav-item" active-class="active">👥 用户管理</router-link>
<router-link to="/admin/messages" class="admin-nav-item" active-class="active">💬 消息审查</router-link>
<router-link to="/admin/config" class="admin-nav-item" active-class="active"> 系统配置</router-link>
</nav>
<div style="margin-top: auto; padding: 16px">
<n-button text @click="$router.push('/chat')"> 返回青叶</n-button>
</div>
</aside>
<main class="admin-content">
<router-view />
</main>
</div>
</template>
<style scoped>
.admin-layout { display: flex; height: 100vh; }
.admin-sidebar {
width: 220px; background: var(--color-surface); border-right: 1px solid var(--color-border);
display: flex; flex-direction: column; padding-top: 16px;
}
.admin-logo { display: flex; align-items: center; gap: 8px; padding: 16px 20px; margin-bottom: 16px; }
.admin-nav { display: flex; flex-direction: column; gap: 2px; padding: 0 8px; }
.admin-nav-item {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
border-radius: 8px; text-decoration: none; color: var(--color-text-primary);
font-size: 14px; transition: all 0.2s;
}
.admin-nav-item:hover { background: var(--color-primary-lightest); }
.admin-nav-item.active { background: var(--color-primary-lightest); color: var(--color-primary); font-weight: 500; }
.admin-content { flex: 1; overflow-y: auto; padding: 24px; background: var(--color-bg); }
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="auth-layout">
<div class="auth-card">
<div class="auth-header">
<div class="logo">🌿</div>
<h1 class="title">青叶</h1>
<p class="subtitle">QingYe 清新社交</p>
</div>
<router-view />
</div>
</div>
</template>
<style scoped>
.auth-layout {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #E0F2F1 0%, #B2DFDB 50%, #80CBC4 100%);
}
.auth-card {
width: 420px;
background: #fff;
border-radius: 16px;
padding: 48px 40px;
box-shadow: 0 8px 32px rgba(0, 150, 136, 0.12);
}
.auth-header {
text-align: center;
margin-bottom: 36px;
}
.logo {
font-size: 48px;
margin-bottom: 8px;
}
.title {
font-size: 28px;
font-weight: 700;
color: var(--color-primary-dark);
margin: 0;
}
.subtitle {
color: var(--color-text-secondary);
font-size: 14px;
margin-top: 4px;
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div class="chat-layout">
<!-- 左侧会话列表面板 -->
<div class="left-panel" :class="{ collapsed: uiStore.sidebarCollapsed && uiStore.isMobile }">
<div class="panel-header">
<div class="user-info" @click="$router.push('/profile')">
<n-avatar v-if="auth.user?.avatar_url" :src="auth.user.avatar_url" :size="36" round />
<n-avatar v-else :size="36" round style="background: var(--color-primary)">
{{ (auth.user?.username || '?')[0].toUpperCase() }}
</n-avatar>
<span class="username">{{ auth.user?.username || '青叶用户' }}</span>
</div>
<div class="header-actions">
<n-button quaternary circle @click="$router.push('/contacts')">
<template #icon>👥</template>
</n-button>
</div>
</div>
<!-- 布局模式切换 -->
<div class="layout-switch">
<n-button-group size="small">
<n-button :type="uiStore.layoutMode === 'list' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('list')">列表</n-button>
<n-button :type="uiStore.layoutMode === 'card' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('card')">卡片</n-button>
<n-button :type="uiStore.layoutMode === 'waterfall' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('waterfall')">瀑布流</n-button>
</n-button-group>
</div>
<router-view name="left" />
<div class="conversation-list">
<div v-if="chatStore.isLoading" style="text-align: center; padding: 40px; color: var(--color-text-hint)">加载中...</div>
<div v-else-if="chatStore.conversations.length === 0" class="empty-state">
<div style="font-size: 48px">💬</div>
<p>暂无消息</p>
<p style="font-size: 13px; color: var(--color-text-hint)">去通讯录找朋友聊天吧</p>
</div>
<!-- 列表模式 -->
<template v-if="uiStore.layoutMode === 'list'">
<div v-for="conv in chatStore.conversations" :key="conv.id"
class="conv-item" :class="{ active: chatStore.activeConversation === conv.id }"
@click="openChat(conv.id)">
<n-avatar :size="46" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<div class="conv-info">
<div class="conv-top">
<span class="conv-name">{{ conv.name || '未命名' }}</span>
<span class="conv-time">{{ formatTime(conv.last_message_at) }}</span>
</div>
<div class="conv-bottom">
<span class="conv-preview">{{ conv.last_message_preview || '' }}</span>
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" />
</div>
</div>
</div>
</template>
<!-- 卡片模式 -->
<template v-else-if="uiStore.layoutMode === 'card'">
<div class="card-grid">
<div v-for="conv in chatStore.conversations" :key="conv.id"
class="conv-card" :class="{ active: chatStore.activeConversation === conv.id }"
@click="openChat(conv.id)">
<n-avatar :size="56" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<div class="conv-card-name">{{ conv.name || '未命名' }}</div>
<div class="conv-card-preview">{{ conv.last_message_preview?.substring(0, 30) || '' }}</div>
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" style="position: absolute; top: 8px; right: 8px" />
</div>
</div>
</template>
<!-- 瀑布流模式 -->
<template v-else>
<div class="waterfall-grid">
<div v-for="conv in chatStore.conversations" :key="conv.id"
class="conv-waterfall" @click="openChat(conv.id)">
<div class="wf-header">
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<span class="wf-name">{{ conv.name }}</span>
</div>
<div class="wf-content">{{ conv.last_message_preview || '暂无消息' }}</div>
<div class="wf-footer">
<span>{{ formatTime(conv.last_message_at) }}</span>
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" />
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 右侧聊天窗口 -->
<div class="right-panel">
<router-view />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useUiStore } from '@/stores/ui'
import { useWebSocket } from '@/composables/useWebSocket'
import dayjs from 'dayjs'
const router = useRouter()
const auth = useAuthStore()
const chatStore = useChatStore()
const uiStore = useUiStore()
const { connect } = useWebSocket()
onMounted(async () => {
await auth.fetchProfile()
await chatStore.fetchConversations()
connect()
})
function openChat(id: string) {
router.push(`/chat/${id}`)
}
function formatTime(time: string | null) {
if (!time) return ''
const d = dayjs(time)
const now = dayjs()
if (d.isSame(now, 'day')) return d.format('HH:mm')
if (d.isSame(now.subtract(1, 'day'), 'day')) return '昨天'
return d.format('MM/DD')
}
</script>
<style scoped>
.chat-layout { display: flex; height: 100vh; background: var(--color-bg); }
.left-panel {
width: 340px; background: var(--color-surface);
border-right: 1px solid var(--color-border);
display: flex; flex-direction: column;
transition: width 0.3s;
}
.left-panel.collapsed { width: 0; overflow: hidden; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px; border-bottom: 1px solid var(--color-border);
}
.user-info { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.username { font-weight: 500; font-size: 15px; }
.layout-switch { padding: 8px 16px; border-bottom: 1px solid var(--color-border); text-align: center; }
.conversation-list { flex: 1; overflow-y: auto; }
.empty-state { text-align: center; padding: 80px 20px; color: var(--color-text-secondary); }
/* 列表模式 */
.conv-item {
display: flex; align-items: center; padding: 12px 16px; gap: 12px;
cursor: pointer; transition: background 0.15s; border-bottom: 0.5px solid var(--color-border);
}
.conv-item:hover { background: var(--color-primary-lightest); }
.conv-item.active { background: var(--color-primary-lightest); border-left: 3px solid var(--color-primary); }
.conv-info { flex: 1; min-width: 0; }
.conv-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.conv-name { font-weight: 500; font-size: 15px; }
.conv-time { font-size: 12px; color: var(--color-text-hint); }
.conv-bottom { display: flex; justify-content: space-between; align-items: center; }
.conv-preview { font-size: 13px; color: var(--color-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
/* 卡片模式 */
.card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px; }
.conv-card {
background: var(--color-surface-elevated); border-radius: 12px; padding: 16px;
text-align: center; cursor: pointer; border: 1px solid var(--color-border);
transition: all 0.2s; position: relative;
}
.conv-card:hover { border-color: var(--color-primary-lighter); box-shadow: 0 2px 12px rgba(0,150,136,0.1); }
.conv-card.active { border-color: var(--color-primary); background: var(--color-primary-lightest); }
.conv-card-name { font-weight: 500; margin-top: 8px; font-size: 14px; }
.conv-card-preview { font-size: 12px; color: var(--color-text-hint); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 瀑布流模式 */
.waterfall-grid { columns: 2; column-gap: 10px; padding: 12px; }
.conv-waterfall {
break-inside: avoid; background: var(--color-surface-elevated); border-radius: 12px;
padding: 14px; margin-bottom: 10px; cursor: pointer; border: 1px solid var(--color-border);
transition: all 0.2s;
}
.conv-waterfall:hover { border-color: var(--color-primary-lighter); }
.wf-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.wf-name { font-weight: 500; font-size: 14px; }
.wf-content { font-size: 13px; color: var(--color-text-secondary); line-height: 1.5; margin-bottom: 8px; }
.wf-footer { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--color-text-hint); }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
@media (max-width: 768px) {
.left-panel { width: 100%; }
.right-panel { display: none; }
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<template>
<div class="main-layout">
<aside class="sidebar">
<div class="sidebar-logo">🌿</div>
<nav class="sidebar-nav">
<router-link to="/chat" class="nav-item" active-class="active" title="消息">
<span class="nav-icon">💬</span>
</router-link>
<router-link to="/contacts" class="nav-item" active-class="active" title="通讯录">
<span class="nav-icon">👥</span>
</router-link>
<router-link to="/profile" class="nav-item" active-class="active" title="我的">
<span class="nav-icon">👤</span>
</router-link>
</nav>
</aside>
<main class="main-content">
<router-view />
</main>
</div>
</template>
<style scoped>
.main-layout { display: flex; height: 100vh; }
.sidebar {
width: 64px; background: var(--color-surface); border-right: 1px solid var(--color-border);
display: flex; flex-direction: column; align-items: center; padding-top: 16px;
}
.sidebar-logo { font-size: 28px; margin-bottom: 24px; }
.sidebar-nav { display: flex; flex-direction: column; gap: 8px; }
.nav-item {
width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
border-radius: 10px; text-decoration: none; font-size: 20px; transition: all 0.2s;
}
.nav-item:hover { background: var(--color-primary-lightest); }
.nav-item.active { background: var(--color-primary-lightest); color: var(--color-primary); }
.main-content { flex: 1; overflow-y: auto; background: var(--color-bg); }
</style>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/styles/global.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+99
View File
@@ -0,0 +1,99 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresAuth: false, layout: 'auth' },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: { requiresAuth: false, layout: 'auth' },
},
{
path: '/',
component: () => import('@/layouts/ChatLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/chat' },
{
path: 'chat',
name: 'ChatList',
component: () => import('@/views/chat/ChatListView.vue'),
},
{
path: 'chat/:id',
name: 'ChatRoom',
component: () => import('@/views/chat/ChatRoomView.vue'),
},
],
},
{
path: '/contacts',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Contacts', component: () => import('@/views/contacts/ContactsView.vue') },
{ path: 'search', name: 'Search', component: () => import('@/views/contacts/SearchView.vue') },
],
},
{
path: '/profile',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Profile', component: () => import('@/views/profile/ProfileView.vue') },
],
},
{
path: '/admin',
children: [
{
path: 'login',
name: 'AdminLogin',
component: () => import('@/views/admin/AdminLoginView.vue'),
meta: { layout: 'auth' },
},
{
path: '',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAdmin: true },
children: [
{ path: '', redirect: '/admin/dashboard' },
{ path: 'dashboard', name: 'AdminDashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
{ path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/AdminUsersView.vue') },
{ path: 'messages', name: 'AdminMessages', component: () => import('@/views/admin/AdminMessagesView.vue') },
{ path: 'config', name: 'AdminConfig', component: () => import('@/views/admin/AdminConfigView.vue') },
],
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
if (to.meta.requiresAuth === false && authStore.isAuthenticated) {
next({ name: 'ChatList' })
return
}
next()
})
export default router
+44
View File
@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const user = ref<any>(null)
const accessToken = ref(localStorage.getItem('access_token') || '')
const isAuthenticated = computed(() => !!accessToken.value && !!user.value)
async function login(username: string, password: string) {
const { data } = await authApi.login(username, password)
accessToken.value = data.access_token
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
user.value = data.user
}
async function register(username: string, email: string, password: string) {
const { data } = await authApi.register(username, email, password)
accessToken.value = data.access_token
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
user.value = data.user
}
async function fetchProfile() {
try {
const { data } = await authApi.getProfile()
user.value = data
} catch {
logout()
}
}
function logout() {
user.value = null
accessToken.value = ''
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
return { user, accessToken, isAuthenticated, login, register, fetchProfile, logout }
})
+49
View File
@@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { chatApi } from '@/api/chat'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<any[]>([])
const currentMessages = ref<any[]>([])
const activeConversation = ref<string | null>(null)
const isLoading = ref(false)
async function fetchConversations() {
isLoading.value = true
try {
const { data } = await chatApi.getConversations()
conversations.value = data
} finally {
isLoading.value = false
}
}
async function fetchMessages(conversationId: string, before?: string) {
const { data } = await chatApi.getMessages(conversationId, before)
if (before) {
currentMessages.value = [...data.messages, ...currentMessages.value]
} else {
currentMessages.value = data.messages
}
activeConversation.value = conversationId
}
function addMessage(message: any) {
currentMessages.value.push(message)
// 更新会话列表中的最后消息
const conv = conversations.value.find((c) => c.id === message.conversation_id)
if (conv) {
conv.last_message_preview = message.content?.substring(0, 50)
conv.last_message_at = message.created_at
}
}
async function markAsRead(conversationId: string, messageId: string) {
await chatApi.markAsRead(conversationId, messageId)
}
return {
conversations, currentMessages, activeConversation, isLoading,
fetchConversations, fetchMessages, addMessage, markAsRead,
}
})
+36
View File
@@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type LayoutMode = 'list' | 'card' | 'waterfall'
export type ChatStyle = 'classic' | 'compact' | 'bubble'
export const useUiStore = defineStore('ui', () => {
const layoutMode = ref<LayoutMode>((localStorage.getItem('layoutMode') as LayoutMode) || 'list')
const chatStyle = ref<ChatStyle>((localStorage.getItem('chatStyle') as ChatStyle) || 'bubble')
const sidebarCollapsed = ref(false)
const isMobile = ref(window.innerWidth < 768)
function setLayoutMode(mode: LayoutMode) {
layoutMode.value = mode
localStorage.setItem('layoutMode', mode)
}
function setChatStyle(style: ChatStyle) {
chatStyle.value = style
localStorage.setItem('chatStyle', style)
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 监听窗口大小
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth < 768
})
return {
layoutMode, chatStyle, sidebarCollapsed, isMobile,
setLayoutMode, setChatStyle, toggleSidebar,
}
})
@@ -0,0 +1,48 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)"> 系统配置</h2>
<n-card>
<n-form label-placement="left" label-width="120">
<n-form-item v-for="config in configs" :key="config.key" :label="configLabels[config.key] || config.key">
<n-input v-if="config.key === 'announcement'" v-model:value="config.value" type="textarea" :rows="3" />
<n-switch v-else-if="config.key === 'allow_registration'" :value="config.value === 'true'"
@update:value="config.value = $event ? 'true' : 'false'" />
<n-input v-else v-model:value="config.value" />
</n-form-item>
<n-button type="primary" @click="saveConfigs" :loading="saving">保存配置</n-button>
</n-form>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { adminApi } from '@/api/admin'
const message = useMessage()
const configs = ref<{ key: string; value: string }[]>([])
const saving = ref(false)
const configLabels: Record<string, string> = {
platform_name: '平台名称',
announcement: '公告内容',
max_upload_size_mb: '最大上传大小 (MB)',
allow_registration: '允许新用户注册',
}
onMounted(async () => {
try { const { data } = await adminApi.getConfig(); configs.value = data } catch {}
})
async function saveConfigs() {
saving.value = true
try {
const map: Record<string, string> = {}
configs.value.forEach(c => { map[c.key] = c.value })
await adminApi.updateConfig(map)
message.success('配置已保存')
} catch { message.error('保存失败') }
finally { saving.value = false }
}
</script>
@@ -0,0 +1,88 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)">📊 数据仪表盘</h2>
<!-- 统计卡片 -->
<div class="stats-grid">
<n-card v-for="stat in statsCards" :key="stat.label" class="stat-card">
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</n-card>
</div>
<!-- 图表区域 -->
<div class="charts-grid">
<n-card title="消息量趋势" style="height: 320px">
<v-chart :option="messageChartOption" autoresize style="height: 240px" />
</n-card>
<n-card title="用户注册趋势" style="height: 320px">
<v-chart :option="registrationChartOption" autoresize style="height: 240px" />
</n-card>
<n-card title="在线用户趋势" style="height: 320px">
<v-chart :option="onlineChartOption" autoresize style="height: 240px" />
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { adminApi } from '@/api/admin'
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent])
const stats = ref<any>({})
const statsCards = computed(() => [
{ icon: '👥', label: '总用户数', value: stats.value.total_users || 0 },
{ icon: '🟢', label: '当前在线', value: stats.value.online_users || 0 },
{ icon: '💬', label: '消息总量', value: stats.value.total_messages || 0 },
{ icon: '📅', label: '今日消息', value: stats.value.today_messages || 0 },
])
const messageData = ref<{ date: string; value: number }[]>([])
const regData = ref<{ date: string; value: number }[]>([])
const onlineData = ref<{ date: string; value: number }[]>([])
const makeChartOption = (data: typeof messageData.value, color: string, type: string) => ({
tooltip: { trigger: 'axis' as const },
grid: { left: 40, right: 16, top: 16, bottom: 30 },
xAxis: { type: 'category' as const, data: data.value.map(d => d.date.slice(5)), axisLabel: { fontSize: 11 } },
yAxis: { type: 'value' as const, axisLabel: { fontSize: 11 } },
series: [{ data: data.value.map(d => d.value), type, smooth: true, itemStyle: { color }, areaStyle: type === 'line' ? { color: color + '33' } : undefined }],
})
const messageChartOption = computed(() => makeChartOption(messageData, '#009688', 'bar'))
const registrationChartOption = computed(() => makeChartOption(regData, '#26A69A', 'line'))
const onlineChartOption = computed(() => makeChartOption(onlineData, '#4CAF50', 'line'))
onMounted(async () => {
try {
const [dashRes, msgRes, regRes, onlRes] = await Promise.all([
adminApi.getDashboard(),
adminApi.getStats('messages', 7),
adminApi.getStats('registrations', 7),
adminApi.getStats('online', 7),
])
stats.value = dashRes.data
messageData.value = msgRes.data
regData.value = regRes.data
onlineData.value = onlRes.data
} catch {}
})
</script>
<style scoped>
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { text-align: center; }
.stat-icon { font-size: 32px; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 700; color: var(--color-primary); }
.stat-label { font-size: 14px; color: var(--color-text-secondary); margin-top: 4px; }
.charts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 16px; }
</style>
@@ -0,0 +1,54 @@
<template>
<div class="auth-layout">
<div class="auth-card">
<div class="auth-header">
<div class="logo">🌿</div>
<h1 class="title">管理后台</h1>
<p class="subtitle">青叶平台管理系统</p>
</div>
<n-form @submit.prevent="handleLogin">
<n-form-item label="管理员密码">
<n-input v-model:value="password" type="password" show-password-on="click" placeholder="请输入管理员密码" />
</n-form-item>
<n-button type="primary" block attr-type="submit" :loading="loading">登录</n-button>
<div style="text-align: center; margin-top: 16px">
<n-button text @click="$router.push('/login')"> 返回用户登录</n-button>
</div>
</n-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { adminApi } from '@/api/admin'
const router = useRouter()
const message = useMessage()
const password = ref('')
const loading = ref(false)
async function handleLogin() {
if (!password.value) { message.warning('请输入密码'); return }
loading.value = true
try {
const { data } = await adminApi.login(password.value)
localStorage.setItem('admin_token', data.access_token)
message.success('登录成功')
router.push('/admin/dashboard')
} catch {
message.error('密码错误')
} finally { loading.value = false }
}
</script>
<style scoped>
.auth-layout { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #004D40, #00796B, #009688); }
.auth-card { width: 400px; background: #fff; border-radius: 16px; padding: 48px 40px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
.auth-header { text-align: center; margin-bottom: 36px; }
.logo { font-size: 48px; margin-bottom: 8px; }
.title { font-size: 24px; font-weight: 700; color: var(--color-primary-dark); margin: 0; }
.subtitle { color: var(--color-text-secondary); font-size: 14px; margin-top: 4px; }
</style>
@@ -0,0 +1,47 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)">💬 消息审查</h2>
<div class="toolbar">
<n-input v-model:value="filters.keyword" placeholder="搜索关键词..." style="width: 200px" />
<n-input v-model:value="filters.user_id" placeholder="用户ID..." style="width: 160px" />
<n-input v-model:value="filters.conversation_id" placeholder="会话ID..." style="width: 160px" />
<n-button type="primary" @click="searchMessages">搜索</n-button>
</div>
<n-data-table :columns="columns" :data="messages" :loading="loading" striped />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { adminApi } from '@/api/admin'
const messages = ref<any[]>([])
const loading = ref(false)
const filters = reactive({ keyword: '', user_id: '', conversation_id: '' })
const columns = [
{ title: '发送者', key: 'sender_name', width: 100 },
{ title: '会话ID', key: 'conversation_id', width: 100, ellipsis: { tooltip: true } },
{ title: '内容', key: 'content', ellipsis: { tooltip: true } },
{ title: '类型', key: 'type', width: 80 },
{ title: '时间', key: 'created_at', width: 160, render: (row: any) => new Date(row.created_at).toLocaleString('zh-CN') },
]
async function searchMessages() {
loading.value = true
try {
const params: Record<string, string> = {}
if (filters.keyword) params.keyword = filters.keyword
if (filters.user_id) params.user_id = filters.user_id
if (filters.conversation_id) params.conversation_id = filters.conversation_id
const { data } = await adminApi.getMessages(params)
messages.value = data
} finally { loading.value = false }
}
onMounted(searchMessages)
</script>
<style scoped>
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
</style>
@@ -0,0 +1,94 @@
<template>
<div>
<h2 style="margin-top: 0; color: var(--color-primary-dark)">👥 用户管理</h2>
<div class="toolbar">
<n-input v-model:value="search" placeholder="搜索用户名..." style="width: 240px" @update:value="loadUsers" />
<n-select v-model:value="statusFilter" :options="statusOptions" style="width: 140px" @update:value="loadUsers" />
</div>
<n-data-table :columns="columns" :data="users" :pagination="{ pageSize: 20 }" :loading="loading" striped />
</div>
</template>
<script setup lang="ts">
import { ref, h, onMounted } from 'vue'
import { NButton, useMessage, useDialog } from 'naive-ui'
import { adminApi } from '@/api/admin'
const message = useMessage()
const dialog = useDialog()
const users = ref<any[]>([])
const loading = ref(false)
const search = ref('')
const statusFilter = ref<string | null>(null)
const statusOptions = [
{ label: '全部', value: null },
{ label: '在线', value: 'online' },
{ label: '已封禁', value: 'banned' },
]
const columns = [
{ title: '用户名', key: 'username', width: 120 },
{ title: '邮箱', key: 'email', width: 180 },
{ title: '状态', key: 'status', width: 80, render: (row: any) => row.is_banned ? '🚫 已封禁' : row.status === 'online' ? '🟢 在线' : '⚫ 离线' },
{ title: '注册时间', key: 'created_at', width: 160, render: (row: any) => new Date(row.created_at).toLocaleString('zh-CN') },
{
title: '操作', key: 'actions', width: 160,
render: (row: any) => [
h(NButton, {
size: 'small', type: row.is_banned ? 'success' : 'warning',
onClick: () => toggleBan(row),
}, { default: () => row.is_banned ? '解封' : '封禁' }),
h(NButton, {
size: 'small', type: 'error', style: 'margin-left: 8px',
onClick: () => deleteUser(row),
}, { default: () => '删除' }),
],
},
]
async function loadUsers() {
loading.value = true
try {
const { data } = await adminApi.getUsers({
search: search.value || undefined,
status: statusFilter.value || undefined,
})
users.value = data.items
} finally { loading.value = false }
}
function toggleBan(user: any) {
dialog.warning({
title: user.is_banned ? '解封用户' : '封禁用户',
content: `确定要${user.is_banned ? '解封' : '封禁'} ${user.username} 吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
await adminApi.banUser(user.id, !user.is_banned)
message.success('操作成功')
await loadUsers()
},
})
}
function deleteUser(user: any) {
dialog.error({
title: '⚠️ 删除用户',
content: `确定要永久删除 ${user.username} 吗?此操作不可撤销!`,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
await adminApi.deleteUser(user.id)
message.success('已删除')
await loadUsers()
},
})
}
onMounted(loadUsers)
</script>
<style scoped>
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
</style>
+50
View File
@@ -0,0 +1,50 @@
<template>
<n-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
<n-form-item path="username" label="用户名">
<n-input v-model:value="form.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item path="password" label="密码">
<n-input v-model:value="form.password" type="password" show-password-on="click" placeholder="请输入密码" />
</n-form-item>
<n-button type="primary" block attr-type="submit" :loading="loading" style="margin-top: 8px">
登录
</n-button>
<div style="text-align: center; margin-top: 16px">
<n-button text type="primary" @click="$router.push('/register')">没有账号立即注册</n-button>
</div>
</n-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: { required: true, message: '请输入用户名', trigger: 'blur' },
password: { required: true, message: '请输入密码', trigger: 'blur' },
}
async function handleLogin() {
if (!form.username || !form.password) return
loading.value = true
try {
await auth.login(form.username, form.password)
message.success('登录成功!欢迎回到青叶 🌿')
const redirect = (route.query.redirect as string) || '/chat'
router.push(redirect)
} catch (e: any) {
message.error(e.response?.data?.detail || '登录失败,请检查用户名和密码')
} finally {
loading.value = false
}
}
</script>
+58
View File
@@ -0,0 +1,58 @@
<template>
<n-form :model="form" :rules="rules" @submit.prevent="handleRegister">
<n-form-item path="username" label="用户名">
<n-input v-model:value="form.username" placeholder="2-50个字符" />
</n-form-item>
<n-form-item path="email" label="邮箱">
<n-input v-model:value="form.email" placeholder="your@email.com" />
</n-form-item>
<n-form-item path="password" label="密码">
<n-input v-model:value="form.password" type="password" show-password-on="click" placeholder="至少6位" />
</n-form-item>
<n-form-item path="confirmPassword" label="确认密码">
<n-input v-model:value="form.confirmPassword" type="password" placeholder="再次输入密码" />
</n-form-item>
<n-button type="primary" block attr-type="submit" :loading="loading" style="margin-top: 8px">
注册
</n-button>
<div style="text-align: center; margin-top: 16px">
<n-button text type="primary" @click="$router.push('/login')">已有账号去登录</n-button>
</div>
</n-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const message = useMessage()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ username: '', email: '', password: '', confirmPassword: '' })
const rules = {
username: { required: true, message: '请输入用户名', trigger: 'blur' },
email: { required: true, message: '请输入邮箱', trigger: 'blur' },
password: { required: true, min: 6, message: '密码至少6位', trigger: 'blur' },
}
async function handleRegister() {
if (form.password !== form.confirmPassword) {
message.error('两次密码不一致')
return
}
loading.value = true
try {
await auth.register(form.username, form.email, form.password)
message.success('注册成功!欢迎加入青叶 🌿')
router.push('/chat')
} catch (e: any) {
message.error(e.response?.data?.detail || '注册失败,请稍后重试')
} finally {
loading.value = false
}
}
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="chat-list-placeholder">
<div style="font-size: 64px">🌿</div>
<h2 style="color: var(--color-primary-dark)">青叶</h2>
<p style="color: var(--color-text-secondary)">选择一个会话开始聊天</p>
</div>
</template>
<style scoped>
.chat-list-placeholder {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 12px;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<template>
<div class="chat-room">
<!-- 聊天头部 -->
<div class="chat-header">
<n-button v-if="uiStore.isMobile" quaternary circle @click="$router.push('/chat')"></n-button>
<span class="room-name">{{ conversationName }}</span>
<!-- 聊天风格切换 -->
<n-button-group size="tiny" style="margin-left: auto">
<n-button :type="uiStore.chatStyle === 'classic' ? 'primary' : 'default'" @click="uiStore.setChatStyle('classic')">经典</n-button>
<n-button :type="uiStore.chatStyle === 'compact' ? 'primary' : 'default'" @click="uiStore.setChatStyle('compact')">紧凑</n-button>
<n-button :type="uiStore.chatStyle === 'bubble' ? 'primary' : 'default'" @click="uiStore.setChatStyle('bubble')">气泡</n-button>
</n-button-group>
</div>
<!-- 消息列表 -->
<div class="message-list" ref="messageListRef">
<div v-if="chatStore.currentMessages.length === 0" class="no-messages">
<p>开始聊天吧 🌿</p>
</div>
<div
v-for="msg in chatStore.currentMessages" :key="msg.id"
class="message-row" :class="{
'own': msg.sender_id === auth.user?.id,
'other': msg.sender_id !== auth.user?.id,
[`style-${uiStore.chatStyle}`]: true,
}"
>
<template v-if="msg.type === 'system'">
<div class="system-msg">{{ msg.content }}</div>
</template>
<template v-else>
<div v-if="msg.sender_id !== auth.user?.id" class="avatar">
<n-avatar :size="uiStore.chatStyle === 'compact' ? 28 : 34" round
:style="{ background: 'var(--color-primary)' }">
{{ (msg.sender_name || '?')[0] }}
</n-avatar>
</div>
<div class="bubble-area">
<div v-if="uiStore.chatStyle !== 'compact' && msg.sender_id !== auth.user?.id" class="sender-name">
{{ msg.sender_name }}
</div>
<div class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }">
<img v-if="msg.type === 'image'" :src="msg.content" class="msg-image" />
<span v-else>{{ msg.content }}</span>
</div>
<div v-if="uiStore.chatStyle === 'classic'" class="msg-time">
{{ formatTime(msg.created_at) }}
</div>
</div>
<div v-if="msg.sender_id === auth.user?.id" class="avatar">
<n-avatar :size="uiStore.chatStyle === 'compact' ? 28 : 34" round
style="background: var(--color-primary-dark)"></n-avatar>
</div>
</template>
</div>
</div>
<!-- 输入框 -->
<div class="input-bar">
<n-input v-model:value="inputText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }"
placeholder="输入消息..." @keydown.enter.exact.prevent="sendMessage" />
<n-button type="primary" :disabled="!inputText.trim()" @click="sendMessage" circle>
</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { useWebSocket } from '@/composables/useWebSocket'
import dayjs from 'dayjs'
const route = useRoute()
const chatStore = useChatStore()
const auth = useAuthStore()
const uiStore = useUiStore()
const { send } = useWebSocket()
const inputText = ref('')
const messageListRef = ref<HTMLElement>()
const conversationName = ref('')
onMounted(async () => {
const id = route.params.id as string
if (id) {
await chatStore.fetchMessages(id)
await nextTick()
scrollToBottom()
}
})
watch(() => chatStore.currentMessages.length, () => {
nextTick(scrollToBottom)
})
function scrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
}
function sendMessage() {
const text = inputText.value.trim()
if (!text || !chatStore.activeConversation) return
inputText.value = ''
send('chat.send', {
conversation_id: chatStore.activeConversation,
content: text,
type: 'text',
})
}
function formatTime(time: string) {
return dayjs(time).format('HH:mm')
}
</script>
<style scoped>
.chat-room { display: flex; flex-direction: column; height: 100%; }
.chat-header {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px; border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.room-name { font-weight: 600; font-size: 16px; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; }
.no-messages { text-align: center; padding-top: 120px; color: var(--color-text-hint); }
.message-row { display: flex; align-items: flex-start; margin-bottom: 12px; gap: 8px; }
.message-row.own { justify-content: flex-end; }
.message-row.other { justify-content: flex-start; }
.message-row.style-compact { margin-bottom: 4px; }
.bubble-area { max-width: 65%; }
.sender-name { font-size: 12px; color: var(--color-text-hint); margin-bottom: 2px; margin-left: 4px; }
.bubble { padding: 10px 14px; border-radius: 12px; font-size: 15px; line-height: 1.5; word-break: break-word; }
.bubble-self { background: var(--color-bubble-self); color: var(--color-bubble-self-text); border-top-right-radius: 4px; }
.bubble-other { background: var(--color-bubble-other); color: var(--color-bubble-other-text); border-top-left-radius: 4px; }
.msg-time { font-size: 11px; color: var(--color-text-hint); margin-top: 2px; }
.msg-image { max-width: 200px; border-radius: 8px; }
.system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 8px 0; padding: 4px 12px; }
.input-bar {
display: flex; align-items: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="contacts-page">
<div class="page-header">
<h2>通讯录</h2>
<n-button type="primary" size="small" @click="$router.push('/contacts/search')">🔍 搜索添加</n-button>
</div>
<!-- 好友请求 -->
<div v-if="pendingRequests.length > 0" class="section">
<h3 class="section-title">好友请求 ({{ pendingRequests.length }})</h3>
<div v-for="req in pendingRequests" :key="req.id" class="request-item">
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }">
{{ (req.from_username || '?')[0] }}
</n-avatar>
<div class="request-info">
<span class="request-name">{{ req.from_username }}</span>
<span class="request-msg">{{ req.message || '请求添加你为好友' }}</span>
</div>
<div class="request-actions">
<n-button type="primary" size="small" @click="acceptRequest(req.id)">接受</n-button>
<n-button size="small" @click="rejectRequest(req.id)">拒绝</n-button>
</div>
</div>
</div>
<!-- 好友列表 -->
<div class="section">
<h3 class="section-title">我的好友 ({{ friends.length }})</h3>
<div v-if="friends.length === 0" class="empty">
<p style="color: var(--color-text-hint)">还没有好友去搜索添加吧</p>
</div>
<div v-for="friend in friends" :key="friend.id" class="friend-item" @click="startChat(friend)">
<n-avatar :size="44" round :style="{ background: 'var(--color-primary)' }">
{{ (friend.username || '?')[0] }}
</n-avatar>
<div class="friend-info">
<span class="friend-name">{{ friend.remark || friend.username }}</span>
<span class="friend-status" :class="friend.status">{{ friend.status === 'online' ? '在线' : '离线' }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { friendsApi } from '@/api/friends'
import { chatApi } from '@/api/chat'
const router = useRouter()
const message = useMessage()
const friends = ref<any[]>([])
const pendingRequests = ref<any[]>([])
onMounted(async () => {
await Promise.all([loadFriends(), loadRequests()])
})
async function loadFriends() {
try { const { data } = await friendsApi.getFriends(); friends.value = data } catch {}
}
async function loadRequests() {
try { const { data } = await friendsApi.getPendingRequests(); pendingRequests.value = data } catch {}
}
async function acceptRequest(id: string) {
try {
await friendsApi.acceptRequest(id)
message.success('已添加好友')
await Promise.all([loadFriends(), loadRequests()])
} catch { message.error('操作失败') }
}
async function rejectRequest(id: string) {
try { await friendsApi.rejectRequest(id); await loadRequests() } catch {}
}
async function startChat(friend: any) {
try {
const { data } = await chatApi.createPrivateConversation(friend.friend_user_id)
router.push(`/chat/${data.id}`)
} catch { message.error('创建会话失败') }
}
</script>
<style scoped>
.contacts-page { max-width: 800px; margin: 0 auto; padding: 24px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.page-header h2 { margin: 0; color: var(--color-primary-dark); }
.section { margin-bottom: 24px; }
.section-title { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 12px; padding-left: 4px; }
.request-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--color-surface); border-radius: 10px; margin-bottom: 8px; }
.request-info { flex: 1; }
.request-name { font-weight: 500; display: block; }
.request-msg { font-size: 13px; color: var(--color-text-hint); }
.request-actions { display: flex; gap: 8px; }
.friend-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--color-surface); border-radius: 10px; margin-bottom: 8px; cursor: pointer; transition: background 0.15s; }
.friend-item:hover { background: var(--color-primary-lightest); }
.friend-info { flex: 1; }
.friend-name { font-weight: 500; }
.friend-status { font-size: 12px; margin-left: 8px; }
.friend-status.online { color: var(--color-success); }
.friend-status.offline { color: var(--color-text-hint); }
.empty { text-align: center; padding: 40px; }
</style>
@@ -0,0 +1,58 @@
<template>
<div class="search-page">
<h2>搜索用户</h2>
<n-input v-model:value="keyword" placeholder="输入用户名或邮箱搜索..." size="large" @input="debouncedSearch" style="margin: 16px 0" />
<div v-if="results.length > 0">
<div v-for="user in results" :key="user.id" class="search-result">
<n-avatar :size="44" round :style="{ background: 'var(--color-primary)' }">{{ (user.username)[0] }}</n-avatar>
<div class="result-info">
<span class="result-name">{{ user.username }}</span>
<span class="result-bio">{{ user.bio || '这个人很懒,什么都没写' }}</span>
</div>
<n-button type="primary" size="small" @click="addFriend(user.id)">添加好友</n-button>
</div>
</div>
<div v-else-if="searched" class="empty">没有找到匹配的用户</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api/client'
const message = useMessage()
const keyword = ref('')
const results = ref<any[]>([])
const searched = ref(false)
let timer: any = null
const debouncedSearch = () => {
clearTimeout(timer)
timer = setTimeout(async () => {
if (!keyword.value.trim()) { results.value = []; return }
try {
const { data } = await api.get('/users/search', { params: { q: keyword.value } })
results.value = data; searched.value = true
} catch {}
}, 400)
}
async function addFriend(userId: string) {
try {
await api.post('/friends/request', { to_user_id: userId })
message.success('好友请求已发送')
} catch (e: any) {
message.error(e.response?.data?.detail || '发送失败')
}
}
</script>
<style scoped>
.search-page { max-width: 600px; margin: 0 auto; padding: 24px; }
.search-result { display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--color-surface); border-radius: 10px; margin-bottom: 8px; }
.result-info { flex: 1; }
.result-name { font-weight: 500; display: block; }
.result-bio { font-size: 13px; color: var(--color-text-hint); }
.empty { text-align: center; padding: 40px; color: var(--color-text-hint); }
</style>
@@ -0,0 +1,70 @@
<template>
<div class="profile-page">
<div class="profile-card">
<n-avatar :size="80" round :style="{ background: 'var(--color-primary)', fontSize: '32px' }">
{{ (auth.user?.username || '?')[0].toUpperCase() }}
</n-avatar>
<div class="profile-info">
<h2>{{ auth.user?.username }}</h2>
<p>{{ auth.user?.email }}</p>
<p class="bio">{{ auth.user?.bio || '这个人很懒,什么都没写' }}</p>
</div>
</div>
<n-card title="个人设置" style="margin-top: 16px">
<n-form :model="form" label-placement="left" label-width="80">
<n-form-item label="用户名">
<n-input v-model:value="form.username" />
</n-form-item>
<n-form-item label="个性签名">
<n-input v-model:value="form.bio" type="textarea" :rows="2" />
</n-form-item>
<n-button type="primary" @click="saveProfile">保存修改</n-button>
</n-form>
</n-card>
<n-card style="margin-top: 16px">
<n-button type="error" ghost block @click="handleLogout">退出登录</n-button>
</n-card>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/client'
const router = useRouter()
const message = useMessage()
const auth = useAuthStore()
const form = reactive({ username: '', bio: '' })
onMounted(() => {
form.username = auth.user?.username || ''
form.bio = auth.user?.bio || ''
})
async function saveProfile() {
try {
await api.put('/users/me', form)
await auth.fetchProfile()
message.success('保存成功')
} catch { message.error('保存失败') }
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.profile-page { max-width: 600px; margin: 0 auto; padding: 24px; }
.profile-card { display: flex; align-items: center; gap: 20px; padding: 24px; background: var(--color-surface); border-radius: 16px; }
.profile-info h2 { margin: 0 0 4px; color: var(--color-primary-dark); }
.profile-info p { margin: 0; color: var(--color-text-secondary); font-size: 14px; }
.bio { margin-top: 8px !important; }
</style>
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
watch: {
usePolling: true,
},
},
})