This commit is contained in:
2026-06-15 19:14:13 +08:00
parent c9fc87cd89
commit 4167714149
6 changed files with 191 additions and 31 deletions
-15
View File
@@ -56,21 +56,6 @@ async def send_friend_request(
raise HTTPException(status_code=400, detail=str(e))
@router.post("/add-direct")
async def add_friend_direct(
req: FriendRequestCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""直接添加好友(跳过验证)"""
service = FriendService(db)
try:
await service.add_direct(user.id, req.to_user_id)
return {"success": True, "message": "已添加好友"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{friend_user_id}/remark")
async def update_friend_remark(
friend_user_id: str,
+19
View File
@@ -25,6 +25,10 @@ class StatusUpdate(BaseModel):
expires_hours: int | None = None
class DeleteAccountRequest(BaseModel):
password: str
@router.get("/me", response_model=UserRead)
async def get_me(user: User = Depends(get_current_user)):
"""获取当前用户信息"""
@@ -58,6 +62,21 @@ async def change_password(
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/me")
async def delete_account(
req: DeleteAccountRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""注销账号(需验证密码,级联清理数据)"""
service = UserService(db)
try:
await service.delete_account(user.id, req.password)
return {"success": True, "message": "账号已注销"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/me/email")
async def change_email(
req: EmailChange,
+10
View File
@@ -61,6 +61,16 @@ class UserService:
user.password_hash = hash_password(new_password)
user.updated_at = datetime.utcnow()
async def delete_account(self, user_id: str, password: str):
"""注销账号:验证密码后删除用户(FK CASCADE 级联清理相关数据)"""
from sqlalchemy import delete
user = await self.get_by_id(user_id)
if not user:
raise ValueError("用户不存在")
if not verify_password(password, user.password_hash):
raise ValueError("密码错误,无法注销")
await self.db.execute(delete(User).where(User.id == user_id))
async def change_email(self, user_id: str, new_email: str, password: str):
"""更换绑定邮箱"""
user = await self.get_by_id(user_id)
-3
View File
@@ -8,9 +8,6 @@ export const friendsApi = {
sendRequest: (toUserId: string, message?: string) =>
api.post('/friends/request', { to_user_id: toUserId, message }),
addDirect: (toUserId: string) =>
api.post('/friends/add-direct', { to_user_id: toUserId }),
acceptRequest: (requestId: string) =>
api.put(`/friends/request/${requestId}/accept`),
+73 -13
View File
@@ -13,29 +13,64 @@
<span class="result-bio">{{ user.bio || '这个人很懒,什么都没写' }}</span>
</div>
<div class="result-actions">
<n-button type="primary" size="small" @click="addDirect(user.id)">添加</n-button>
<n-button size="small" @click="sendRequest(user.id)">发请求</n-button>
<n-button type="primary" size="small" :disabled="user.is_friend || user.request_sent" @click="openRequest(user)">
{{ user.is_friend ? '已是好友' : user.request_sent ? '已发送' : '加为好友' }}
</n-button>
</div>
</div>
</div>
<div v-else-if="searched" class="empty">
<p style="color: var(--color-text-hint)">没有找到匹配的用户</p>
</div>
<!-- 发送好友请求弹窗需对方同意 -->
<div v-if="requestTarget" class="modal-overlay" @click.self="requestTarget = null">
<div class="request-modal">
<div class="modal-header">
<h3>添加好友</h3>
<span class="close-btn" @click="requestTarget = null"></span>
</div>
<div class="modal-body">
<div class="target-preview">
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }">
{{ (requestTarget.nickname || requestTarget.username)[0] }}
</n-avatar>
<div>
<div class="target-name">{{ requestTarget.nickname || requestTarget.username }}</div>
<div class="target-hint">发送验证消息对方同意后才能成为好友</div>
</div>
</div>
<n-input v-model:value="verifyMessage" type="textarea" :rows="3"
:placeholder="`我是 ${myName},想加你为好友...`" maxlength="100" show-count />
</div>
<div class="modal-footer">
<n-button size="small" @click="requestTarget = null">取消</n-button>
<n-button size="small" type="primary" :loading="sending" @click="confirmRequest">发送请求</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/client'
import { friendsApi } from '@/api/friends'
const message = useMessage()
const auth = useAuthStore()
const keyword = ref('')
const results = ref<any[]>([])
const searched = ref(false)
const requestTarget = ref<any>(null)
const verifyMessage = ref('')
const sending = ref(false)
let timer: any = null
const myName = computed(() => auth.user?.nickname || auth.user?.username || '我')
const debouncedSearch = () => {
clearTimeout(timer)
timer = setTimeout(async () => {
@@ -47,21 +82,25 @@ const debouncedSearch = () => {
}, 400)
}
async function addDirect(userId: string) {
try {
await friendsApi.addDirect(userId)
message.success('已添加为好友')
} catch (e: any) {
message.error(e.response?.data?.detail || '添加失败')
}
function openRequest(user: any) {
requestTarget.value = user
verifyMessage.value = `我是 ${myName.value},想加你为好友`
}
async function sendRequest(userId: string) {
async function confirmRequest() {
if (!requestTarget.value) return
sending.value = true
try {
await friendsApi.sendRequest(userId)
message.success('好友请求已发送')
await friendsApi.sendRequest(requestTarget.value.id, verifyMessage.value.trim() || undefined)
message.success('好友请求已发送,等待对方同意')
// 标记该用户已发送请求
const r = results.value.find((u) => u.id === requestTarget.value.id)
if (r) r.request_sent = true
requestTarget.value = null
} catch (e: any) {
message.error(e.response?.data?.detail || '发送失败')
} finally {
sending.value = false
}
}
</script>
@@ -80,4 +119,25 @@ async function sendRequest(userId: string) {
.result-bio { font-size: 13px; color: var(--color-text-hint); display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-actions { display: flex; gap: 6px; flex-shrink: 0; }
.empty { text-align: center; padding: 40px; }
/* 请求弹窗 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.request-modal {
width: 400px; max-width: 92vw; background: var(--color-surface); border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15); overflow: hidden;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 18px 24px; border-bottom: 1px solid var(--color-border);
}
.modal-header h3 { margin: 0; font-size: 17px; }
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.modal-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 14px; }
.target-preview { display: flex; align-items: center; gap: 12px; }
.target-name { font-weight: 600; font-size: 15px; }
.target-hint { font-size: 12px; color: var(--color-text-hint); margin-top: 2px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 24px; border-top: 1px solid var(--color-border); }
</style>
@@ -45,6 +45,47 @@
<h3 class="card-title">账号操作</h3>
<n-button type="error" ghost block @click="handleLogout">退出登录</n-button>
</div>
<!-- 注销账号 -->
<div class="card danger-card">
<h3 class="card-title danger-title">🗑 注销账号</h3>
<p class="danger-desc">注销后账号将永久删除所有聊天记录好友朋友圈花园数据都会被清除<b>不可恢复</b></p>
<n-button type="error" block @click="showDeleteModal = true">申请注销账号</n-button>
</div>
</div>
<!-- 注销确认弹窗 -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="delete-modal">
<div class="modal-header">
<h3>确认注销账号</h3>
<span class="close-btn" @click="showDeleteModal = false"></span>
</div>
<div class="modal-body">
<div class="warn-box">
此操作<b>不可撤销</b>你的所有数据将被永久删除
<ul>
<li>所有聊天记录与会话</li>
<li>好友关系与通讯录</li>
<li>朋友圈动态与花园/叶子/胶囊</li>
</ul>
</div>
<div class="form-group">
<label>为确认请输入<b>确认注销</b></label>
<n-input v-model:value="deleteConfirmText" placeholder="确认注销" />
</div>
<div class="form-group">
<label>输入登录密码</label>
<n-input v-model:value="deletePassword" type="password" show-password-on="click" placeholder="你的密码" />
</div>
</div>
<div class="modal-footer">
<n-button @click="showDeleteModal = false">取消</n-button>
<n-button type="error" :loading="deleting"
:disabled="deleteConfirmText !== '确认注销' || !deletePassword"
@click="confirmDelete">永久注销</n-button>
</div>
</div>
</div>
</div>
</template>
@@ -62,6 +103,10 @@ const message = useMessage()
const changingPwd = ref(false)
const changingEmail = ref(false)
const showDeleteModal = ref(false)
const deleteConfirmText = ref('')
const deletePassword = ref('')
const deleting = ref(false)
const pwdForm = reactive({ old_password: '', new_password: '', confirm: '' })
const emailForm = reactive({ new_email: '', password: '' })
@@ -121,6 +166,20 @@ function handleLogout() {
auth.logout()
router.push('/login')
}
async function confirmDelete() {
deleting.value = true
try {
await api.delete('/users/me', { data: { password: deletePassword.value } })
message.success('账号已注销,感谢你曾来过青叶 🌿')
auth.logout()
router.push('/login')
} catch (e: any) {
message.error(e.response?.data?.detail || '注销失败,请检查密码')
} finally {
deleting.value = false
}
}
</script>
<style scoped>
@@ -147,4 +206,34 @@ function handleLogout() {
}
.current-email .label { font-size: 13px; color: var(--color-text-secondary); }
.current-email .value { font-size: 14px; font-weight: 500; }
/* 注销账号 */
.danger-card { border-color: #FFCDD2; }
.danger-title { color: #C62828; }
.danger-desc { font-size: 13px; color: var(--color-text-secondary); margin: 0 0 12px; line-height: 1.6; }
.danger-desc b { color: #C62828; }
/* 注销弹窗 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px;
}
.delete-modal {
width: 420px; max-width: 100%; background: var(--color-surface); border-radius: 16px; overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 18px 24px; border-bottom: 1px solid var(--color-border);
}
.modal-header h3 { margin: 0; font-size: 17px; }
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.modal-body { padding: 20px 24px; }
.warn-box {
background: #FFF3E0; border-radius: 8px; padding: 12px; font-size: 13px;
color: #E65100; line-height: 1.6; margin-bottom: 16px;
}
.warn-box ul { margin: 6px 0 0 18px; padding: 0; }
.warn-box b { color: #C62828; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 24px; border-top: 1px solid var(--color-border); }
</style>