@@ -4,6 +4,7 @@ import {
Bell ,
KeyRound ,
LogOut ,
Monitor ,
Shield ,
Upload ,
User as UserIcon ,
@@ -12,16 +13,18 @@ import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays" ;
import { CustomSelect } from "../components/custom-select" ;
import { useFileDrop } from "../components/use-file-drop" ;
import { applyTheme , normalizeAppearance , readStoredAppearance } from "../theme" ;
type SectionKey = "profile" | "security" | "notify" | "pref" | "display" ;
// 可导航(UI 可见)的 分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列 。
const SECTION_KEYS : SectionKey [ ] = [ "profile" , "security" , "notify" ] ;
// 可导航分区。pref(创作默认)仍隐藏; display(显示)开放外观切换 。
const SECTION_KEYS : SectionKey [ ] = [ "profile" , "security" , "notify" , "display" ] ;
const NAV : Array < { key : SectionKey ; label : string ; icon : ReactNode } > = [
{ key : "profile" , label : "个人信息" , icon : < UserIcon / > } ,
{ key : "security" , label : "安全" , icon : < Shield / > } ,
{ key : "notify" , label : "通知" , icon : < Bell / > } ,
{ key : "display" , label : "显示" , icon : < Monitor / > } ,
] ;
function SettingRow ( { title , hint , stack , children } : { title : string ; hint? : string ; stack? : boolean ; children : ReactNode } ) {
@@ -174,7 +177,7 @@ export function SettingsPage({
onChangePassword : ( payload : { old_password : string ; new_password : string } ) = > void | Promise < unknown > ;
onUploadAvatar : ( formData : FormData ) = > void | Promise < unknown > ;
onResetAvatar ? : ( ) = > void | Promise < unknown > ;
onNotify ? : ( text : string ) = > void ;
onNotify ? : ( type : "success" | "error" | "info" , text : string ) = > void ;
onLogout ? : ( ) = > void | Promise < void > ;
} ) {
const normalizedInitial = ( SECTION_KEYS as readonly string [ ] ) . includes ( initialSection )
@@ -199,7 +202,10 @@ export function SettingsPage({
transition : cd?.transition ? ? DEFAULT_PREFS . transition ,
twoFactor : ! ! preferences ? . two_factor_enabled ,
notify : { . . . DEFAULT_PREFS . notify , . . . ( preferences ? . notify || { } ) } ,
appearance : dp?.appearance ? ? DEFAULT_PREFS . appearance ,
// preferences 尚未加载时用本地已选外观,避免一进设置就被默认 system 盖掉
appearance : ! preferences
? readStoredAppearance ( )
: ( dp ? . appearance ? ? DEFAULT_PREFS . appearance ) ,
language : dp?.language ? ? DEFAULT_PREFS . language ,
density : dp?.density ? ? DEFAULT_PREFS . density ,
} ;
@@ -210,18 +216,19 @@ export function SettingsPage({
const [ draft , setDraft ] = useState < TrackedState > ( baselineFromProps ) ;
const [ saving , setSaving ] = useState ( false ) ;
// 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端 )
// 外观即时生效(含 后端 preferences 到达 / 放弃修改回滚 )
useEffect ( ( ) = > {
setBaseline ( baselineFromProps ) ;
setDraft ( baselineFromProps ) ;
} , [ baselineFromProps ] ) ;
applyTheme ( normalizeAppearance ( draft . appearance ) ) ;
} , [ draft . appearance ] ) ;
// 单字段 draft 更新器
const patchDraft = useCallback ( < K extends keyof TrackedState > ( key : K , value : TrackedState [ K ] ) = > {
setDraft ( ( prev ) = > ( { . . . prev , [ key ] : value } ) ) ;
} , [ ] ) ;
// 已保存基线的 ref,自动保存时避免连点用到过期 baseline
const baselineRef = useRef ( baseline ) ;
baselineRef . current = baseline ;
const draftRef = useRef ( draft ) ;
draftRef . current = draft ;
const saveSeq = useRef ( 0 ) ;
// ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / nav dirty-dot / beforeunload 全由此驱动 ) ───
// ─── 聚合脏字段集(文本框编辑中仍可能短暂脏;选项类会立即落盘 ) ───
const dirtyFields = useMemo < Array < keyof TrackedState > > ( ( ) = > {
const keys = Object . keys ( FIELD_SECTION ) as Array < keyof TrackedState > ;
return keys . filter ( ( key ) = > {
@@ -239,6 +246,97 @@ export function SettingsPage({
const dirtyCount = dirtyFields . length ;
const isDirty = dirtyCount > 0 ;
// 后端 preferences / user 到达或刷新时同步;保存中或本地有未落盘编辑时不覆盖 draft
useEffect ( ( ) = > {
if ( saving || isDirty ) return ;
setBaseline ( baselineFromProps ) ;
setDraft ( baselineFromProps ) ;
} , [ baselineFromProps , saving , isDirty ] ) ;
// 把 next 相对 baseline 的变更拆成 profile / preferences 提交;成功后升基线
const persist = useCallback ( async ( next : TrackedState ) = > {
const base = baselineRef . current ;
const profilePayload : { name? : string ; email? : string ; phone? : string } = { } ;
if ( next . name !== base . name ) profilePayload . name = next . name . trim ( ) ;
if ( next . email !== base . email ) profilePayload . email = next . email . trim ( ) ;
if ( next . phone !== base . phone ) profilePayload . phone = next . phone . trim ( ) ;
const prefPayload : Partial < UserPreference > = { } ;
const creationKeys : Array < keyof TrackedState > = [ "template" , "duration" , "subtitle" , "bgm" , "transition" ] ;
if ( creationKeys . some ( ( key ) = > next [ key ] !== base [ key ] ) ) {
prefPayload . creation_defaults = {
template : next.template ,
duration : next.duration ,
subtitle : next.subtitle ,
bgm : next.bgm ,
transition : next.transition ,
} ;
}
const displayKeys : Array < keyof TrackedState > = [ "appearance" , "language" , "density" ] ;
if ( displayKeys . some ( ( key ) = > next [ key ] !== base [ key ] ) ) {
prefPayload . display = { appearance : next.appearance , language : next.language , density : next.density } ;
}
if ( ! notifyEqual ( next . notify , base . notify ) ) prefPayload . notify = { . . . next . notify } ;
if ( next . twoFactor !== base . twoFactor ) prefPayload . two_factor_enabled = next . twoFactor ;
const hasProfile = Object . keys ( profilePayload ) . length > 0 ;
const hasPref = Object . keys ( prefPayload ) . length > 0 ;
if ( ! hasProfile && ! hasPref ) {
setBaseline ( next ) ;
return ;
}
const seq = ++ saveSeq . current ;
setSaving ( true ) ;
try {
const tasks : Array < Promise < unknown > > = [ ] ;
if ( hasProfile ) tasks . push ( Promise . resolve ( onSaveProfile ( profilePayload ) ) ) ;
if ( hasPref && onSavePreferences ) {
tasks . push (
Promise . resolve ( onSavePreferences ( prefPayload ) ) . then ( ( result ) = > {
if ( result == null ) throw new Error ( "偏好设置保存失败,请重试" ) ;
return result ;
} ) ,
) ;
}
await Promise . all ( tasks ) ;
if ( seq !== saveSeq . current ) return ;
baselineRef . current = next ;
setBaseline ( next ) ;
} catch ( error ) {
if ( seq !== saveSeq . current ) return ;
// 失败不推进 baseline,回滚 draft 并恢复主题
const restored = baselineRef . current ;
draftRef . current = restored ;
setDraft ( restored ) ;
applyTheme ( normalizeAppearance ( restored . appearance ) ) ;
onNotify ? . (
"error" ,
error instanceof Error ? error . message : "设置保存失败,请重试" ,
) ;
} finally {
if ( seq === saveSeq . current ) setSaving ( false ) ;
}
} , [ onSaveProfile , onSavePreferences , onNotify ] ) ;
// 选项类:改完立即存
const commitField = useCallback ( < K extends keyof TrackedState > ( key : K , value : TrackedState [ K ] ) = > {
const next = { . . . draftRef . current , [ key ] : value } ;
draftRef . current = next ;
setDraft ( next ) ;
if ( key === "appearance" ) applyTheme ( normalizeAppearance ( value as string ) ) ;
void persist ( next ) ;
} , [ persist ] ) ;
// 文本框:只改 draft,失焦再存
const patchDraft = useCallback ( < K extends keyof TrackedState > ( key : K , value : TrackedState [ K ] ) = > {
setDraft ( ( prev ) = > ( { . . . prev , [ key ] : value } ) ) ;
} , [ ] ) ;
const saveDraftNow = useCallback ( ( ) = > {
void persist ( draftRef . current ) ;
} , [ persist ] ) ;
// 个人信息 · 受控输入(draft 派生)
const name = draft . name ;
const email = draft . email ;
@@ -271,55 +369,6 @@ export function SettingsPage({
[ notifyOnCount , sessions . length ] ,
) ;
// ─── 取消:draft 回退到 baseline(放弃所有未保存改动) ───
function discardChanges() {
if ( ! isDirty ) return ;
setDraft ( baseline ) ;
onNotify ? . ( "已放弃未保存的改动" ) ;
}
// ─── 统一保存:把所有脏字段拆成 profile / preferences 两个 payload 提交,成功后把 draft 升为新基线 ───
async function handleSaveAll() {
if ( ! isDirty || saving ) return ;
setSaving ( true ) ;
try {
const profilePayload : { name? : string ; email? : string ; phone? : string } = { } ;
if ( draft . name !== baseline . name ) profilePayload . name = draft . name . trim ( ) ;
if ( draft . email !== baseline . email ) profilePayload . email = draft . email . trim ( ) ;
if ( draft . phone !== baseline . phone ) profilePayload . phone = draft . phone . trim ( ) ;
const prefPayload : Partial < UserPreference > = { } ;
const creationKeys : Array < keyof TrackedState > = [ "template" , "duration" , "subtitle" , "bgm" , "transition" ] ;
if ( creationKeys . some ( ( key ) = > draft [ key ] !== baseline [ key ] ) ) {
prefPayload . creation_defaults = {
template : draft.template ,
duration : draft.duration ,
subtitle : draft.subtitle ,
bgm : draft.bgm ,
transition : draft.transition ,
} ;
}
const displayKeys : Array < keyof TrackedState > = [ "appearance" , "language" , "density" ] ;
if ( displayKeys . some ( ( key ) = > draft [ key ] !== baseline [ key ] ) ) {
prefPayload . display = { appearance : draft.appearance , language : draft.language , density : draft.density } ;
}
if ( ! notifyEqual ( draft . notify , baseline . notify ) ) prefPayload . notify = { . . . draft . notify } ;
if ( draft . twoFactor !== baseline . twoFactor ) prefPayload . two_factor_enabled = draft . twoFactor ;
const tasks : Array < Promise < unknown > > = [ ] ;
if ( Object . keys ( profilePayload ) . length > 0 ) tasks . push ( Promise . resolve ( onSaveProfile ( profilePayload ) ) ) ;
if ( Object . keys ( prefPayload ) . length > 0 && onSavePreferences ) tasks . push ( Promise . resolve ( onSavePreferences ( prefPayload ) ) ) ;
await Promise . all ( tasks ) ;
// 提交成功 → draft 升为新基线,脏集清空
const savedCount = dirtyCount ;
setBaseline ( draft ) ;
onNotify ? . ( ` ${ savedCount } 项已保存 ` ) ;
} finally {
setSaving ( false ) ;
}
}
function openPasswordModal() {
setOldPassword ( "" ) ;
setNewPassword ( "" ) ;
@@ -381,32 +430,13 @@ export function SettingsPage({
return ( ) = > URL . revokeObjectURL ( avatarPreview ) ;
} , [ avatarPreview ] ) ;
// ─── 离开页面前提醒:有未保存改动时弹浏览器原生确认 ───
useEffect ( ( ) = > {
if ( ! isDirty ) return ;
const handler = ( event : BeforeUnloadEvent ) = > {
event . preventDefault ( ) ;
event . returnValue = "" ;
} ;
window . addEventListener ( "beforeunload" , handler ) ;
return ( ) = > window . removeEventListener ( "beforeunload" , handler ) ;
} , [ isDirty ] ) ;
return (
< section className = "settings-page" >
< div className = "settings-inner" >
< div className = "page-head" >
< div >
< h1 > 设 置 < / h1 >
< div className = "sub" > 个 人 信 息 、 偏 好 、 通 知 与 安 全 < / div >
< / div >
< div className = "actions" >
< button className = "btn" type = "button" onClick = { discardChanges } disabled = { ! isDirty || saving } > 取 消 < / button >
< button className = "btn btn-primary" type = "button" onClick = { handleSaveAll } disabled = { ! isDirty || saving } >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M4 12l5 5L20 6" / > < / svg >
保 存 所 有 更 改
{ isDirty ? < span className = "save-count" > · { dirtyCount } 项 < / span > : null }
< / button >
< div className = "sub" > 个 人 信 息 、 偏 好 、 通 知 与 安 全 · 修 改 后 自 动 保 存 < / div >
< / div >
< / div >
@@ -449,7 +479,7 @@ export function SettingsPage({
< / div >
< / SettingRow >
< SettingRow title = "显示名称" hint = "用于团队和项目协作中展示" >
< input className = "st-input" value = { name } onChange = { ( event ) = > patchDraft ( "name" , event . target . value ) } / >
< input className = "st-input" value = { name } onChange = { ( event ) = > patchDraft ( "name" , event . target . value ) } onBlur = { saveDraftNow } / >
< / SettingRow >
< SettingRow title = "登录邮箱" hint = "仅做记录用,不用于登录" >
< input
@@ -458,13 +488,15 @@ export function SettingsPage({
value = { email }
placeholder = "邮箱验证未启用"
onChange = { ( event ) = > patchDraft ( "email" , event . target . value ) }
onBlur = { saveDraftNow }
/ >
< / SettingRow >
< SettingRow title = "手机号" hint = "保存所有更改时一并提交 " >
< SettingRow title = "手机号" hint = "修改后自动 保存" >
< input
className = "st-input"
value = { phone }
onChange = { ( event ) = > patchDraft ( "phone" , event . target . value ) }
onBlur = { saveDraftNow }
placeholder = "138****8000"
/ >
< / SettingRow >
@@ -499,7 +531,7 @@ export function SettingsPage({
< / div >
< / SettingRow >
< SettingRow title = "两步验证" hint = "推荐开启短信 + Authenticator" >
< Switch checked = { draft . twoFactor } onChange = { ( v ) = > patchDraft ( "twoFactor" , v ) } / >
< Switch checked = { draft . twoFactor } onChange = { ( v ) = > commitField ( "twoFactor" , v ) } / >
< / SettingRow >
< SettingRow stack title = "在用设备" hint = "真实登录会话 · 每次登录记录设备 UA / IP" >
< div className = "device-list" >
@@ -541,7 +573,7 @@ export function SettingsPage({
< section className = "pane" aria-label = "通知" >
{ NOTIFY_ROWS . map ( ( row ) = > (
< SettingRow key = { row . key } title = { row . title } hint = { row . sub ? . replace ( /^\/\/\s*/ , "" ) } >
< Switch checked = { ! ! draft . notify [ row . key ] } onChange = { ( next ) = > patchDraft ( "notify" , { . . . draft . notify , [ row . key ] : next } ) } / >
< Switch checked = { ! ! draft . notify [ row . key ] } onChange = { ( next ) = > commitField ( "notify" , { . . . draft . notify , [ row . key ] : next } ) } / >
< / SettingRow >
) ) }
< div className = "pane-desc" style = { { margin : "18px 0 4px" } } > 消 息 中 心 分 类 < / div >
@@ -549,7 +581,7 @@ export function SettingsPage({
< SettingRow key = { row . key } title = { row . title } hint = { row . hint } >
< Switch
checked = { ! draft . notify [ ` mute- ${ row . key } ` ] }
onChange = { ( show ) = > patchDraft ( "notify" , { . . . draft . notify , [ ` mute- ${ row . key } ` ] : ! show } ) }
onChange = { ( show ) = > commitField ( "notify" , { . . . draft . notify , [ ` mute- ${ row . key } ` ] : ! show } ) }
/ >
< / SettingRow >
) ) }
@@ -571,7 +603,7 @@ export function SettingsPage({
className = { ` pref-choice ${ draft . template === choice . v ? "selected" : "" } ` }
role = "button"
tabIndex = { 0 }
onClick = { ( ) = > patchDraft ( "template" , choice . v ) }
onClick = { ( ) = > commitField ( "template" , choice . v ) }
>
< div className = "t" > { choice . t } < / div >
< div className = "d" > { choice . d } < / div >
@@ -590,7 +622,7 @@ export function SettingsPage({
className = { ` dur-chip ${ draft . duration === d ? "selected" : "" } ` }
role = "button"
tabIndex = { 0 }
onClick = { ( ) = > patchDraft ( "duration" , d ) }
onClick = { ( ) = > commitField ( "duration" , d ) }
>
{ d } s
< / span >
@@ -609,7 +641,7 @@ export function SettingsPage({
className = { ` pref-choice ${ draft . subtitle === choice . v ? "selected" : "" } ` }
role = "button"
tabIndex = { 0 }
onClick = { ( ) = > patchDraft ( "subtitle" , choice . v ) }
onClick = { ( ) = > commitField ( "subtitle" , choice . v ) }
>
< div className = "t" > { choice . t } < / div >
< div className = "d" > { choice . d } < / div >
@@ -621,7 +653,7 @@ export function SettingsPage({
< div className = "form-row" >
< div className = "lbl" > 默 认 BGM 库 < / div >
< div className = "val" >
< CustomSelect fill value = { draft . bgm } onChange = { ( next ) = > patchDraft ( "bgm" , next ) } options = { [
< CustomSelect fill value = { draft . bgm } onChange = { ( next ) = > commitField ( "bgm" , next ) } options = { [
{ value : "kapian" , label : "抖音 Top10 卡点曲库" } ,
{ value : "emotion" , label : "情绪向 · 治愈/悬念" } ,
{ value : "urban" , label : "都市电子 · 通勤场景" } ,
@@ -632,7 +664,7 @@ export function SettingsPage({
< div className = "form-row" >
< div className = "lbl" > 默 认 转 场 < / div >
< div className = "val" >
< CustomSelect fill value = { draft . transition } onChange = { ( next ) = > patchDraft ( "transition" , next ) } options = { [
< CustomSelect fill value = { draft . transition } onChange = { ( next ) = > commitField ( "transition" , next ) } options = { [
{ value : "none" , label : "无转场" } ,
{ value : "fade" , label : "淡入淡出 · 0.3s" } ,
{ value : "slide" , label : "滑动 · 0.3s" } ,
@@ -651,6 +683,8 @@ export function SettingsPage({
< / section >
) }
{ section === "display" && (
< section className = "pane" aria-label = "显示" >
< h3 > 显 示 < / h3 >
@@ -659,17 +693,17 @@ export function SettingsPage({
< div className = "form-row" >
< div className = "lbl" > 外 观 < / div >
< div className = "val" >
< CustomSelect fill value = { draft . appearance } onChange = { ( next ) = > patchDraft ( "appearance" , next ) } options = { [
< CustomSelect fill value = { draft . appearance } onChange = { ( next ) = > commitField ( "appearance" , next ) } options = { [
{ value : "system" , label : "跟随系统" } ,
{ value : "light" , label : "浅色" } ,
{ value : "dark" , label : "深色(V2)" , disabled : true } ,
{ value : "dark" , label : "深色" } ,
] } / >
< / div >
< / div >
< div className = "form-row" >
< div className = "lbl" > 语 言 < / div >
< div className = "val" >
< CustomSelect fill value = { draft . language } onChange = { ( next ) = > patchDraft ( "language" , next ) } options = { [
< CustomSelect fill value = { draft . language } onChange = { ( next ) = > commitField ( "language" , next ) } options = { [
{ value : "zh" , label : "简体中文" } ,
{ value : "en" , label : "English(V2)" , disabled : true } ,
] } / >
@@ -678,7 +712,7 @@ export function SettingsPage({
< div className = "form-row" >
< div className = "lbl" > 表 格 密 度 < / div >
< div className = "val" >
< CustomSelect fill value = { draft . density } onChange = { ( next ) = > patchDraft ( "density" , next ) } options = { [
< CustomSelect fill value = { draft . density } onChange = { ( next ) = > commitField ( "density" , next ) } options = { [
{ value : "compact" , label : "紧凑" } ,
{ value : "standard" , label : "标准" } ,
{ value : "loose" , label : "宽松" } ,
@@ -813,7 +847,7 @@ export function SettingsPage({
< div className = "li" > 项 目 、 资 产 、 团 队 成 员 与 余 额 数 据 都 会 保 留 < / div >
< div className = "li" > 仅 影 响 当 前 浏 览 器 会 话 , 不 会 下 线 其 他 设 备 < / div >
< / div >
{ isDirty ? < div className = "logout-unsaved-note" > 当 前 有 { dirtyCount } 项 未 保 存 的 设 置 变 更 , 退 出 后 这 些 变 更 不 会 保 存 。 < / div > : null }
{ isDirty ? < div className = "logout-unsaved-note" > 还 有 { dirtyCount } 项 未 落 盘 的 修 改 ( 通 常 是 正 在 编 辑 的 文 本 ) , 退 出 后 不 会 保 存 。 < / div > : null }
< / TeamModal >
< / section >
) ;