1.9+
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`),
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user