Implement compression quota refunds and admin manual subscription
This commit is contained in:
275
frontend/src/pages/dashboard/DashboardSettingsPage.vue
Normal file
275
frontend/src/pages/dashboard/DashboardSettingsPage.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { getProfile, sendVerification, updatePassword, updateProfile, type UserProfile } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const profileForm = ref({ email: '', username: '' })
|
||||
const profileBusy = ref(false)
|
||||
const profileMessage = ref<string | null>(null)
|
||||
const profileError = ref<string | null>(null)
|
||||
|
||||
const passwordForm = ref({ current: '', next: '', confirm: '' })
|
||||
const passwordBusy = ref(false)
|
||||
const passwordMessage = ref<string | null>(null)
|
||||
const passwordError = ref<string | null>(null)
|
||||
|
||||
const verificationBusy = ref(false)
|
||||
const verificationMessage = ref<string | null>(null)
|
||||
const verificationError = ref<string | null>(null)
|
||||
|
||||
const canResendVerification = computed(() => Boolean(auth.user && !auth.user.email_verified))
|
||||
|
||||
function syncProfile(user: UserProfile) {
|
||||
profileForm.value = { email: user.email ?? '', username: user.username ?? '' }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.token) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const user = await getProfile(auth.token)
|
||||
auth.updateUser(user)
|
||||
syncProfile(user)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
profileError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
profileError.value = '加载失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function resendVerification() {
|
||||
if (!auth.token) return
|
||||
verificationBusy.value = true
|
||||
verificationMessage.value = null
|
||||
verificationError.value = null
|
||||
try {
|
||||
const resp = await sendVerification(auth.token)
|
||||
verificationMessage.value = resp.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
verificationError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
verificationError.value = '发送失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
verificationBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!auth.token || !auth.user) return
|
||||
profileBusy.value = true
|
||||
profileMessage.value = null
|
||||
profileError.value = null
|
||||
try {
|
||||
const email = profileForm.value.email.trim().toLowerCase()
|
||||
const username = profileForm.value.username.trim()
|
||||
const payload: { email?: string; username?: string } = {}
|
||||
|
||||
if (email && email !== auth.user.email) payload.email = email
|
||||
if (username && username !== auth.user.username) payload.username = username
|
||||
|
||||
if (!payload.email && !payload.username) {
|
||||
profileMessage.value = '暂无更新'
|
||||
return
|
||||
}
|
||||
|
||||
const resp = await updateProfile(auth.token, payload)
|
||||
auth.updateUser(resp.user)
|
||||
syncProfile(resp.user)
|
||||
profileMessage.value = resp.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
profileError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
profileError.value = '更新失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
profileBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (!auth.token) return
|
||||
passwordBusy.value = true
|
||||
passwordMessage.value = null
|
||||
passwordError.value = null
|
||||
try {
|
||||
const currentPassword = passwordForm.value.current.trim()
|
||||
const nextPassword = passwordForm.value.next.trim()
|
||||
const confirm = passwordForm.value.confirm.trim()
|
||||
|
||||
if (!currentPassword || !nextPassword) {
|
||||
passwordError.value = '请填写当前密码与新密码'
|
||||
return
|
||||
}
|
||||
if (nextPassword !== confirm) {
|
||||
passwordError.value = '两次输入的新密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
const resp = await updatePassword(auth.token, currentPassword, nextPassword)
|
||||
passwordMessage.value = resp.message
|
||||
passwordForm.value = { current: '', next: '', confirm: '' }
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
passwordError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
passwordError.value = '更新失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
passwordBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl font-semibold text-slate-900">账号设置</h1>
|
||||
<p class="text-sm text-slate-600">更新个人资料、邮箱验证与密码。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-slate-600">加载中…</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm font-medium text-slate-900">账号资料</div>
|
||||
<span
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="auth.user?.email_verified ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-800'"
|
||||
>
|
||||
{{ auth.user?.email_verified ? '邮箱已验证' : '邮箱未验证' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">邮箱</div>
|
||||
<input
|
||||
v-model="profileForm.email"
|
||||
type="email"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="name@company.com"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">用户名</div>
|
||||
<input
|
||||
v-model="profileForm.username"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="你的用户名"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="profileMessage" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{{ profileMessage }}
|
||||
</div>
|
||||
<div v-if="profileError" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ profileError }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
:disabled="profileBusy"
|
||||
@click="saveProfile"
|
||||
>
|
||||
{{ profileBusy ? '保存中…' : '保存资料' }}
|
||||
</button>
|
||||
|
||||
<div v-if="canResendVerification">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
||||
:disabled="verificationBusy"
|
||||
@click="resendVerification"
|
||||
>
|
||||
{{ verificationBusy ? '发送中…' : '重新发送验证邮件' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="verificationMessage"
|
||||
class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900"
|
||||
>
|
||||
{{ verificationMessage }}
|
||||
</div>
|
||||
<div v-if="verificationError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ verificationError }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-slate-500">开发环境邮件关闭时,链接会打印在后端日志中。</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<div class="text-sm font-medium text-slate-900">修改密码</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">当前密码</div>
|
||||
<input
|
||||
v-model="passwordForm.current"
|
||||
type="password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="当前密码"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">新密码</div>
|
||||
<input
|
||||
v-model="passwordForm.next"
|
||||
type="password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="至少 8 位"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">确认新密码</div>
|
||||
<input
|
||||
v-model="passwordForm.confirm"
|
||||
type="password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="passwordMessage" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{{ passwordMessage }}
|
||||
</div>
|
||||
<div v-if="passwordError" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-950 disabled:opacity-50"
|
||||
:disabled="passwordBusy"
|
||||
@click="changePassword"
|
||||
>
|
||||
{{ passwordBusy ? '更新中…' : '更新密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user