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 database import get_db
|
||||
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"])
|
||||
|
||||
@@ -53,3 +53,38 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_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
|
||||
|
||||
|
||||
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
|
||||
class TranslateRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
@@ -172,6 +172,18 @@ export interface Settings {
|
||||
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 {
|
||||
role: string
|
||||
key: string
|
||||
@@ -242,7 +254,8 @@ export const api = {
|
||||
request.post('/auth/register', { username, password }),
|
||||
login: (username: string, password: string, remember = true) =>
|
||||
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 }),
|
||||
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
|
||||
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">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, type Settings } from '../api/request'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, type Settings, type UserProfile } from '../api/request'
|
||||
import { clearAuth } from '../utils/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const settings = ref<Settings>({
|
||||
daily_target: 20,
|
||||
master_required_count: 3,
|
||||
weak_wrong_threshold: 3,
|
||||
})
|
||||
const message = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.getSettings()
|
||||
settings.value = data
|
||||
const profile = ref<UserProfile>({
|
||||
id: 0,
|
||||
username: '',
|
||||
created_at: '',
|
||||
})
|
||||
|
||||
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
|
||||
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 {
|
||||
const { data } = await api.updateSettings(settings.value)
|
||||
settings.value = data
|
||||
message.value = '设置已保存 ✅'
|
||||
} catch {
|
||||
message.value = '保存失败'
|
||||
settingsMessage.value = '学习设置已保存 ✅'
|
||||
} catch (error) {
|
||||
settingsMessage.value = getErrorMessage(error, '保存学习设置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -37,35 +61,258 @@ function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">学习设置</h1>
|
||||
<div v-if="message" class="message success">{{ message }}</div>
|
||||
<div class="card">
|
||||
<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 class="page wechat-page">
|
||||
<div class="header">
|
||||
<h1 class="page-title">设置</h1>
|
||||
<p class="page-subtitle">账号与学习参数</p>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
display: block;
|
||||
.wechat-page {
|
||||
padding-top: 10px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
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>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
{ 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) || ''
|
||||
}
|
||||
|
||||
export function setSavedUsername(username: string) {
|
||||
localStorage.setItem(USERNAME_KEY, username)
|
||||
}
|
||||
|
||||
function clearTokenStores() {
|
||||
localStorage.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)
|
||||
}
|
||||
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