From 24017e7454da78ff4a47afa127489e30e052d5f7 Mon Sep 17 00:00:00 2001 From: hefanyang Date: Sat, 13 Jun 2026 07:33:46 +0800 Subject: [PATCH] 1.0 --- CLAUDE.md | 77 +++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 4 + backend/app/models/moment.py | 61 ++++ backend/app/models/user.py | 1 + backend/app/routers/conversations.py | 85 ++++- backend/app/routers/friends.py | 33 +- backend/app/routers/moments.py | 140 ++++++++ backend/app/routers/users.py | 35 +- backend/app/schemas/auth.py | 1 + backend/app/schemas/conversation.py | 14 + backend/app/schemas/friend.py | 4 + backend/app/schemas/moment.py | 48 +++ backend/app/schemas/user.py | 12 +- backend/app/services/conversation_service.py | 102 +++++- backend/app/services/friend_service.py | 45 ++- backend/app/services/moment_service.py | 312 ++++++++++++++++++ backend/app/services/user_service.py | 30 +- frontend/src/App.vue | 12 +- frontend/src/api/chat.ts | 12 + frontend/src/api/friends.ts | 6 + frontend/src/api/moments.ts | 30 ++ frontend/src/layouts/UnifiedLayout.vue | 283 ++++++++++++++++ frontend/src/main.ts | 44 +++ frontend/src/router/index.ts | 129 ++++++-- frontend/src/stores/moments.ts | 73 ++++ frontend/src/views/chat/ChatRoomView.vue | 121 ++++--- .../src/views/chat/ConversationListPanel.vue | 216 ++++++++++++ frontend/src/views/chat/CreateGroupModal.vue | 133 ++++++++ frontend/src/views/chat/GroupInfoPanel.vue | 156 +++++++++ .../src/views/contacts/ContactsSidebar.vue | 129 ++++++++ frontend/src/views/contacts/SearchView.vue | 49 ++- frontend/src/views/moments/MomentCard.vue | 151 +++++++++ .../src/views/moments/MomentsFeedView.vue | 168 ++++++++++ frontend/src/views/settings/AboutView.vue | 70 ++++ .../views/settings/AccountSettingsView.vue | 150 +++++++++ .../settings/NotificationSettingsView.vue | 85 +++++ .../views/settings/ProfileSettingsView.vue | 118 +++++++ .../src/views/settings/SettingsSidebar.vue | 93 ++++++ 提示词.md | 8 +- 40 files changed, 3135 insertions(+), 108 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/app/models/moment.py create mode 100644 backend/app/routers/moments.py create mode 100644 backend/app/schemas/moment.py create mode 100644 backend/app/services/moment_service.py create mode 100644 frontend/src/api/moments.ts create mode 100644 frontend/src/layouts/UnifiedLayout.vue create mode 100644 frontend/src/stores/moments.ts create mode 100644 frontend/src/views/chat/ConversationListPanel.vue create mode 100644 frontend/src/views/chat/CreateGroupModal.vue create mode 100644 frontend/src/views/chat/GroupInfoPanel.vue create mode 100644 frontend/src/views/contacts/ContactsSidebar.vue create mode 100644 frontend/src/views/moments/MomentCard.vue create mode 100644 frontend/src/views/moments/MomentsFeedView.vue create mode 100644 frontend/src/views/settings/AboutView.vue create mode 100644 frontend/src/views/settings/AccountSettingsView.vue create mode 100644 frontend/src/views/settings/NotificationSettingsView.vue create mode 100644 frontend/src/views/settings/ProfileSettingsView.vue create mode 100644 frontend/src/views/settings/SettingsSidebar.vue diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65e6059 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**青叶 (QingYe)** — a social chat application with teal-green theme (#009688 primary). Monorepo with `frontend/` (Vue 3) and `backend/` (Python FastAPI), all orchestrated via Docker Compose. + +## Development Commands + +All services run in Docker. No host dependencies needed. + +```bash +docker compose up --build # Start all 4 services +docker compose up -d # Start detached +docker compose down # Stop +docker compose logs -f backend # View logs +docker compose restart backend # Restart a service +``` + +**Backend** runs on `localhost:8000`, **Frontend** on `localhost:5173`. Both have hot-reload via volume mounts. Database changes requiring new columns need manual `ALTER TABLE` or `docker compose restart backend` (which triggers `Base.metadata.create_all`). + +## Architecture + +### Backend (FastAPI + SQLAlchemy 2.0 async) + +**Pattern**: Router → Service → Model, with Pydantic v2 schemas for request/response. + +- `backend/app/main.py` — App entry, lifespan (creates DB tables, seeds config), router registration at `/api/v1/{module}` +- `backend/app/config.py` — Pydantic Settings from env vars +- `backend/app/database.py` — Async engine + session factory + declarative Base +- `backend/app/dependencies.py` — `get_db()`, `get_current_user()`, `get_admin_user()` +- `backend/app/utils/security.py` — JWT (HS256, 30min access / 7day refresh), bcrypt hashing + +**API routes**: auth, users, conversations, messages, friends, admin, uploads, **moments** (friend circle) + +**Models** (7 + 3 new): User, Conversation, ConversationMember, Message, Friend, FriendRequest, SystemConfig, **Moment, MomentLike, MomentComment** + +**Key convention**: All IDs are `str(uuid.uuid4())`, not auto-increment. Services return dicts, not ORM objects. Timestamps use `datetime.utcnow()` (NOT timezone-aware — PostgreSQL columns are `TIMESTAMP WITHOUT TIME ZONE`). + +### Frontend (Vue 3 + Vite + Naive UI) + +**Pattern**: Composition API with ` + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 56d50e3..6df4b0b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,6 +9,7 @@ import { NConfigProvider, NMessageProvider, NDialogProvider, + NNotificationProvider, NButton, NInput, NForm, @@ -20,6 +21,27 @@ import { NSwitch, NSelect, NButtonGroup, + NModal, + NUpload, + NTooltip, + NTab, + NTabs, + NTabPane, + NDivider, + NSpace, + NImage, + NIcon, + NText, + NPopconfirm, + NResult, + NSpin, + NEmpty, + NGrid, + NGridItem, + NStatistic, + NTag, + NRadio, + NRadioGroup, } from 'naive-ui' const app = createApp(App) @@ -34,6 +56,7 @@ const naiveComponents: Record = { NConfigProvider, NMessageProvider, NDialogProvider, + NNotificationProvider, NButton, NInput, NForm, @@ -45,6 +68,27 @@ const naiveComponents: Record = { NSwitch, NSelect, NButtonGroup, + NModal, + NUpload, + NTooltip, + NTab, + NTabs, + NTabPane, + NDivider, + NSpace, + NImage, + NIcon, + NText, + NPopconfirm, + NResult, + NSpin, + NEmpty, + NGrid, + NGridItem, + NStatistic, + NTag, + NRadio, + NRadioGroup, } Object.entries(naiveComponents).forEach(([name, component]) => { app.component(name, component) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7c385e7..a45f839 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -13,36 +13,115 @@ const routes: RouteRecordRaw[] = [ ], }, - // ==================== 聊天主界面(套用 ChatLayout)===================== + // ==================== 主界面(统一布局)===================== { path: '/', - component: () => import('@/layouts/ChatLayout.vue'), + component: () => import('@/layouts/UnifiedLayout.vue'), meta: { requiresAuth: true }, children: [ + // 聊天 + { + path: 'chat', + children: [ + { + path: '', + name: 'ChatList', + components: { + secondary: () => import('@/views/chat/ConversationListPanel.vue'), + default: () => import('@/views/chat/ChatListView.vue'), + }, + }, + { + path: ':id', + name: 'ChatRoom', + meta: { hideSecondary: true }, + components: { + secondary: () => import('@/views/chat/ConversationListPanel.vue'), + default: () => import('@/views/chat/ChatRoomView.vue'), + }, + }, + ], + }, + + // 通讯录 + { + path: 'contacts', + children: [ + { + path: '', + name: 'Contacts', + components: { + secondary: () => import('@/views/contacts/ContactsSidebar.vue'), + default: () => import('@/views/contacts/ContactsView.vue'), + }, + }, + { + path: 'search', + name: 'Search', + components: { + secondary: () => import('@/views/contacts/ContactsSidebar.vue'), + default: () => import('@/views/contacts/SearchView.vue'), + }, + }, + ], + }, + + // 朋友圈 + { + path: 'moments', + name: 'Moments', + components: { + secondary: () => import('@/views/moments/MomentsFeedView.vue'), + default: () => import('@/views/moments/MomentsFeedView.vue'), + }, + }, + + // 设置 + { + path: 'settings', + children: [ + { + path: '', + redirect: '/settings/profile', + }, + { + path: 'profile', + name: 'SettingsProfile', + components: { + secondary: () => import('@/views/settings/SettingsSidebar.vue'), + default: () => import('@/views/settings/ProfileSettingsView.vue'), + }, + }, + { + path: 'account', + name: 'SettingsAccount', + components: { + secondary: () => import('@/views/settings/SettingsSidebar.vue'), + default: () => import('@/views/settings/AccountSettingsView.vue'), + }, + }, + { + path: 'notifications', + name: 'SettingsNotifications', + components: { + secondary: () => import('@/views/settings/SettingsSidebar.vue'), + default: () => import('@/views/settings/NotificationSettingsView.vue'), + }, + }, + { + path: 'about', + name: 'SettingsAbout', + components: { + secondary: () => import('@/views/settings/SettingsSidebar.vue'), + default: () => import('@/views/settings/AboutView.vue'), + }, + }, + ], + }, + + // 根路径重定向 { path: '', redirect: '/chat' }, - { path: 'chat', name: 'ChatList', component: () => import('@/views/chat/ChatListView.vue') }, - { path: 'chat/:id', name: 'ChatRoom', component: () => import('@/views/chat/ChatRoomView.vue') }, - ], - }, - - // ==================== 通讯录(套用 MainLayout)===================== - { - 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: 'profile', redirect: '/settings/profile' }, ], }, diff --git a/frontend/src/stores/moments.ts b/frontend/src/stores/moments.ts new file mode 100644 index 0000000..822854b --- /dev/null +++ b/frontend/src/stores/moments.ts @@ -0,0 +1,73 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { momentsApi } from '@/api/moments' + +export const useMomentsStore = defineStore('moments', () => { + const feed = ref([]) + const isLoading = ref(false) + const hasMore = ref(true) + const cursor = ref(null) + + async function fetchFeed(refresh = false) { + if (isLoading.value) return + if (!refresh && !hasMore.value) return + + isLoading.value = true + try { + if (refresh) { + cursor.value = null + feed.value = [] + hasMore.value = true + } + const { data } = await momentsApi.getFeed(cursor.value || undefined) + if (refresh) { + feed.value = data + } else { + feed.value = [...feed.value, ...data] + } + if (data.length > 0) { + cursor.value = data[data.length - 1].id + } + if (data.length < 20) { + hasMore.value = false + } + } finally { + isLoading.value = false + } + } + + async function createMoment(content: string, images?: string[], visibility?: string) { + const { data } = await momentsApi.createMoment({ content, images, visibility }) + feed.value.unshift(data) + return data + } + + async function toggleLike(momentId: string) { + const { data } = await momentsApi.toggleLike(momentId) + const moment = feed.value.find((m) => m.id === momentId) + if (moment) { + moment.is_liked = data.is_liked + moment.like_count = data.is_liked ? moment.like_count + 1 : Math.max(0, moment.like_count - 1) + } + return data + } + + async function addComment(momentId: string, content: string, replyToId?: string) { + const { data } = await momentsApi.addComment(momentId, content, replyToId) + const moment = feed.value.find((m) => m.id === momentId) + if (moment) { + moment.comment_count = (moment.comment_count || 0) + 1 + } + return data + } + + async function deleteMoment(momentId: string) { + await momentsApi.deleteMoment(momentId) + feed.value = feed.value.filter((m) => m.id !== momentId) + } + + return { + feed, isLoading, hasMore, + fetchFeed, createMoment, toggleLike, addComment, deleteMoment, + } +}) diff --git a/frontend/src/views/chat/ChatRoomView.vue b/frontend/src/views/chat/ChatRoomView.vue index 94e8f3f..1ca288a 100644 --- a/frontend/src/views/chat/ChatRoomView.vue +++ b/frontend/src/views/chat/ChatRoomView.vue @@ -3,56 +3,76 @@
- {{ conversationName }} +
+ {{ conversationName }} + + ({{ convDetail.members?.length || 0 }}人) + +
经典 紧凑 气泡 + + + ℹ️ +
- -
-
-

开始聊天吧 🌿

-
-
- - +
+ +
+
+

开始聊天吧 🌿

+
+
+ + +
+ + +
@@ -73,6 +93,8 @@ import { useChatStore } from '@/stores/chat' import { useAuthStore } from '@/stores/auth' import { useUiStore } from '@/stores/ui' import { useWebSocket } from '@/composables/useWebSocket' +import { chatApi } from '@/api/chat' +import GroupInfoPanel from './GroupInfoPanel.vue' import dayjs from 'dayjs' const route = useRoute() @@ -84,16 +106,29 @@ const { send } = useWebSocket() const inputText = ref('') const messageListRef = ref() const conversationName = ref('') +const convDetail = ref(null) +const showGroupInfo = ref(false) onMounted(async () => { const id = route.params.id as string if (id) { await chatStore.fetchMessages(id) + await loadDetail() await nextTick() scrollToBottom() } }) +async function loadDetail() { + const id = route.params.id as string + if (!id) return + try { + const { data } = await chatApi.getConversationDetail(id) + convDetail.value = data + conversationName.value = data.name || '未命名' + } catch {} +} + watch(() => chatStore.currentMessages.length, () => { nextTick(scrollToBottom) }) @@ -127,7 +162,11 @@ function formatTime(time: string) { padding: 12px 20px; border-bottom: 1px solid var(--color-border); background: var(--color-surface); } +.header-info { display: flex; align-items: baseline; gap: 4px; } .room-name { font-weight: 600; font-size: 16px; } +.member-count { font-size: 12px; color: var(--color-text-hint); } + +.chat-body { flex: 1; display: flex; overflow: hidden; } .message-list { flex: 1; overflow-y: auto; padding: 16px 20px; } .no-messages { text-align: center; padding-top: 120px; color: var(--color-text-hint); } diff --git a/frontend/src/views/chat/ConversationListPanel.vue b/frontend/src/views/chat/ConversationListPanel.vue new file mode 100644 index 0000000..963c172 --- /dev/null +++ b/frontend/src/views/chat/ConversationListPanel.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/frontend/src/views/chat/CreateGroupModal.vue b/frontend/src/views/chat/CreateGroupModal.vue new file mode 100644 index 0000000..b123868 --- /dev/null +++ b/frontend/src/views/chat/CreateGroupModal.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/views/chat/GroupInfoPanel.vue b/frontend/src/views/chat/GroupInfoPanel.vue new file mode 100644 index 0000000..a32af2d --- /dev/null +++ b/frontend/src/views/chat/GroupInfoPanel.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/views/contacts/ContactsSidebar.vue b/frontend/src/views/contacts/ContactsSidebar.vue new file mode 100644 index 0000000..7d809eb --- /dev/null +++ b/frontend/src/views/contacts/ContactsSidebar.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/src/views/contacts/SearchView.vue b/frontend/src/views/contacts/SearchView.vue index 87c2504..607adae 100644 --- a/frontend/src/views/contacts/SearchView.vue +++ b/frontend/src/views/contacts/SearchView.vue @@ -1,18 +1,26 @@ @@ -20,6 +28,7 @@ import { ref } from 'vue' import { useMessage } from 'naive-ui' import api from '@/api/client' +import { friendsApi } from '@/api/friends' const message = useMessage() const keyword = ref('') @@ -38,9 +47,18 @@ const debouncedSearch = () => { }, 400) } -async function addFriend(userId: string) { +async function addDirect(userId: string) { try { - await api.post('/friends/request', { to_user_id: userId }) + await friendsApi.addDirect(userId) + message.success('已添加为好友') + } catch (e: any) { + message.error(e.response?.data?.detail || '添加失败') + } +} + +async function sendRequest(userId: string) { + try { + await friendsApi.sendRequest(userId) message.success('好友请求已发送') } catch (e: any) { message.error(e.response?.data?.detail || '发送失败') @@ -50,9 +68,16 @@ async function addFriend(userId: string) { diff --git a/frontend/src/views/moments/MomentCard.vue b/frontend/src/views/moments/MomentCard.vue new file mode 100644 index 0000000..d143ba6 --- /dev/null +++ b/frontend/src/views/moments/MomentCard.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/frontend/src/views/moments/MomentsFeedView.vue b/frontend/src/views/moments/MomentsFeedView.vue new file mode 100644 index 0000000..d5d0c15 --- /dev/null +++ b/frontend/src/views/moments/MomentsFeedView.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/frontend/src/views/settings/AboutView.vue b/frontend/src/views/settings/AboutView.vue new file mode 100644 index 0000000..3d97cc3 --- /dev/null +++ b/frontend/src/views/settings/AboutView.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/src/views/settings/AccountSettingsView.vue b/frontend/src/views/settings/AccountSettingsView.vue new file mode 100644 index 0000000..09e20ef --- /dev/null +++ b/frontend/src/views/settings/AccountSettingsView.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/frontend/src/views/settings/NotificationSettingsView.vue b/frontend/src/views/settings/NotificationSettingsView.vue new file mode 100644 index 0000000..af07403 --- /dev/null +++ b/frontend/src/views/settings/NotificationSettingsView.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/views/settings/ProfileSettingsView.vue b/frontend/src/views/settings/ProfileSettingsView.vue new file mode 100644 index 0000000..431435c --- /dev/null +++ b/frontend/src/views/settings/ProfileSettingsView.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/views/settings/SettingsSidebar.vue b/frontend/src/views/settings/SettingsSidebar.vue new file mode 100644 index 0000000..074f79d --- /dev/null +++ b/frontend/src/views/settings/SettingsSidebar.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/提示词.md b/提示词.md index 8a3ebfb..8b60819 100644 --- a/提示词.md +++ b/提示词.md @@ -6,4 +6,10 @@ 前端的用户和管理员界面,都看不到登录界面。用户界面目前一片空白。请检查。此外,需要实现热挂载,即修改了前端或后端代码后,不需要重启docker(除非必要),刷新就能看到效果。 -这三个页面目前都变成一片空白了 \ No newline at end of file +这三个页面目前都变成一片空白了 + +功能太少,操作不方便,过于简洁,没有基础的功能,设置页面加入修改密码等,用户名和昵称可以分开,简化加好友功能,加入群聊,左边固定栏,选择功能,内容丰富一点,加入好友圈。 + +账号绑定邮箱 + +不要列表卡片瀑布流,功能丰富一点,不要太单调,界面也丰富一点 \ No newline at end of file