feat: 取消显示名称,统一用户名,大小写不敏感去重,一次性改名
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Successful in 52s

- 注册: 去掉display_name字段,用户名大小写不敏感去重
- 登录: 大小写不敏感匹配
- 用户菜单: 显示用户名+改名按钮(只能改一次)
- /api/me/username: 一次性改名API
- 启动时: sync display_name=username, 发通知告知用户
- 前端: 所有display_name显示改为username

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 22:20:23 +00:00
parent ca3f409827
commit fdc7b20929
6 changed files with 89 additions and 26 deletions

View File

@@ -165,6 +165,8 @@ def init_db():
c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'") c.execute("ALTER TABLE users ADD COLUMN brand_align TEXT DEFAULT 'center'")
if "role_changed_at" not in user_cols: if "role_changed_at" not in user_cols:
c.execute("ALTER TABLE users ADD COLUMN role_changed_at TEXT") c.execute("ALTER TABLE users ADD COLUMN role_changed_at TEXT")
if "username_changed" not in user_cols:
c.execute("ALTER TABLE users ADD COLUMN username_changed INTEGER DEFAULT 0")
# Migration: add tags to user_diary # Migration: add tags to user_diary
diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()] diary_cols = [row[1] for row in c.execute("PRAGMA table_info(user_diary)").fetchall()]

View File

