feat: 登录持久化修复 + 左下角账号区 + 管理页面(管理员/邀请码)
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { serverLogin, serverRegister } from '@/lib/api';
|
||||
import { Button, Dialog, Field, Input } from '@/components/ui';
|
||||
|
||||
export function LoginDialog({ open, onClose, defaultUrl }: { open: boolean; onClose: () => void; defaultUrl: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [serverUrl, setServerUrl] = useState(defaultUrl);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [invite, setInvite] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!username.trim() || !password) { setError('请填写账号与密码'); return; }
|
||||
let base = serverUrl.trim();
|
||||
if (base && !base.startsWith('http')) base = 'https://' + base;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await serverLogin(username.trim(), password, base);
|
||||
} else {
|
||||
if (!invite.trim()) throw new Error('请填写邀请码');
|
||||
await serverRegister(username.trim(), password, invite.trim(), base);
|
||||
}
|
||||
void qc.invalidateQueries();
|
||||
onClose();
|
||||
window.location.reload(); // 触发侧边栏与同步引擎重启
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} width={440} title={mode === 'login' ? '登录账号' : '注册账号'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button variant="primary" onClick={() => void submit()} disabled={busy}>{busy ? '请稍候…' : '确定'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="服务器地址" hint="默认与网页版同一账号体系">
|
||||
<Input value={serverUrl} onChange={e => setServerUrl(e.target.value)} placeholder="https://footpr.linyux.cn" />
|
||||
</Field>
|
||||
<Field label="用户名" required>
|
||||
<Input value={username} onChange={e => setUsername(e.target.value)} placeholder="3-32 位字母数字-_" />
|
||||
</Field>
|
||||
<Field label="密码" required>
|
||||
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="至少 8 位" />
|
||||
</Field>
|
||||
{mode === 'register' && (
|
||||
<Field label="邀请码" required hint="向管理员索取">
|
||||
<Input value={invite} onChange={e => setInvite(e.target.value)} placeholder="邀请码" />
|
||||
</Field>
|
||||
)}
|
||||
{error && <div className="text-[12px] text-danger">{error}</div>}
|
||||
<div className="flex text-[12px] text-ink-3 gap-2">
|
||||
<button className="underline hover:text-ink-1" onClick={() => { setMode(mode === 'login' ? 'register' : 'login'); setError(''); }}>
|
||||
{mode === 'login' ? '没有账号?注册' : '已有账号?登录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { ToastProvider } from '@/components/ui';
|
||||
import { LoginPage } from '@/components/auth/LoginPage';
|
||||
import { isLocalMode } from '@/lib/api';
|
||||
import { getToken } from '@/lib/api';
|
||||
import { isTauriRef } from '@/lib/api';
|
||||
|
||||
export function AppShell() {
|
||||
const [authed, setAuthed] = useState(() => isLocalMode());
|
||||
const [authed, setAuthed] = useState(() => isTauriRef() || !!getToken());
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, ChevronsLeft } from 'lucide-react';
|
||||
import { appInfo, syncStatus } from '@/lib/api';
|
||||
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, ShieldCheck } from 'lucide-react';
|
||||
import { syncStatus, serverMe, clearToken, isTauriRef, getToken } from '@/lib/api';
|
||||
import { LoginDialog } from '@/components/auth/LoginDialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV = [
|
||||
@@ -17,6 +19,7 @@ const NAV = [
|
||||
{
|
||||
section: '配置',
|
||||
items: [
|
||||
{ to: '/admin', label: '管理', icon: ShieldCheck },
|
||||
{ to: '/types', label: '记录类型', icon: Shapes },
|
||||
{ to: '/taxonomy', label: '字典与维度', icon: SlidersHorizontal }
|
||||
]
|
||||
@@ -33,9 +36,21 @@ function LogoMark() {
|
||||
);
|
||||
}
|
||||
|
||||
function baseUrl() {
|
||||
return isTauriRef() ? (localStorage.getItem('fp-server-url') || '') : '';
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const info = useQuery({ queryKey: ['app-info'], queryFn: appInfo, staleTime: Infinity });
|
||||
const isWeb = !isTauriRef();
|
||||
const sync = useQuery({ queryKey: ['sync-status'], queryFn: syncStatus, refetchInterval: 15000 });
|
||||
const meQ = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => serverMe(baseUrl()),
|
||||
enabled: !!getToken(),
|
||||
retry: 0
|
||||
});
|
||||
const me = meQ.data ?? null;
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -52,7 +67,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
|
||||
<div className="text-[10px] font-medium tracking-[0.18em] text-ink-3">FOOTPRINTS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} defaultUrl={localStorage.getItem('fp-server-url') || 'https://footpr.linyux.cn'} />
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3">
|
||||
{NAV.map(group => (
|
||||
<div key={group.section} className="mb-4">
|
||||
@@ -82,18 +97,65 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-line px-4 py-3">
|
||||
<div className="truncate text-[11px] text-ink-3" title={info.data?.db_path ?? ''}>
|
||||
{info.data ? '数据库:' + info.data.db_path.split(/[\\/]/).slice(-1)[0] : '加载中…'}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-ink-3">
|
||||
<ChevronsLeft size={11} />
|
||||
{sync.data
|
||||
? (sync.data.pending_ops > 0
|
||||
? '本地模式 · 待同步 ' + sync.data.pending_ops
|
||||
: '本地模式 · 数据已就绪')
|
||||
: '…'}
|
||||
</div>
|
||||
{/* 左下角:账号区 */}
|
||||
{getToken() ? (
|
||||
<div className="mb-2">
|
||||
{me && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[12px] font-medium text-ink-1" title={me.username}>
|
||||
{me.is_admin && <span className="mr-1 text-brand-text">管理员</span>}
|
||||
{me.username}
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 text-[11px] text-ink-3 underline transition-colors hover:text-danger"
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
localStorage.removeItem('fp-server-url');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isWeb && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-1 py-1 text-[12px] text-ink-2 transition-colors hover:bg-surface-3 hover:text-ink-1"
|
||||
onClick={() => setLoginOpen(true)}
|
||||
>
|
||||
<LogIn size={13} />
|
||||
{me ? "账号与同步设置" : "已注册 · 登录同步"}
|
||||
</button>
|
||||
)}
|
||||
{!isWeb && (
|
||||
<div className="num text-[10px] text-ink-3">
|
||||
{sync.data ? ("同步 · 待推送 " + sync.data.pending_ops) : "…"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 rounded-md bg-brand-subtle px-2 py-1.5 text-[12.5px] font-medium text-brand-text transition-colors hover:brightness-[0.97]"
|
||||
onClick={() => setLoginOpen(true)}
|
||||
>
|
||||
<LogIn size={13} />
|
||||
登录 / 注册账号
|
||||
</button>
|
||||
<div className="text-[10px] text-ink-3">未登录 = 本地模式,数据仅存本机</div>
|
||||
</div>
|
||||
)}
|
||||
{!isWeb && (
|
||||
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-ink-3">
|
||||
<ChevronsLeftSvg />
|
||||
数据库 · 本地存储
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronsLeftSvg() {
|
||||
return <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 17l-5-5 5-5" /><path d="M18 17l-5-5 5-5" /></svg>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user