首个可运行的版本
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user