@@ -132,7 +132,7 @@ def get_version():
@app.get("/api/me") @app.get("/api/me")
def get_me(user=Depends(get_current_user)): def get_me(user=Depends(get_current_user)):
return {"username": user["username"], "role": user["role"], "display_name": user.get("display_name", ""), "id": user.get("id"), "has_password": bool(user.get("password")), "business_verified": bool(user.get("business_verified"))} return {"username": user["username"], "role": user["role"], "display_name": user["username"], "id": user.get("id"), "has_password": bool(user.get("password")), "business_verified": bool(user.get("business_verified")), "username_changed": bool(user.get("username_changed"))}
# ── Bug Reports ───────────────────────────────────────── # ── Bug Reports ─────────────────────────────────────────
@@ -350,20 +350,24 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
def register(body: dict): def register(body: dict):
username = body.get("username", "").strip() username = body.get("username", "").strip()
password = body.get("password", "").strip() password = body.get("password", "").strip()
display_name = body.get("display_name", "").strip()
if not username or len(username) < 2: if not username or len(username) < 2:
raise HTTPException(400, "用户名至少2个字符") raise HTTPException(400, "用户名至少2个字符")
if not password or len(password) < 4: if not password or len(password) < 4:
raise HTTPException(400, "密码至少4位") raise HTTPException(400, "密码至少4位")
token = _secrets.token_hex(24) # Case-insensitive uniqueness check
conn = get_db() conn = get_db()
existing = conn.execute("SELECT id FROM users WHERE LOWER(username) = LOWER(?)", (username,)).fetchone()
if existing:
conn.close()
raise HTTPException(400, "用户名已被占用")
token = _secrets.token_hex(24)
try: try:
conn.execute( conn.execute(
"INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)", "INSERT INTO users (username, token, role, display_name, password) VALUES (?, ?, ?, ?, ?)",
(username, token, "viewer", display_name or username, hash_password(password)) (username, token, "viewer", username, hash_password(password))
) )
uid = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone() uid = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
log_audit(conn, uid["id"] if uid else None, "register", "user", username, display_name or username, None) log_audit(conn, uid["id"] if uid else None, "register", "user", username, username, None)
conn.commit() conn.commit()
except Exception: except Exception:
conn.close() conn.close()
@@ -380,7 +384,7 @@ def login(body: dict):
if not username or not password: if not username or not password:
raise HTTPException(400, "请输入用户名和密码") raise HTTPException(400, "请输入用户名和密码")
conn = get_db() conn = get_db()
user = conn.execute("SELECT id, token, password, display_name, role FROM users WHERE username = ?", (username,)).fetchone() user = conn.execute("SELECT id, token, password, display_name, role, username FROM users WHERE LOWER(username) = LOWER(?)", (username,)).fetchone()
if not user: if not user:
conn.close() conn.close()
raise HTTPException(401, "用户名不存在") raise HTTPException(401, "用户名不存在")
@@ -452,6 +456,30 @@ def set_password(body: dict, user=Depends(get_current_user)):
return {"ok": True} return {"ok": True}
@app.put("/api/me/username")
def change_username(body: dict, user=Depends(get_current_user)):
if not user["id"]:
raise HTTPException(403, "请先登录")
conn = get_db()
u = conn.execute("SELECT username_changed FROM users WHERE id = ?", (user["id"],)).fetchone()
if u and u["username_changed"]:
conn.close()
raise HTTPException(400, "用户名只能修改一次")
new_name = body.get("username", "").strip()
if not new_name or len(new_name) < 2:
conn.close()
raise HTTPException(400, "用户名至少2个字符")
existing = conn.execute("SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?", (new_name, user["id"])).fetchone()
if existing:
conn.close()
raise HTTPException(400, "用户名已被占用")
conn.execute("UPDATE users SET username = ?, display_name = ?, username_changed = 1 WHERE id = ?",
(new_name, new_name, user["id"]))
conn.commit()
conn.close()
return {"ok": True, "username": new_name}
# ── Business Verification ────────────────────────────── # ── Business Verification ──────────────────────────────
@app.post("/api/business-apply", status_code=201) @app.post("/api/business-apply", status_code=201)
def business_apply(body: dict, user=Depends(get_current_user)): def business_apply(body: dict, user=Depends(get_current_user)):
@@ -2058,6 +2086,26 @@ def startup():
data = json.load(f) data = json.load(f)
seed_defaults(data["oils_meta"], data["recipes"]) seed_defaults(data["oils_meta"], data["recipes"])
# One-time migration: sync display_name = username, notify about username change
conn = get_db()
needs_sync = conn.execute("SELECT id, username, display_name FROM users WHERE display_name != username AND display_name IS NOT NULL").fetchall()
if needs_sync:
for row in needs_sync:
conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (row["username"], row["id"]))
# Send notification once (check if already sent)
already_notified = conn.execute("SELECT id FROM notifications WHERE title = '📢 用户名变更通知'").fetchone()
if not already_notified:
all_users = conn.execute("SELECT id FROM users").fetchall()
for u in all_users:
conn.execute(
"INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)",
("viewer", "📢 用户名变更通知",
"系统已取消显示名称,统一使用用户名。你可以在个人设置中修改一次用户名。", u["id"])
)
conn.commit()
print(f"[INIT] Synced display_name for {len(needs_sync)} users")
conn.close()
# Auto-fill missing en_name for existing recipes # Auto-fill missing en_name for existing recipes
conn = get_db() conn = get_db()
missing = conn.execute("SELECT id, name FROM recipes WHERE en_name IS NULL OR en_name = ''").fetchall() missing = conn.execute("SELECT id, name FROM recipes WHERE en_name IS NULL OR en_name = ''").fetchall()

View File

@@ -15,7 +15,7 @@
<div class="header-right" @click="toggleUserMenu"> <div class="header-right" @click="toggleUserMenu">
<template v-if="auth.isLoggedIn"> <template v-if="auth.isLoggedIn">
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span> <span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
<span class="user-name">{{ auth.user.display_name || auth.user.username }} </span> <span class="user-name">{{ auth.user.username }} </span>
<span v-if="unreadNotifCount > 0" class="notif-badge">{{ unreadNotifCount }}</span> <span v-if="unreadNotifCount > 0" class="notif-badge">{{ unreadNotifCount }}</span>
</template> </template>
<template v-else> <template v-else>

View File

