Files
MoviePilot-Frontend/src/views/user/UserProfileView.vue
2026-01-26 13:34:22 +08:00

558 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { VForm } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import type { User, PassKey } from '@/api/types'
import avatar1 from '@images/avatars/avatar-1.png'
import { useDisplay } from 'vuetify'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'
import PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'
// 国际化
const { t, locale } = useI18n()
// 显示器宽度
const display = useDisplay()
const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
// 用户 Store
const userStore = useUserStore()
// 提示框
const $toast = useToast()
const refInputEl = ref<HTMLElement>()
// 正在保存
const isSaving = ref(false)
// 开启双重验证窗口
const otpDialog = ref(false)
// 当前头像缓存
const currentAvatar = ref(avatar1)
// 当前用户名
const currentUserName = ref('')
// 当前用户信息
const accountInfo = ref<User>({
id: 0,
name: '',
password: '',
email: '',
is_active: false,
is_superuser: false,
avatar: '',
is_otp: false,
permissions: {},
settings: {},
nickname: '',
})
// PassKey列表
const passkeyList = ref<PassKey[]>([])
// PassKey对话框
const passkeyDialog = ref(false)
// 双重验证菜单
const mfaMenu = ref(false)
// 密码验证对话框
const verifyPasswordDialog = ref(false)
// 验证密码
const verifyPassword = ref('')
// 验证后的回调
const verifyCallback = ref<((password: string) => void) | null>(null)
// 验证对话框标题
const verifyTitle = ref('')
// 验证对话框提示
const verifyText = ref('')
// 检查是否已启用任何双重验证
const hasMfaEnabled = computed(() => {
return accountInfo.value.is_otp || passkeyList.value.length > 0
})
// 更新头像
function changeAvatar(file: Event) {
const fileReader = new FileReader()
const { files } = file.target as HTMLInputElement
if (files && files.length > 0) {
const selectedFile = files[0]
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const maxSize = 800 * 1024
// 检查文件是否为图片
if (!allowedTypes.includes(selectedFile.type)) {
$toast.error(t('profile.avatarFormatError'))
return
}
// 检查文件大小
if (selectedFile.size > maxSize) {
$toast.error(t('profile.avatarSizeError'))
return
}
fileReader.readAsDataURL(selectedFile)
fileReader.onload = () => {
if (typeof fileReader.result === 'string') {
currentAvatar.value = fileReader.result
$toast.success(t('profile.avatarUploadSuccess'))
}
}
}
}
// 重置默认头像
function resetDefaultAvatar() {
currentAvatar.value = avatar1
$toast.success(t('profile.resetAvatarSuccess'))
}
// 还原当前头像
function restoreCurrentAvatar() {
currentAvatar.value = accountInfo.value.avatar
$toast.success(t('profile.restoreAvatarSuccess'))
}
// 加载当前用户信息
async function fetchUserInfo() {
try {
const result: User = await api.get(`user/${userStore.userName}`)
if (result) {
accountInfo.value = result
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''
currentUserName.value = accountInfo.value.name
currentAvatar.value = accountInfo.value.avatar
// 同时加载PassKey列表
await fetchPassKeyList()
}
} catch (error) {
console.log(error)
}
}
// 保存账户信息
async function saveAccountInfo() {
if (isSaving.value) {
$toast.error(t('profile.savingInProgress'))
return
}
if (!currentUserName.value) {
$toast.error(t('profile.usernameRequired'))
return
}
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error(t('profile.passwordMismatch'))
return
}
accountInfo.value.password = newPassword.value
}
// 将nickname保存到settings中后端可以直接处理JSON对象
if (accountInfo.value.nickname) {
if (!accountInfo.value.settings) {
accountInfo.value.settings = {}
}
accountInfo.value.settings.nickname = accountInfo.value.nickname
}
const oldUserName = accountInfo.value.name
const oldAvatar = accountInfo.value.avatar
accountInfo.value.avatar = currentAvatar.value
accountInfo.value.name = currentUserName.value
isSaving.value = true
try {
// 创建一个临时对象来保存用户数据,确保所有字段都会发送
const userData = { ...accountInfo.value }
const result: { [key: string]: any } = await api.put('user/', userData)
if (result.success) {
if (oldUserName !== currentUserName.value) {
$toast.success(t('profile.usernameChangeSuccess', { oldName: oldUserName, newName: currentUserName.value }))
// 更新本地用户名显示
userStore.setUserName(currentUserName.value)
} else {
$toast.success(t('profile.saveSuccess'))
}
// 更新本地头像显示
if (oldAvatar !== currentAvatar.value) {
userStore.setAvatar(currentAvatar.value)
}
} else {
if (oldAvatar !== currentAvatar.value) {
$toast.error(
t('profile.saveFailedWithNameChange', {
oldName: oldUserName,
newName: currentUserName.value,
message: result.message,
}),
)
} else {
$toast.error(t('profile.saveFailed', { message: result.message }))
}
// 失败缓存值还原
currentUserName.value = accountInfo.value.name
accountInfo.value.name = oldUserName
currentAvatar.value = accountInfo.value.avatar
accountInfo.value.avatar = oldAvatar
}
} catch (error) {
console.log('保存失败:', error)
}
isSaving.value = false
}
// 验证密码载荷接口
interface VerifyPasswordPayload {
title: string
text: string
callback: (password: string) => void
}
// 密码验证并执行回调
function withPasswordVerification(title: string, text: string, callback: (password: string) => void) {
verifyTitle.value = title
verifyText.value = text
verifyCallback.value = callback
verifyPassword.value = ''
verifyPasswordDialog.value = true
}
// 弹窗请求密码验证
function onVerifyPassword({ title, text, callback }: VerifyPasswordPayload) {
withPasswordVerification(title, text, callback)
}
// 确认密码验证
async function confirmVerifyPassword() {
if (!verifyPassword.value) {
$toast.error(t('user.passwordHint'))
return
}
if (verifyCallback.value) {
verifyCallback.value(verifyPassword.value)
}
verifyPasswordDialog.value = false
}
// 获取PassKey列表
async function fetchPassKeyList() {
try {
const result: { [key: string]: any } = await api.get('mfa/passkey/list')
if (result.success) {
passkeyList.value = result.data || []
}
} catch (error) {
console.log(error)
}
}
// 加载当前用户数据
onMounted(() => {
fetchUserInfo()
})
// 监听 localStorage 中的用户头像变化
watch(
() => userStore.avatar,
() => {
currentAvatar.value = userStore.avatar
},
)
</script>
<template>
<div>
<VRow>
<VCol cols="12">
<VCard :title="t('profile.personalInfo')">
<VCardText class="flex">
<!-- 👉 Avatar -->
<VAvatar rounded="lg" size="100" class="me-6" :image="currentAvatar" />
<!-- 👉 Upload Photo -->
<form class="flex flex-col justify-center gap-5">
<div class="flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('profile.uploadNewAvatar') }}</span>
</VBtn>
<input
ref="refInputEl"
type="file"
name="file"
accept=".jpeg,.png,.jpg,GIF"
hidden
@input="changeAvatar"
/>
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar">
<VIcon icon="mdi-refresh" />
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.reset') }}</span>
</VBtn>
<VBtn type="reset" color="error" variant="tonal" @click="resetDefaultAvatar">
<VIcon icon="mdi-image-sync-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.default') }}</span>
</VBtn>
<!-- 双重验证菜单按钮 -->
<VMenu v-model="mfaMenu" :close-on-content-click="false">
<template #activator="{ props }">
<VBtn :color="hasMfaEnabled ? 'warning' : 'success'" variant="tonal" v-bind="props">
<VIcon icon="mdi-shield-key" />
<span v-if="display.mdAndUp.value" class="ms-2">
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}
</span>
<VIcon icon="mdi-menu-down" class="ms-1" />
</VBtn>
</template>
<VList>
<VListItem
@click="
() => {
otpDialog = true
mfaMenu = false
}
"
>
<template #prepend>
<VIcon icon="mdi-cellphone-key" />
</template>
<VListItemTitle>{{ t('profile.useAuthenticator') }}</VListItemTitle>
<VListItemSubtitle v-if="accountInfo.is_otp" class="text-success">
{{ t('profile.enabled') }}
</VListItemSubtitle>
</VListItem>
<VListItem
@click="
() => {
passkeyDialog = true
mfaMenu = false
}
"
>
<template #prepend>
<VIcon icon="material-symbols:passkey" />
</template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
{{ t('profile.keysCount', { count: passkeyList.length }) }}
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
</div>
<p class="text-body-1 mb-0">{{ t('profile.avatarFormatTip') }}</p>
</form>
</VCardText>
<VCardText>
<!-- 👉 Form -->
<VForm class="mt-6">
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="currentUserName"
density="comfortable"
readonly
:label="t('user.username')"
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.email"
density="comfortable"
clearable
:label="t('user.email')"
type="email"
prepend-inner-icon="mdi-email"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="newPassword"
density="comfortable"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
:label="t('user.password')"
autocomplete=""
prepend-inner-icon="mdi-lock"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<!-- 👉 confirm password -->
<VTextField
v-model="confirmPassword"
density="comfortable"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
:label="t('user.confirmPassword')"
prepend-inner-icon="mdi-lock-check"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.nickname"
density="comfortable"
clearable
:label="t('profile.nickname')"
:placeholder="t('profile.nicknamePlaceholder')"
prepend-inner-icon="mdi-card-account-details"
/>
</VCol>
</VRow>
<VDivider class="my-10">
<span>{{ t('profile.accountBinding') }}</span>
</VDivider>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.wechat_userid"
density="comfortable"
clearable
:label="t('profile.wechatUser')"
prepend-inner-icon="mdi-wechat"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.telegram_userid"
density="comfortable"
clearable
:label="t('profile.telegramUser')"
prepend-inner-icon="mdi-send"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.slack_userid"
density="comfortable"
clearable
:label="t('profile.slackUser')"
prepend-inner-icon="mdi-slack"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.discord_userid"
density="comfortable"
clearable
:label="t('profile.discordUser')"
prepend-inner-icon="mdi-discord"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.vocechat_userid"
density="comfortable"
clearable
:label="t('profile.vocechatUser')"
prepend-inner-icon="mdi-chat"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.synologychat_userid"
density="comfortable"
clearable
:label="t('profile.synologychatUser')"
prepend-inner-icon="mdi-message"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="accountInfo.settings.douban_userid"
density="comfortable"
clearable
:label="t('profile.doubanUser')"
prepend-inner-icon="mdi-movie"
/>
</VCol>
</VRow>
<VRow>
<!-- 👉 Form Actions -->
<VCol cols="12" class="d-flex flex-wrap gap-4">
<VBtn @click="saveAccountInfo" :disabled="isSaving" prepend-icon="mdi-content-save">
<span v-if="isSaving">{{ t('common.saving') }}...</span>
<span v-else>{{ t('common.save') }}</span>
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- 双重验证弹窗 -->
<OTPAuthDialog
v-model="otpDialog"
v-model:is-otp="accountInfo.is_otp"
:passkey-list="passkeyList"
@verify-password="onVerifyPassword"
/>
<!-- PassKey管理对话框 -->
<PasskeyDialog
v-model="passkeyDialog"
:is-otp="accountInfo.is_otp"
v-model:passkey-list="passkeyList"
@verify-password="onVerifyPassword"
/>
<!-- 密码验证对话框 -->
<VDialog v-model="verifyPasswordDialog" max-width="30rem">
<VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ verifyTitle }}</VCardTitle>
<VCardText>
<p class="mb-4">{{ verifyText }}</p>
<VForm @submit.prevent="confirmVerifyPassword">
<VTextField
v-model="verifyPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:label="t('user.password')"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
variant="outlined"
prepend-inner-icon="mdi-lock"
autocomplete="current-password"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
<div class="d-flex justify-end gap-4 mt-4">
<VBtn variant="outlined" color="secondary" @click="verifyPasswordDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit" color="primary">
{{ t('common.confirm') }}
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</div>
</template>