Fix account service startup race

This commit is contained in:
zhoujun0601
2026-08-13 07:33:04 -04:00
parent 4c05dde0f1
commit d6edde6534
5 changed files with 259 additions and 29 deletions
+14 -8
View File
@@ -21,7 +21,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
connected := false connected := false
a.mu.RLock() a.mu.RLock()
for _, rt := range a.runtimes { for _, rt := range a.runtimes {
if rt.client.Connected() { if rt.Ready() {
connected = true connected = true
break break
} }
@@ -178,7 +178,7 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
a.mu.Lock() a.mu.Lock()
delete(a.runtimes, id) delete(a.runtimes, id)
a.mu.Unlock() a.mu.Unlock()
_ = rt.client.Close() rt.stop()
e = a.store.DeleteAccount(id) e = a.store.DeleteAccount(id)
if e == nil { if e == nil {
dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10)) dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10))
@@ -194,7 +194,10 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
a.deviceLogin(w, r, id) a.deviceLogin(w, r, id)
case action == "logout" && r.Method == "POST": case action == "logout" && r.Method == "POST":
var out any var out any
e = rt.client.Call(r.Context(), "account/logout", map[string]any{}, &out) e = rt.ensureReady(r.Context())
if e == nil {
e = rt.client.Call(r.Context(), "account/logout", map[string]any{}, &out)
}
if e == nil { if e == nil {
_ = a.store.UpdateAccount(id, nil, nil, false) _ = a.store.UpdateAccount(id, nil, nil, false)
jsonOut(w, 200, map[string]bool{"ok": true}) jsonOut(w, 200, map[string]bool{"ok": true})
@@ -326,11 +329,14 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) {
func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request, id int64) { func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request, id int64) {
var out map[string]any var out map[string]any
rt := a.runtime(id) rt := a.runtime(id)
if !rt.client.Connected() { if rt == nil {
jsonOut(w, 503, map[string]string{"error": "该账号服务正在启动,请稍后重试"}) jsonOut(w, 404, map[string]string{"error": "账号不存在"})
return return
} }
e := rt.client.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out) e := rt.ensureReady(r.Context())
if e == nil {
e = rt.client.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out)
}
if e != nil { if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()}) jsonOut(w, 502, map[string]string{"error": e.Error()})
return return
@@ -345,8 +351,8 @@ func (a *App) syncAccount(ctx context.Context, id int64) error {
} }
rt.syncing.Lock() rt.syncing.Lock()
defer rt.syncing.Unlock() defer rt.syncing.Unlock()
if !rt.client.Connected() { if err := rt.ensureReady(ctx); err != nil {
return fmt.Errorf("app-server 未连接") return err
} }
ctx, c := context.WithTimeout(ctx, 20*time.Second) ctx, c := context.WithTimeout(ctx, 20*time.Second)
defer c() defer c()
+83 -19
View File
@@ -37,9 +37,22 @@ type App struct {
loginAttempts sync.Map loginAttempts sync.Map
} }
type accountRuntime struct { type accountRuntime struct {
client *codex.Client client codexClient
dash Dashboard processCtx context.Context
syncing sync.Mutex dash Dashboard
syncing sync.Mutex
lifecycle sync.Mutex
stateMu sync.RWMutex
ready bool
stopped bool
}
type codexClient interface {
Start(context.Context) error
Initialize(context.Context) error
Call(context.Context, string, any, any) error
Close() error
Connected() bool
} }
func New() (*App, error) { func New() (*App, error) {
@@ -88,7 +101,7 @@ func (a *App) Close() {
_ = a.server.Shutdown(ctx) _ = a.server.Shutdown(ctx)
a.mu.RLock() a.mu.RLock()
for _, rt := range a.runtimes { for _, rt := range a.runtimes {
_ = rt.client.Close() rt.stop()
} }
a.mu.RUnlock() a.mu.RUnlock()
_ = a.store.DB.Close() _ = a.store.DB.Close()
@@ -108,21 +121,13 @@ func (a *App) keepCodex() {
a.mu.RUnlock() a.mu.RUnlock()
for _, id := range ids { for _, id := range ids {
rt := a.runtime(id) rt := a.runtime(id)
if rt == nil || rt.client.Connected() { if rt == nil || rt.Ready() {
continue continue
} }
if e := rt.client.Start(a.ctx); e == nil { if e := rt.ensureReady(a.ctx); e == nil {
ctx, c := context.WithTimeout(a.ctx, 20*time.Second) _ = a.syncAccount(context.Background(), id)
e = rt.client.Initialize(ctx) } else if !errors.Is(e, errRuntimeStopped) {
c() log.Printf("app-server initialize: %v", e)
if e == nil {
_ = a.syncAccount(context.Background(), id)
} else {
log.Printf("app-server initialize: %v", e)
// A live process is not necessarily an initialized process. Tear it
// down so the next iteration starts a fresh protocol session.
_ = rt.client.Close()
}
} }
} }
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -142,11 +147,70 @@ func (a *App) addRuntime(id int64) {
// existing login, and fresh installs use the same deterministic path. // existing login, and fresh installs use the same deterministic path.
dir = filepath.Join(a.dataDir, "codex") dir = filepath.Join(a.dataDir, "codex")
} }
rt := &accountRuntime{client: codex.New(dir, a.onCodexNotification(id)), dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}} rt := &accountRuntime{client: codex.New(dir, a.onCodexNotification(id)), processCtx: a.ctx, dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}}
a.mu.Lock() a.mu.Lock()
a.runtimes[id] = rt a.runtimes[id] = rt
a.mu.Unlock() a.mu.Unlock()
} }
var errRuntimeStopped = errors.New("账号服务已停止")
func (rt *accountRuntime) ensureReady(ctx context.Context) error {
rt.lifecycle.Lock()
defer rt.lifecycle.Unlock()
rt.stateMu.RLock()
stopped, ready := rt.stopped, rt.ready
rt.stateMu.RUnlock()
if stopped {
return errRuntimeStopped
}
if ready && rt.client.Connected() {
return nil
}
// Connected only means the child process is alive. If a previous attempt
// did not finish the protocol handshake, discard it before trying again.
rt.stateMu.Lock()
rt.ready = false
rt.stateMu.Unlock()
if rt.client.Connected() {
_ = rt.client.Close()
}
processCtx := rt.processCtx
if processCtx == nil {
processCtx = ctx
}
if err := rt.client.Start(processCtx); err != nil {
return err
}
initCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
err := rt.client.Initialize(initCtx)
cancel()
if err != nil {
_ = rt.client.Close()
return err
}
rt.stateMu.Lock()
rt.ready = true
rt.stateMu.Unlock()
return nil
}
func (rt *accountRuntime) Ready() bool {
rt.stateMu.RLock()
ready := !rt.stopped && rt.ready
rt.stateMu.RUnlock()
return ready && rt.client.Connected()
}
func (rt *accountRuntime) stop() {
rt.lifecycle.Lock()
defer rt.lifecycle.Unlock()
rt.stateMu.Lock()
rt.stopped = true
rt.ready = false
rt.stateMu.Unlock()
_ = rt.client.Close()
}
func (a *App) runtime(id int64) *accountRuntime { func (a *App) runtime(id int64) *accountRuntime {
a.mu.RLock() a.mu.RLock()
defer a.mu.RUnlock() defer a.mu.RUnlock()
@@ -192,7 +256,7 @@ func (a *App) routes() http.Handler {
connected := false connected := false
a.mu.RLock() a.mu.RLock()
for _, rt := range a.runtimes { for _, rt := range a.runtimes {
if rt.client.Connected() { if rt.Ready() {
connected = true connected = true
break break
} }
+160
View File
@@ -0,0 +1,160 @@
package app
import (
"context"
"errors"
"net/http/httptest"
"sync"
"testing"
)
type fakeCodexClient struct {
mu sync.Mutex
connected bool
starts int
initializes int
closes int
calls int
initErrors []error
initStarted chan struct{}
initRelease chan struct{}
}
func (f *fakeCodexClient) Start(context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
f.starts++
f.connected = true
return nil
}
func (f *fakeCodexClient) Initialize(context.Context) error {
f.mu.Lock()
f.initializes++
var err error
if len(f.initErrors) > 0 {
err, f.initErrors = f.initErrors[0], f.initErrors[1:]
}
started, release := f.initStarted, f.initRelease
f.mu.Unlock()
if started != nil {
select {
case started <- struct{}{}:
default:
}
}
if release != nil {
<-release
}
return err
}
func (f *fakeCodexClient) Call(_ context.Context, method string, _ any, out any) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
if method == "account/login/start" {
result := out.(*map[string]any)
*result = map[string]any{"verificationUrl": "https://example.test/device", "userCode": "ABCD-EFGH"}
}
return nil
}
func (f *fakeCodexClient) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
f.closes++
f.connected = false
return nil
}
func (f *fakeCodexClient) Connected() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.connected
}
func (f *fakeCodexClient) counts() (starts, initializes, closes, calls int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.starts, f.initializes, f.closes, f.calls
}
func TestEnsureReadySerializesColdStart(t *testing.T) {
client := &fakeCodexClient{}
rt := &accountRuntime{client: client}
var wg sync.WaitGroup
errs := make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
errs <- rt.ensureReady(context.Background())
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
starts, initializes, _, _ := client.counts()
if starts != 1 || initializes != 1 {
t.Fatalf("cold starts = %d, initializes = %d; want 1 each", starts, initializes)
}
}
func TestEnsureReadyRetriesAfterInitializeFailure(t *testing.T) {
client := &fakeCodexClient{initErrors: []error{errors.New("handshake failed")}}
rt := &accountRuntime{client: client}
if err := rt.ensureReady(context.Background()); err == nil {
t.Fatal("first initialization unexpectedly succeeded")
}
if err := rt.ensureReady(context.Background()); err != nil {
t.Fatalf("retry failed: %v", err)
}
starts, initializes, closes, _ := client.counts()
if starts != 2 || initializes != 2 || closes != 1 {
t.Fatalf("starts = %d, initializes = %d, closes = %d; want 2, 2, 1", starts, initializes, closes)
}
}
func TestStopWaitsForStartupAndPreventsRestart(t *testing.T) {
started := make(chan struct{}, 1)
release := make(chan struct{})
client := &fakeCodexClient{initStarted: started, initRelease: release}
rt := &accountRuntime{client: client}
readyDone := make(chan error, 1)
go func() { readyDone <- rt.ensureReady(context.Background()) }()
<-started
stopDone := make(chan struct{})
go func() { rt.stop(); close(stopDone) }()
close(release)
if err := <-readyDone; err != nil {
t.Fatalf("startup failed: %v", err)
}
<-stopDone
if err := rt.ensureReady(context.Background()); !errors.Is(err, errRuntimeStopped) {
t.Fatalf("restart error = %v; want stopped", err)
}
starts, initializes, closes, _ := client.counts()
if starts != 1 || initializes != 1 || closes != 1 {
t.Fatalf("starts = %d, initializes = %d, closes = %d; want 1 each", starts, initializes, closes)
}
}
func TestDeviceLoginStartsColdRuntime(t *testing.T) {
client := &fakeCodexClient{}
a := &App{runtimes: map[int64]*accountRuntime{2: {client: client}}}
recorder := httptest.NewRecorder()
request := httptest.NewRequest("POST", "/api/v1/accounts/2/login/device", nil)
a.deviceLogin(recorder, request, 2)
if recorder.Code != 200 {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
starts, initializes, _, calls := client.counts()
if starts != 1 || initializes != 1 || calls != 1 {
t.Fatalf("starts = %d, initializes = %d, calls = %d; want 1 each", starts, initializes, calls)
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
<script type="module" crossorigin src="/assets/index-CCkNuko2.js"></script> <script type="module" crossorigin src="/assets/index-D5kXUJ5m.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-2I4Pedo2.css"> <link rel="stylesheet" crossorigin href="/assets/index-2I4Pedo2.css">
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -18,7 +18,7 @@ const Stat=({label,v,muted=false}:{label:string,v:string,muted?:boolean})=><div
const Chart=({data}:{data:Point[]})=><div className="panel chart"><div><h3>每日 Token 趋势</h3><small>Codex app server 提供的每日总量</small></div><ResponsiveContainer width="100%" height={280}><AreaChart data={data}><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#5ee7f7" stopOpacity={.35}/><stop offset="1" stopColor="#5ee7f7" stopOpacity={0}/></linearGradient></defs><CartesianGrid stroke="var(--grid)" vertical={false}/><XAxis dataKey="date" stroke="var(--muted)"/><YAxis width={55} tickCount={5} tickFormatter={compactAxis} stroke="var(--muted)"/><Tooltip formatter={(v)=>[new Intl.NumberFormat('zh-CN').format(Number(v)),'Tokens']} contentStyle={{background:'var(--panel)',border:'1px solid var(--border)',borderRadius:12}}/><Area type="monotone" dataKey="totalTokens" stroke="#4dd8ed" fill="url(#a)" strokeWidth={2}/></AreaChart></ResponsiveContainer></div>; const Chart=({data}:{data:Point[]})=><div className="panel chart"><div><h3>每日 Token 趋势</h3><small>Codex app server 提供的每日总量</small></div><ResponsiveContainer width="100%" height={280}><AreaChart data={data}><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#5ee7f7" stopOpacity={.35}/><stop offset="1" stopColor="#5ee7f7" stopOpacity={0}/></linearGradient></defs><CartesianGrid stroke="var(--grid)" vertical={false}/><XAxis dataKey="date" stroke="var(--muted)"/><YAxis width={55} tickCount={5} tickFormatter={compactAxis} stroke="var(--muted)"/><Tooltip formatter={(v)=>[new Intl.NumberFormat('zh-CN').format(Number(v)),'Tokens']} contentStyle={{background:'var(--panel)',border:'1px solid var(--border)',borderRadius:12}}/><Area type="monotone" dataKey="totalTokens" stroke="#4dd8ed" fill="url(#a)" strokeWidth={2}/></AreaChart></ResponsiveContainer></div>;
function SettingsPage(){const[tab,setTab]=useState('general');return <><Header title="设置中心" sub="所有运行期配置均可在这里完成"/><div className="settings"><div className="tabs"><button className={tab==='general'?'active':''} onClick={()=>setTab('general')}><Settings/>通用</button><button className={tab==='codex'?'active':''} onClick={()=>setTab('codex')}><Zap/>Codex</button><button className={tab==='telegram'?'active':''} onClick={()=>setTab('telegram')}><Bell/>Telegram</button><button className={tab==='smtp'?'active':''} onClick={()=>setTab('smtp')}><Bell/>SMTP 邮件</button></div>{tab==='general'?<General/>:tab==='codex'?<CodexSettings/>:tab==='telegram'?<Telegram/>:<SMTP/>}</div></>} function SettingsPage(){const[tab,setTab]=useState('general');return <><Header title="设置中心" sub="所有运行期配置均可在这里完成"/><div className="settings"><div className="tabs"><button className={tab==='general'?'active':''} onClick={()=>setTab('general')}><Settings/>通用</button><button className={tab==='codex'?'active':''} onClick={()=>setTab('codex')}><Zap/>Codex</button><button className={tab==='telegram'?'active':''} onClick={()=>setTab('telegram')}><Bell/>Telegram</button><button className={tab==='smtp'?'active':''} onClick={()=>setTab('smtp')}><Bell/>SMTP 邮件</button></div>{tab==='general'?<General/>:tab==='codex'?<CodexSettings/>:tab==='telegram'?<Telegram/>:<SMTP/>}</div></>}
function General(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/general').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{await api('settings/general',{method:'PUT',body:JSON.stringify(v)});setMsg('设置已保存')}catch(x){setMsg((x as Error).message)}}}><h2>通用设置</h2><div className="grid2"><label>时区<input value={v.timezone} onChange={e=>setV({...v,timezone:e.target.value})}/></label><label>主题<select value={v.theme} onChange={e=>setV({...v,theme:e.target.value})}><option value="system">跟随系统</option><option value="dark">深色</option><option value="light">浅色</option></select></label><label>同步间隔(分钟)<input type="number" min="1" max="60" value={v.syncMinutes} onChange={e=>setV({...v,syncMinutes:+e.target.value})}/></label><label>历史保留(天)<select value={v.retentionDays} onChange={e=>setV({...v,retentionDays:+e.target.value})}>{[30,60,90,180,365].map(x=><option key={x}>{x}</option>)}</select></label><label>提前提醒(分钟)<input type="number" min="1" max="1440" value={v.beforeMinutes} onChange={e=>setV({...v,beforeMinutes:+e.target.value})}/></label><label className="checks"><input type="checkbox" checked={v.notifyBefore} onChange={e=>setV({...v,notifyBefore:e.target.checked})}/>重置前提醒 <input type="checkbox" checked={v.notifyAfter} onChange={e=>setV({...v,notifyAfter:e.target.checked})}/>重置后确认</label></div><button>保存设置</button>{msg&&<p className="hint">{msg}</p>}</form>} function General(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/general').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{await api('settings/general',{method:'PUT',body:JSON.stringify(v)});setMsg('设置已保存')}catch(x){setMsg((x as Error).message)}}}><h2>通用设置</h2><div className="grid2"><label>时区<input value={v.timezone} onChange={e=>setV({...v,timezone:e.target.value})}/></label><label>主题<select value={v.theme} onChange={e=>setV({...v,theme:e.target.value})}><option value="system">跟随系统</option><option value="dark">深色</option><option value="light">浅色</option></select></label><label>同步间隔(分钟)<input type="number" min="1" max="60" value={v.syncMinutes} onChange={e=>setV({...v,syncMinutes:+e.target.value})}/></label><label>历史保留(天)<select value={v.retentionDays} onChange={e=>setV({...v,retentionDays:+e.target.value})}>{[30,60,90,180,365].map(x=><option key={x}>{x}</option>)}</select></label><label>提前提醒(分钟)<input type="number" min="1" max="1440" value={v.beforeMinutes} onChange={e=>setV({...v,beforeMinutes:+e.target.value})}/></label><label className="checks"><input type="checkbox" checked={v.notifyBefore} onChange={e=>setV({...v,notifyBefore:e.target.checked})}/>重置前提醒 <input type="checkbox" checked={v.notifyAfter} onChange={e=>setV({...v,notifyAfter:e.target.checked})}/>重置后确认</label></div><button>保存设置</button>{msg&&<p className="hint">{msg}</p>}</form>}
function CodexSettings(){const[xs,setXs]=useState<Account[]>([]),[result,setResult]=useState<any>(null),[active,setActive]=useState(0),[err,setErr]=useState('');const load=()=>api<Account[]>('accounts').then(setXs);useEffect(()=>{load()},[]);const add=async()=>{try{const x=await post<Account>('accounts',{displayName:`账号 ${xs.length+1}`});await load();await login(x.id)}catch(q){setErr((q as Error).message)}};const login=async(id:number)=>{try{setActive(id);setResult(await post(`accounts/${id}/login/device`));setErr('')}catch(q){setErr((q as Error).message)}};return <div className="panel form wide"><div className="settings-title"><div><h2>Codex 账户与工作区</h2><p>个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。</p></div><button onClick={add}>添加账号</button></div><div className="account-list">{xs.map(x=><div className="account-row" key={x.id}><span className={'dot '+(x.connected?'ok':'')}/><div><input aria-label="连接名称" defaultValue={x.displayName} onBlur={async e=>{const name=e.target.value.trim();if(name&&name!==x.displayName){await put(`accounts/${x.id}`,{displayName:name});load()}}}/><small>{x.email||'尚未登录'} · {x.planType||'未识别套餐'}</small></div><button className="secondary" onClick={()=>login(x.id)}>设备码登录</button><button className="secondary" onClick={async()=>{await post(`accounts/${x.id}/logout`);load()}}>退出</button><button className="icon danger" title="删除连接" onClick={async()=>{if(confirm(`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`)){await del(`accounts/${x.id}`);if(+localStorage.accountId===x.id)localStorage.removeItem('accountId');load()}}}><Trash2/></button></div>)}</div>{result&&<div className="codebox"><span>为“{xs.find(x=>x.id===active)?.displayName}”访问 <a href={result.verificationUrl} target="_blank" rel="noreferrer">{result.verificationUrl}</a></span><strong>{result.userCode}</strong></div>}{err&&<p className="error">{err}</p>}</div>} function CodexSettings(){const[xs,setXs]=useState<Account[]>([]),[result,setResult]=useState<any>(null),[active,setActive]=useState(0),[busy,setBusy]=useState(false),[err,setErr]=useState('');const load=()=>api<Account[]>('accounts').then(setXs);useEffect(()=>{load()},[]);const login=async(id:number)=>{try{setBusy(true);setActive(id);setErr('');setResult(await post(`accounts/${id}/login/device`))}catch(q){setErr((q as Error).message)}finally{setBusy(false)}};const add=async()=>{try{setBusy(true);setActive(0);setErr('');const x=await post<Account>('accounts',{displayName:`账号 ${xs.length+1}`});await load();setActive(x.id);setResult(await post(`accounts/${x.id}/login/device`))}catch(q){setErr((q as Error).message)}finally{setBusy(false)}};return <div className="panel form wide"><div className="settings-title"><div><h2>Codex 账户与工作区</h2><p>个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。</p></div><button disabled={busy} onClick={add}>{busy&&active===0?'服务启动中…':'添加账号'}</button></div><div className="account-list">{xs.map(x=><div className="account-row" key={x.id}><span className={'dot '+(x.connected?'ok':'')}/><div><input aria-label="连接名称" defaultValue={x.displayName} onBlur={async e=>{const name=e.target.value.trim();if(name&&name!==x.displayName){await put(`accounts/${x.id}`,{displayName:name});load()}}}/><small>{x.email||'尚未登录'} · {x.planType||'未识别套餐'}</small></div><button className="secondary" disabled={busy} onClick={()=>login(x.id)}>{busy&&active===x.id?'服务启动中…':'设备码登录'}</button><button className="secondary" disabled={busy} onClick={async()=>{await post(`accounts/${x.id}/logout`);load()}}>退出</button><button className="icon danger" disabled={busy} title="删除连接" onClick={async()=>{if(confirm(`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`)){await del(`accounts/${x.id}`);if(+localStorage.accountId===x.id)localStorage.removeItem('accountId');load()}}}><Trash2/></button></div>)}</div>{result&&<div className="codebox"><span>为“{xs.find(x=>x.id===active)?.displayName}”访问 <a href={result.verificationUrl} target="_blank" rel="noreferrer">{result.verificationUrl}</a></span><strong>{result.userCode}</strong></div>}{err&&<p className="error">{err}</p>}</div>}
function Telegram(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState(''),[code,setCode]=useState('');useEffect(()=>{api('settings/telegram').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{const x:any=await api('settings/telegram',{method:'PUT',body:JSON.stringify(v)});setV(x);setMsg('Bot 已验证并保存')}catch(x){setMsg((x as Error).message)}}}><h2>Telegram Bot</h2><label>Bot Token<input type="password" value={v.token||''} onChange={e=>setV({...v,token:e.target.value})} placeholder={v.configured?'已配置,留空表示不修改':'从 BotFather 获取'}/></label><div className="checks"><label><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用提醒</label><label><input type="checkbox" checked={v.menuEnabled} onChange={e=>setV({...v,menuEnabled:e.target.checked})}/>启用查询菜单</label></div><button>验证并保存</button><div className="actions"><button type="button" className="secondary" onClick={async()=>{const x:any=await post('settings/telegram/bind');setCode(x.code)}}>生成绑定码</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/telegram/test');setMsg('测试消息已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试</button></div>{code&&<div className="codebox"><span>向 Bot 发送</span><strong>/bind {code}</strong></div>}{msg&&<p className="hint">{msg}</p>}</form>} function Telegram(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState(''),[code,setCode]=useState('');useEffect(()=>{api('settings/telegram').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{const x:any=await api('settings/telegram',{method:'PUT',body:JSON.stringify(v)});setV(x);setMsg('Bot 已验证并保存')}catch(x){setMsg((x as Error).message)}}}><h2>Telegram Bot</h2><label>Bot Token<input type="password" value={v.token||''} onChange={e=>setV({...v,token:e.target.value})} placeholder={v.configured?'已配置,留空表示不修改':'从 BotFather 获取'}/></label><div className="checks"><label><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用提醒</label><label><input type="checkbox" checked={v.menuEnabled} onChange={e=>setV({...v,menuEnabled:e.target.checked})}/>启用查询菜单</label></div><button>验证并保存</button><div className="actions"><button type="button" className="secondary" onClick={async()=>{const x:any=await post('settings/telegram/bind');setCode(x.code)}}>生成绑定码</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/telegram/test');setMsg('测试消息已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试</button></div>{code&&<div className="codebox"><span>向 Bot 发送</span><strong>/bind {code}</strong></div>}{msg&&<p className="hint">{msg}</p>}</form>}
function SMTP(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/smtp').then(setV)},[]);if(!v)return <Splash/>;const save=async(e:React.FormEvent)=>{e.preventDefault();try{setV(await api('settings/smtp',{method:'PUT',body:JSON.stringify(v)}));setMsg('SMTP 设置已保存')}catch(x){setMsg((x as Error).message)}};return <form className="panel form wide" onSubmit={save}><h2>SMTP 邮件</h2><div className="grid2"><label>服务器<input value={v.host} onChange={e=>setV({...v,host:e.target.value})} placeholder="smtp.example.com"/></label><label>端口<input type="number" value={v.port} onChange={e=>setV({...v,port:+e.target.value})}/></label><label>用户名<input value={v.username} onChange={e=>setV({...v,username:e.target.value})}/></label><label>密码<input type="password" value={v.password||''} onChange={e=>setV({...v,password:e.target.value})} placeholder={v.configured?'已配置,留空不修改':''}/></label><label>加密<select value={v.security} onChange={e=>setV({...v,security:e.target.value})}><option value="starttls">STARTTLS</option><option value="tls">隐式 TLS</option><option value="none">无加密</option></select></label><label>发件名称<input value={v.fromName} onChange={e=>setV({...v,fromName:e.target.value})}/></label><label>发件地址<input type="email" value={v.from} onChange={e=>setV({...v,from:e.target.value})}/></label><label>收件地址<input type="email" value={v.to} onChange={e=>setV({...v,to:e.target.value})}/></label></div><label className="checks"><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用邮件提醒</label><div className="actions"><button>保存</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/smtp/test');setMsg('测试邮件已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试邮件</button></div>{msg&&<p className="hint">{msg}</p>}</form>} function SMTP(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/smtp').then(setV)},[]);if(!v)return <Splash/>;const save=async(e:React.FormEvent)=>{e.preventDefault();try{setV(await api('settings/smtp',{method:'PUT',body:JSON.stringify(v)}));setMsg('SMTP 设置已保存')}catch(x){setMsg((x as Error).message)}};return <form className="panel form wide" onSubmit={save}><h2>SMTP 邮件</h2><div className="grid2"><label>服务器<input value={v.host} onChange={e=>setV({...v,host:e.target.value})} placeholder="smtp.example.com"/></label><label>端口<input type="number" value={v.port} onChange={e=>setV({...v,port:+e.target.value})}/></label><label>用户名<input value={v.username} onChange={e=>setV({...v,username:e.target.value})}/></label><label>密码<input type="password" value={v.password||''} onChange={e=>setV({...v,password:e.target.value})} placeholder={v.configured?'已配置,留空不修改':''}/></label><label>加密<select value={v.security} onChange={e=>setV({...v,security:e.target.value})}><option value="starttls">STARTTLS</option><option value="tls">隐式 TLS</option><option value="none">无加密</option></select></label><label>发件名称<input value={v.fromName} onChange={e=>setV({...v,fromName:e.target.value})}/></label><label>发件地址<input type="email" value={v.from} onChange={e=>setV({...v,from:e.target.value})}/></label><label>收件地址<input type="email" value={v.to} onChange={e=>setV({...v,to:e.target.value})}/></label></div><label className="checks"><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用邮件提醒</label><div className="actions"><button>保存</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/smtp/test');setMsg('测试邮件已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试邮件</button></div>{msg&&<p className="hint">{msg}</p>}</form>}
function Header({title,sub,children}:{title:string,sub:string,children?:React.ReactNode}){return <header><div><h1>{title}</h1><p>{sub}</p></div>{children}</header>} function Header({title,sub,children}:{title:string,sub:string,children?:React.ReactNode}){return <header><div><h1>{title}</h1><p>{sub}</p></div>{children}</header>}