@@ -37,14 +37,6 @@
class="login-input" class="login-input"
@keydown.enter="submit" @keydown.enter="submit"
/> />
<input
v-if="mode === 'register'"
v-model="displayName"
type="text"
placeholder="显示名称(可选)"
class="login-input"
@keydown.enter="submit"
/>
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div> <div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
@@ -80,7 +72,6 @@ const mode = ref('login')
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const displayName = ref('')
const errorMsg = ref('') const errorMsg = ref('')
const loading = ref(false) const loading = ref(false)
const showFeedback = ref(false) const showFeedback = ref(false)
@@ -109,11 +100,7 @@ async function submit() {
await auth.login(username.value.trim(), password.value) await auth.login(username.value.trim(), password.value)
ui.showToast('登录成功') ui.showToast('登录成功')
} else { } else {
await auth.register( await auth.register(username.value.trim(), password.value)
username.value.trim(),
password.value,
displayName.value.trim() || username.value.trim()
)
ui.showToast('注册成功') ui.showToast('注册成功')
} }
emit('close') emit('close')

View File

@@ -1,7 +1,10 @@
<template> <template>
<div class="usermenu-overlay" @mousedown.self="$emit('close')"> <div class="usermenu-overlay" @mousedown.self="$emit('close')">
<div class="usermenu-card"> <div class="usermenu-card">
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div> <div class="usermenu-name">
{{ auth.user.username }}
<button v-if="!auth.user.username_changed" class="rename-btn" @click="changeUsername"> 改名</button>
</div>
<div class="usermenu-actions"> <div class="usermenu-actions">
<button class="usermenu-btn" @click="goMyDiary"> <button class="usermenu-btn" @click="goMyDiary">
@@ -131,6 +134,27 @@ function isSearchMissing(n) {
return n.title && n.title.includes('用户需求') return n.title && n.title.includes('用户需求')
} }
async function changeUsername() {
const { showPrompt } = await import('../composables/useDialog')
const newName = await showPrompt('输入新用户名(只能修改一次):', auth.user.username)
if (!newName || !newName.trim() || newName.trim() === auth.user.username) return
try {
const res = await api('/api/me/username', {
method: 'PUT',
body: JSON.stringify({ username: newName.trim() }),
})
if (res.ok) {
await auth.loadMe()
ui.showToast('用户名已修改')
} else {
const err = await res.json().catch(() => ({}))
ui.showToast(err.detail || '修改失败')
}
} catch {
ui.showToast('修改失败')
}
}
function isPlanRequest(n) { function isPlanRequest(n) {
return n.title && (n.title.includes('方案请求') || n.title.includes('方案需求')) return n.title && (n.title.includes('方案请求') || n.title.includes('方案需求'))
} }
@@ -227,7 +251,9 @@ onMounted(loadNotifications)
z-index: 4001; z-index: 4001;
} }
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 14px; } .usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
.rename-btn { background: none; border: none; font-size: 12px; color: #b0aab5; cursor: pointer; padding: 0; }
.rename-btn:hover { color: #4a9d7e; }
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; } .usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
.usermenu-btn { .usermenu-btn {

View File

@@ -40,9 +40,10 @@ export const useAuthStore = defineStore('auth', () => {
id: data.id, id: data.id,
role: data.role, role: data.role,
username: data.username, username: data.username,
display_name: data.display_name, display_name: data.username,
has_password: data.has_password ?? false, has_password: data.has_password ?? false,
business_verified: data.business_verified ?? false, business_verified: data.business_verified ?? false,
username_changed: data.username_changed ?? false,
} }
} catch { } catch {
logout() logout()
@@ -56,11 +57,10 @@ export const useAuthStore = defineStore('auth', () => {
await loadMe() await loadMe()
} }
async function register(username, password, displayName) { async function register(username, password) {
const data = await api.post('/api/register', { const data = await api.post('/api/register', {
username, username,
password, password,
display_name: displayName,
}) })
token.value = data.token token.value = data.token
localStorage.setItem('oil_auth_token', data.token) localStorage.setItem('oil_auth_token', data.token)