Add account settings and memory coach
This commit is contained in:
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
from auth import create_access_token, get_current_user, hash_password, verify_password
|
from auth import create_access_token, get_current_user, hash_password, verify_password
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import User, UserSettings
|
from models import User, UserSettings
|
||||||
from schemas import TokenResponse, UserLogin, UserOut, UserRegister
|
from schemas import TokenResponse, UserLogin, UserOut, UserRegister, UserUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -53,3 +53,38 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
|
|||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
def me(current_user: User = Depends(get_current_user)):
|
def me(current_user: User = Depends(get_current_user)):
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=UserOut)
|
||||||
|
def update_me(
|
||||||
|
data: UserUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if data.username is None and data.new_password is None:
|
||||||
|
raise HTTPException(status_code=400, detail="请填写要修改的内容")
|
||||||
|
|
||||||
|
if data.username is not None:
|
||||||
|
username = data.username.strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=400, detail="用户名不能为空")
|
||||||
|
exists = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.username == username, User.id != current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
current_user.username = username
|
||||||
|
|
||||||
|
if data.new_password is not None:
|
||||||
|
if not data.current_password:
|
||||||
|
raise HTTPException(status_code=400, detail="修改密码需要先输入当前密码")
|
||||||
|
if not verify_password(data.current_password, current_user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="当前密码不正确")
|
||||||
|
current_user.password_hash = hash_password(data.new_password)
|
||||||
|
|
||||||
|
db.add(current_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(current_user)
|
||||||
|
return current_user
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ class UserOut(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
username: Optional[str] = Field(None, min_length=2, max_length=50)
|
||||||
|
current_password: Optional[str] = Field(None, min_length=6, max_length=100)
|
||||||
|
new_password: Optional[str] = Field(None, min_length=6, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
# Translate
|
# Translate
|
||||||
class TranslateRequest(BaseModel):
|
class TranslateRequest(BaseModel):
|
||||||
text: str = Field(min_length=1)
|
text: str = Field(min_length=1)
|
||||||
|
|||||||
@@ -172,6 +172,18 @@ export interface Settings {
|
|||||||
weak_wrong_threshold: number
|
weak_wrong_threshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileUpdate {
|
||||||
|
username?: string
|
||||||
|
current_password?: string
|
||||||
|
new_password?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemoryToken {
|
export interface MemoryToken {
|
||||||
role: string
|
role: string
|
||||||
key: string
|
key: string
|
||||||
@@ -242,7 +254,8 @@ export const api = {
|
|||||||
request.post('/auth/register', { username, password }),
|
request.post('/auth/register', { username, password }),
|
||||||
login: (username: string, password: string, remember = true) =>
|
login: (username: string, password: string, remember = true) =>
|
||||||
request.post<{ access_token: string }>('/auth/login', { username, password, remember }),
|
request.post<{ access_token: string }>('/auth/login', { username, password, remember }),
|
||||||
me: () => request.get('/auth/me'),
|
me: () => request.get<UserProfile>('/auth/me'),
|
||||||
|
updateMe: (data: UserProfileUpdate) => request.patch<UserProfile>('/auth/me', data),
|
||||||
translate: (text: string) => request.post<TranslateResult>('/translate', { text }),
|
translate: (text: string) => request.post<TranslateResult>('/translate', { text }),
|
||||||
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
|
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
|
||||||
listWords: (status?: string) =>
|
listWords: (status?: string) =>
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api } from '../api/request'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
current_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
})
|
||||||
|
const message = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail
|
||||||
|
return typeof detail === 'string' && detail.trim() ? detail : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
message.value = ''
|
||||||
|
if (!form.value.current_password) {
|
||||||
|
message.value = '请输入当前密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.new_password) {
|
||||||
|
message.value = '请输入新密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.value.new_password.length < 6) {
|
||||||
|
message.value = '新密码至少 6 位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.value.new_password !== form.value.confirm_password) {
|
||||||
|
message.value = '两次输入的新密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await api.updateMe({
|
||||||
|
current_password: form.value.current_password,
|
||||||
|
new_password: form.value.new_password,
|
||||||
|
})
|
||||||
|
form.value.current_password = ''
|
||||||
|
form.value.new_password = ''
|
||||||
|
form.value.confirm_password = ''
|
||||||
|
message.value = '登录密码已更新 ✅'
|
||||||
|
} catch (error) {
|
||||||
|
message.value = getErrorMessage(error, '更新密码失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page wechat-page">
|
||||||
|
<div class="topbar">
|
||||||
|
<button class="back-btn" @click="router.push('/settings')">← 返回</button>
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">修改密码</h1>
|
||||||
|
<p class="page-subtitle">只改这一项</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message" :class="['message', message.includes('✅') ? 'success' : 'error']">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">当前密码</span>
|
||||||
|
<span class="cell-desc">请先输入旧密码</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="form.current_password"
|
||||||
|
type="password"
|
||||||
|
class="cell-input"
|
||||||
|
placeholder="当前密码"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">新密码</span>
|
||||||
|
<span class="cell-desc">至少 6 位</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="form.new_password"
|
||||||
|
type="password"
|
||||||
|
class="cell-input"
|
||||||
|
placeholder="新密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">确认新密码</span>
|
||||||
|
<span class="cell-desc">再输一遍</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="form.confirm_password"
|
||||||
|
type="password"
|
||||||
|
class="cell-input"
|
||||||
|
placeholder="确认密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary save-btn" :disabled="loading" @click="savePassword">
|
||||||
|
{{ loading ? '更新中...' : '保存新密码' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wechat-page {
|
||||||
|
padding-top: 10px;
|
||||||
|
background: #f5f5f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-left {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #ececf0;
|
||||||
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-input {
|
||||||
|
width: 160px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: #fafafa;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 110, 247, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: auto;
|
||||||
|
min-height: 34px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+281
-34
@@ -1,30 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { api, type Settings } from '../api/request'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api, type Settings, type UserProfile } from '../api/request'
|
||||||
import { clearAuth } from '../utils/auth'
|
import { clearAuth } from '../utils/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const settings = ref<Settings>({
|
const settings = ref<Settings>({
|
||||||
daily_target: 20,
|
daily_target: 20,
|
||||||
master_required_count: 3,
|
master_required_count: 3,
|
||||||
weak_wrong_threshold: 3,
|
weak_wrong_threshold: 3,
|
||||||
})
|
})
|
||||||
const message = ref('')
|
const profile = ref<UserProfile>({
|
||||||
const loading = ref(false)
|
id: 0,
|
||||||
|
username: '',
|
||||||
onMounted(async () => {
|
created_at: '',
|
||||||
const { data } = await api.getSettings()
|
|
||||||
settings.value = data
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
const settingsMessage = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const usernameInitial = computed(() => (profile.value.username ? profile.value.username[0].toUpperCase() : '?'))
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail
|
||||||
|
return typeof detail === 'string' && detail.trim() ? detail : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
message.value = ''
|
try {
|
||||||
|
const [{ data: settingsData }, { data: profileData }] = await Promise.all([
|
||||||
|
api.getSettings(),
|
||||||
|
api.me(),
|
||||||
|
])
|
||||||
|
settings.value = settingsData
|
||||||
|
profile.value = profileData
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
loading.value = true
|
||||||
|
settingsMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const { data } = await api.updateSettings(settings.value)
|
const { data } = await api.updateSettings(settings.value)
|
||||||
settings.value = data
|
settings.value = data
|
||||||
message.value = '设置已保存 ✅'
|
settingsMessage.value = '学习设置已保存 ✅'
|
||||||
} catch {
|
} catch (error) {
|
||||||
message.value = '保存失败'
|
settingsMessage.value = getErrorMessage(error, '保存学习设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -37,35 +61,258 @@ function logout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page wechat-page">
|
||||||
<h1 class="page-title">学习设置</h1>
|
<div class="header">
|
||||||
<div v-if="message" class="message success">{{ message }}</div>
|
<h1 class="page-title">设置</h1>
|
||||||
<div class="card">
|
<p class="page-subtitle">账号与学习参数</p>
|
||||||
<label class="label">每日训练数量</label>
|
|
||||||
<input v-model.number="settings.daily_target" type="number" min="1" max="100" class="input" />
|
|
||||||
|
|
||||||
<label class="label">连续答对几次算掌握</label>
|
|
||||||
<input v-model.number="settings.master_required_count" type="number" min="1" max="20" class="input" />
|
|
||||||
|
|
||||||
<label class="label">累计错几次进入易错词</label>
|
|
||||||
<input v-model.number="settings.weak_wrong_threshold" type="number" min="1" max="20" class="input" />
|
|
||||||
|
|
||||||
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="save">
|
|
||||||
{{ loading ? '保存中...' : '保存设置' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline" style="margin-top: 20px; width: 100%" @click="logout">
|
|
||||||
|
<div v-if="settingsMessage" :class="['message', settingsMessage.includes('✅') ? 'success' : 'error']">
|
||||||
|
{{ settingsMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-title">账号安全</div>
|
||||||
|
<div class="group">
|
||||||
|
<button class="profile-entry cell-button" @click="router.push('/settings/username')">
|
||||||
|
<div class="avatar">{{ usernameInitial }}</div>
|
||||||
|
<span class="cell-left profile-text">
|
||||||
|
<span class="cell-title">{{ profile.username || '加载中' }}</span>
|
||||||
|
<span class="cell-desc">点击修改用户名</span>
|
||||||
|
</span>
|
||||||
|
<span class="chevron">›</span>
|
||||||
|
</button>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button class="cell cell-button" @click="router.push('/settings/password')">
|
||||||
|
<span class="cell-left">
|
||||||
|
<span class="cell-title">登录密码</span>
|
||||||
|
<span class="cell-desc">点击进入修改界面</span>
|
||||||
|
</span>
|
||||||
|
<span class="cell-right">
|
||||||
|
<span class="chevron">›</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-title">学习设置</div>
|
||||||
|
<div class="group">
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">每日训练数量</span>
|
||||||
|
<span class="cell-desc">1 - 100</span>
|
||||||
|
</div>
|
||||||
|
<input v-model.number="settings.daily_target" type="number" min="1" max="100" class="cell-input" />
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">连续答对几次算掌握</span>
|
||||||
|
<span class="cell-desc">1 - 20</span>
|
||||||
|
</div>
|
||||||
|
<input v-model.number="settings.master_required_count" type="number" min="1" max="20" class="cell-input" />
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">累计错几次进入易错词</span>
|
||||||
|
<span class="cell-desc">1 - 20</span>
|
||||||
|
</div>
|
||||||
|
<input v-model.number="settings.weak_wrong_threshold" type="number" min="1" max="20" class="cell-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<button class="btn btn-outline subtle-btn" @click="router.push('/settings/username')">修改用户名</button>
|
||||||
|
<button class="btn btn-primary save-btn" :disabled="loading" @click="saveSettings">
|
||||||
|
{{ loading ? '保存中...' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button class="logout-row" @click="logout">
|
||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.label {
|
.wechat-page {
|
||||||
display: block;
|
padding-top: 10px;
|
||||||
|
background: #f5f5f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 14px 0 6px;
|
|
||||||
}
|
}
|
||||||
.label:first-child { margin-top: 0; }
|
|
||||||
|
.section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a8a8f;
|
||||||
|
margin: 0 0 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-entry {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-entry:active,
|
||||||
|
.cell-button:active {
|
||||||
|
background: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #68c3ff, #4f6ef7);
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 14px;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-left {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #b2b2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #ececf0;
|
||||||
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-input {
|
||||||
|
width: 92px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: #fafafa;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 110, 247, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 88px;
|
||||||
|
min-height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-btn {
|
||||||
|
width: auto;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-row {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ff3b30;
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-row:active {
|
||||||
|
background: #f2f2f2;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api, type UserProfile } from '../api/request'
|
||||||
|
import { setSavedUsername } from '../utils/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const profile = ref<UserProfile>({
|
||||||
|
id: 0,
|
||||||
|
username: '',
|
||||||
|
created_at: '',
|
||||||
|
})
|
||||||
|
const form = ref({ username: '' })
|
||||||
|
const message = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail
|
||||||
|
return typeof detail === 'string' && detail.trim() ? detail : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await api.me()
|
||||||
|
profile.value = data
|
||||||
|
form.value.username = data.username
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveUsername() {
|
||||||
|
const username = form.value.username.trim()
|
||||||
|
message.value = ''
|
||||||
|
if (!username) {
|
||||||
|
message.value = '用户名不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.updateMe({ username })
|
||||||
|
profile.value = data
|
||||||
|
form.value.username = data.username
|
||||||
|
setSavedUsername(data.username)
|
||||||
|
message.value = '用户名已更新 ✅'
|
||||||
|
} catch (error) {
|
||||||
|
message.value = getErrorMessage(error, '更新用户名失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page wechat-page">
|
||||||
|
<div class="topbar">
|
||||||
|
<button class="back-btn" @click="router.push('/settings')">← 返回</button>
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">修改用户名</h1>
|
||||||
|
<p class="page-subtitle">只改这一项</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message" :class="['message', message.includes('✅') ? 'success' : 'error']">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<div class="cell plain-cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">当前用户名</span>
|
||||||
|
<span class="cell-desc">{{ profile.username || '加载中' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-left">
|
||||||
|
<span class="cell-title">新用户名</span>
|
||||||
|
<span class="cell-desc">请输入新的用户名</span>
|
||||||
|
</div>
|
||||||
|
<input v-model="form.username" class="cell-input" placeholder="新用户名" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary save-btn" :disabled="loading" @click="saveUsername">
|
||||||
|
{{ loading ? '更新中...' : '保存用户名' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wechat-page {
|
||||||
|
padding-top: 10px;
|
||||||
|
background: #f5f5f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plain-cell {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-left {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #ececf0;
|
||||||
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-input {
|
||||||
|
width: 160px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: #fafafa;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 110, 247, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: auto;
|
||||||
|
min-height: 34px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,6 +27,16 @@ const router = createRouter({
|
|||||||
component: () => import('../pages/MemoryCoach.vue'),
|
component: () => import('../pages/MemoryCoach.vue'),
|
||||||
},
|
},
|
||||||
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
|
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
|
||||||
|
{
|
||||||
|
path: 'settings/username',
|
||||||
|
name: 'SettingsUsername',
|
||||||
|
component: () => import('../pages/UsernameSettings.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/password',
|
||||||
|
name: 'SettingsPassword',
|
||||||
|
component: () => import('../pages/PasswordSettings.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export function getSavedUsername(): string {
|
|||||||
return localStorage.getItem(USERNAME_KEY) || ''
|
return localStorage.getItem(USERNAME_KEY) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setSavedUsername(username: string) {
|
||||||
|
localStorage.setItem(USERNAME_KEY, username)
|
||||||
|
}
|
||||||
|
|
||||||
function clearTokenStores() {
|
function clearTokenStores() {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
sessionStorage.removeItem(TOKEN_KEY)
|
sessionStorage.removeItem(TOKEN_KEY)
|
||||||
@@ -38,7 +42,7 @@ export function setAuth(token: string, remember: boolean, username?: string) {
|
|||||||
sessionStorage.setItem(TOKEN_KEY, token)
|
sessionStorage.setItem(TOKEN_KEY, token)
|
||||||
}
|
}
|
||||||
if (username) {
|
if (username) {
|
||||||
localStorage.setItem(USERNAME_KEY, username)
|
setSavedUsername(username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/composables/usegraphfilter.ts","./src/composables/usequizsession.ts","./src/composables/usequiztimer.ts","./src/router/index.ts","./src/utils/auth.ts","./src/utils/buildpracticequestion.ts","./src/utils/practicefocus.ts","./src/utils/practicegraphdisplay.ts","./src/utils/practicegraphsession.ts","./src/utils/spellquestion.ts","./src/app.vue","./src/components/memorycurvechart.vue","./src/components/quizcard.vue","./src/components/spellcard.vue","./src/components/trainfab.vue","./src/components/wordcard.vue","./src/components/wordgraphcanvas.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/graphpractice.vue","./src/pages/login.vue","./src/pages/memorycoach.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/spellquiz.vue","./src/pages/translate.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/composables/usegraphfilter.ts","./src/composables/usequizsession.ts","./src/composables/usequiztimer.ts","./src/router/index.ts","./src/utils/auth.ts","./src/utils/buildpracticequestion.ts","./src/utils/practicefocus.ts","./src/utils/practicegraphdisplay.ts","./src/utils/practicegraphsession.ts","./src/utils/spellquestion.ts","./src/app.vue","./src/components/memorycurvechart.vue","./src/components/quizcard.vue","./src/components/spellcard.vue","./src/components/trainfab.vue","./src/components/wordcard.vue","./src/components/wordgraphcanvas.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/graphpractice.vue","./src/pages/login.vue","./src/pages/memorycoach.vue","./src/pages/passwordsettings.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/spellquiz.vue","./src/pages/translate.vue","./src/pages/usernamesettings.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
||||||
Reference in New Issue
Block a user