chore: sync local changes to Gitea
This commit is contained in:
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#14532d" />
|
||||
<meta name="description" content="Sprout2FA — 网页版双因素验证码管理" />
|
||||
<title>Sprout2FA</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6383
frontend/package-lock.json
generated
Normal file
6383
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "sprout2fa-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"workbox-window": "^7.3.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
21
frontend/src/App.tsx
Normal file
21
frontend/src/App.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { usePwaRegister } from './hooks/usePwaRegister';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { IndexPage } from './pages/IndexPage';
|
||||
import { AddAccountPage } from './pages/AddAccountPage';
|
||||
import { EditAccountPage } from './pages/EditAccountPage';
|
||||
|
||||
export default function App() {
|
||||
usePwaRegister();
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<IndexPage />} />
|
||||
<Route path="/add" element={<AddAccountPage />} />
|
||||
<Route path="/edit/:id" element={<EditAccountPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
frontend/src/api/client.ts
Normal file
151
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
const jsonHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
async function handle<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const j = (await res.json()) as { error?: string };
|
||||
if (j.error) msg = j.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<void> {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
await handle<{ ok: boolean }>(res);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const res = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
await handle<{ ok: boolean }>(res);
|
||||
}
|
||||
|
||||
export type AccountDto = {
|
||||
id: string;
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
code: string;
|
||||
expiresInSeconds: number;
|
||||
shareToken: string;
|
||||
};
|
||||
|
||||
export type SharedAccountDto = {
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
code: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
export async function fetchSharedAccount(token: string): Promise<SharedAccountDto> {
|
||||
const res = await fetch(`/api/share/${encodeURIComponent(token)}`);
|
||||
const data = await handle<{ account: SharedAccountDto }>(res);
|
||||
return data.account;
|
||||
}
|
||||
|
||||
export async function fetchAccounts(): Promise<AccountDto[]> {
|
||||
const res = await fetch('/api/accounts', { credentials: 'include' });
|
||||
const data = await handle<{ accounts: AccountDto[] }>(res);
|
||||
return data.accounts;
|
||||
}
|
||||
|
||||
export async function createAccount(body: {
|
||||
label: string;
|
||||
issuer?: string | null;
|
||||
secret: string;
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
period?: number;
|
||||
}): Promise<{ id: string }> {
|
||||
const res = await fetch('/api/accounts', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handle<{ id: string; ok: boolean }>(res);
|
||||
}
|
||||
|
||||
export async function updateAccount(
|
||||
id: string,
|
||||
body: {
|
||||
label: string;
|
||||
issuer?: string | null;
|
||||
secret?: string;
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
period?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const payload: Record<string, unknown> = {
|
||||
label: body.label,
|
||||
issuer: body.issuer ?? null,
|
||||
};
|
||||
if (body.secret?.trim()) payload.secret = body.secret.trim();
|
||||
if (body.algorithm) payload.algorithm = body.algorithm;
|
||||
if (body.digits !== undefined) payload.digits = body.digits;
|
||||
if (body.period !== undefined) payload.period = body.period;
|
||||
|
||||
const res = await fetch(`/api/accounts/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await handle<{ ok: boolean }>(res);
|
||||
}
|
||||
|
||||
export async function deleteAccount(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/accounts/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
await handle<{ ok: boolean }>(res);
|
||||
}
|
||||
|
||||
export type ExportPayload = {
|
||||
version: number;
|
||||
exportedAt: string;
|
||||
items: {
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function exportAll(): Promise<ExportPayload> {
|
||||
const res = await fetch('/api/export', { credentials: 'include' });
|
||||
return handle<ExportPayload>(res);
|
||||
}
|
||||
|
||||
export async function importItems(
|
||||
items: ExportPayload['items'],
|
||||
): Promise<{ imported: number }> {
|
||||
const res = await fetch('/api/import', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
return handle<{ ok: boolean; imported: number }>(res);
|
||||
}
|
||||
37
frontend/src/components/IconButton.tsx
Normal file
37
frontend/src/components/IconButton.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IconCheck } from './icons';
|
||||
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
to?: string;
|
||||
variant?: 'default' | 'danger';
|
||||
copied?: boolean;
|
||||
};
|
||||
|
||||
export function IconButton({
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
to,
|
||||
variant = 'default',
|
||||
copied = false,
|
||||
}: IconButtonProps) {
|
||||
const className = `icon-btn${variant === 'danger' ? ' danger' : ''}${copied ? ' copied' : ''}`;
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link className={className} to={to} title={label} aria-label={label}>
|
||||
{copied ? <IconCheck /> : children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={className} title={label} aria-label={label} onClick={onClick}>
|
||||
{copied ? <IconCheck /> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/TotpCard.tsx
Normal file
64
frontend/src/components/TotpCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { IconButton } from './IconButton';
|
||||
import { IconCopy } from './icons';
|
||||
|
||||
type TotpCardProps = {
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
code: string;
|
||||
expiresInSeconds: number;
|
||||
period: number;
|
||||
toolbar?: ReactNode;
|
||||
};
|
||||
|
||||
export function TotpCard({
|
||||
label,
|
||||
issuer,
|
||||
code,
|
||||
expiresInSeconds,
|
||||
period,
|
||||
toolbar,
|
||||
}: TotpCardProps) {
|
||||
const pct = Math.max(0, Math.min(100, (expiresInSeconds / period) * 100));
|
||||
const [codeCopied, setCodeCopied] = useState(false);
|
||||
|
||||
async function onCopyCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCodeCopied(true);
|
||||
setTimeout(() => setCodeCopied(false), 1500);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card totp-card">
|
||||
<div className="totp-card-head">
|
||||
<div className="totp-card-meta">
|
||||
<span className="totp-card-label" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
{issuer ? <span className="totp-card-issuer">{issuer}</span> : null}
|
||||
</div>
|
||||
{toolbar ? <div className="icon-toolbar">{toolbar}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="totp-code-row">
|
||||
<button type="button" className="totp-code-hit" onClick={() => void onCopyCode()} title="点击复制验证码">
|
||||
<span className="totp-code">{code}</span>
|
||||
</button>
|
||||
<IconButton label="复制验证码" copied={codeCopied} onClick={() => void onCopyCode()}>
|
||||
<IconCopy />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="totp-card-foot">
|
||||
<div className="progress" aria-hidden>
|
||||
<i style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="totp-card-timer">{expiresInSeconds}s</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/icons.tsx
Normal file
75
frontend/src/components/icons.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
type IconProps = { size?: number };
|
||||
|
||||
export function IconLink({ size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M10 13a5 5 0 0 0 7.07 0l2.12-2.12a5 5 0 0 0-7.07-7.07L11 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 11a5 5 0 0 0-7.07 0L4.81 13.12a5 5 0 1 0 7.07 7.07L13 19"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconEdit({ size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash({ size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M3 6h18M8 6V4h8v2M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M10 11v6M14 11v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCopy({ size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||
<path
|
||||
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck({ size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M20 6 9 17l-5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
frontend/src/hooks/usePwaRegister.ts
Normal file
16
frontend/src/hooks/usePwaRegister.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 注册 PWA Service Worker(由 vite-plugin-pwa 注入)
|
||||
*/
|
||||
export function usePwaRegister() {
|
||||
useEffect(() => {
|
||||
import('virtual:pwa-register')
|
||||
.then(({ registerSW }) => {
|
||||
registerSW({ immediate: true });
|
||||
})
|
||||
.catch(() => {
|
||||
/* 开发模式或未启用插件时忽略 */
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
71
frontend/src/lib/otpauth-parse.ts
Normal file
71
frontend/src/lib/otpauth-parse.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type ParsedOtpAuth = {
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 otpauth://totp/... 或 otpauth://hotp/...(按 TOTP 处理)
|
||||
*/
|
||||
export function parseOtpauthUri(uri: string): ParsedOtpAuth | null {
|
||||
const s = uri.trim();
|
||||
const lower = s.toLowerCase();
|
||||
if (!lower.startsWith('otpauth://totp/') && !lower.startsWith('otpauth://hotp/')) {
|
||||
return null;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (!secret) return null;
|
||||
const issuerQP = url.searchParams.get('issuer');
|
||||
let pathPart = decodeURIComponent(url.pathname.replace(/^\/+/u, ''));
|
||||
if (pathPart.includes(':')) {
|
||||
pathPart = pathPart.split(':').pop() ?? pathPart;
|
||||
}
|
||||
const label = pathPart || '未命名';
|
||||
const issuer = issuerQP?.trim() ? issuerQP : null;
|
||||
const algorithm = (url.searchParams.get('algorithm') || 'SHA1').toUpperCase();
|
||||
const digits = Math.min(
|
||||
10,
|
||||
Math.max(6, parseInt(url.searchParams.get('digits') || '6', 10) || 6),
|
||||
);
|
||||
const period = Math.min(
|
||||
120,
|
||||
Math.max(
|
||||
10,
|
||||
parseInt(url.searchParams.get('period') || '30', 10) || 30,
|
||||
),
|
||||
);
|
||||
return {
|
||||
label,
|
||||
issuer,
|
||||
secret,
|
||||
algorithm,
|
||||
digits,
|
||||
period,
|
||||
};
|
||||
}
|
||||
|
||||
/** 粘贴文本:可能是 URI 或换行分隔的 URI */
|
||||
export function parseOtpauthFromText(text: string): ParsedOtpAuth | null {
|
||||
const lines = text
|
||||
.split(/\r?\n/u)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const p = parseOtpauthUri(line);
|
||||
if (p) return p;
|
||||
}
|
||||
if (text.includes('otpauth://')) {
|
||||
const match = text.match(/otpauth:\/\/[^\s]+/iu);
|
||||
if (match) return parseOtpauthUri(match[0]!);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
3
frontend/src/lib/shareUrl.ts
Normal file
3
frontend/src/lib/shareUrl.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function buildShareUrl(shareToken: string): string {
|
||||
return `${window.location.origin}/?token=${encodeURIComponent(shareToken)}`;
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
274
frontend/src/pages/AddAccountPage.tsx
Normal file
274
frontend/src/pages/AddAccountPage.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { createAccount } from '../api/client';
|
||||
import {
|
||||
parseOtpauthFromText,
|
||||
parseOtpauthUri,
|
||||
type ParsedOtpAuth,
|
||||
} from '../lib/otpauth-parse';
|
||||
|
||||
type Segment = 'manual' | 'scan' | 'image';
|
||||
|
||||
export function AddAccountPage() {
|
||||
const nav = useNavigate();
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [segment, setSegment] = useState<Segment>('manual');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [label, setLabel] = useState('');
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [algorithm, setAlgorithm] = useState('SHA1');
|
||||
const [digits, setDigits] = useState(6);
|
||||
const [period, setPeriod] = useState(30);
|
||||
const [paste, setPaste] = useState('');
|
||||
|
||||
const applyParsed = useCallback((p: ParsedOtpAuth) => {
|
||||
setLabel(p.label);
|
||||
setIssuer(p.issuer ?? '');
|
||||
setSecret(p.secret);
|
||||
setAlgorithm(p.algorithm);
|
||||
setDigits(p.digits);
|
||||
setPeriod(p.period);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
async function submitParsed(p?: ParsedOtpAuth) {
|
||||
const data =
|
||||
p ??
|
||||
({
|
||||
label: label.trim(),
|
||||
issuer: issuer.trim() || null,
|
||||
secret: secret.trim(),
|
||||
algorithm,
|
||||
digits,
|
||||
period,
|
||||
} as ParsedOtpAuth);
|
||||
if (!data.label || !data.secret.trim()) {
|
||||
setError('请填写名称与密钥');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createAccount({
|
||||
label: data.label,
|
||||
issuer: data.issuer,
|
||||
secret: data.secret.trim(),
|
||||
algorithm: data.algorithm,
|
||||
digits: data.digits,
|
||||
period: data.period,
|
||||
});
|
||||
nav('/', { replace: true });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onManualSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
void submitParsed();
|
||||
}
|
||||
|
||||
function onApplyPaste() {
|
||||
const p = parseOtpauthFromText(paste);
|
||||
if (!p) {
|
||||
setError('未识别 otpauth 链接,请检查粘贴内容');
|
||||
return;
|
||||
}
|
||||
applyParsed(p);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (segment !== 'scan') return;
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
let reader: InstanceType<
|
||||
Awaited<typeof import('@zxing/browser')>['BrowserMultiFormatReader']
|
||||
> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { BrowserMultiFormatReader } = await import('@zxing/browser');
|
||||
reader = new BrowserMultiFormatReader();
|
||||
await reader.decodeFromVideoDevice(undefined, video, (result) => {
|
||||
if (cancelled || !result) return;
|
||||
const text = result.getText();
|
||||
const p = parseOtpauthUri(text);
|
||||
if (p) {
|
||||
applyParsed(p);
|
||||
if (reader) void reader.reset();
|
||||
setSegment('manual');
|
||||
setError(null);
|
||||
} else {
|
||||
setError('二维码内容不是 otpauth 格式');
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) setError('无法打开摄像头,请检查权限');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void reader?.reset();
|
||||
};
|
||||
}, [segment, applyParsed]);
|
||||
|
||||
function onImageFile(file: File) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const url = String(reader.result || '');
|
||||
try {
|
||||
const { BrowserMultiFormatReader } = await import('@zxing/browser');
|
||||
const bf = new BrowserMultiFormatReader();
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
img.src = url;
|
||||
await img.decode();
|
||||
const result = await bf.decodeFromImageElement(img);
|
||||
const text = result.getText();
|
||||
const p = parseOtpauthUri(text);
|
||||
if (!p) {
|
||||
setError('图片中二维码不是 otpauth 格式');
|
||||
return;
|
||||
}
|
||||
applyParsed(p);
|
||||
setSegment('manual');
|
||||
} catch {
|
||||
setError('无法从图片解析二维码');
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="top-nav">
|
||||
<Link className="btn secondary" to="/">
|
||||
返回
|
||||
</Link>
|
||||
<span style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<img className="top-nav-logo" src="/logo.png" alt="" width={28} height={28} />
|
||||
添加账户
|
||||
</span>
|
||||
<span style={{ width: 72 }} />
|
||||
</div>
|
||||
|
||||
<div className="segment">
|
||||
<button
|
||||
type="button"
|
||||
className={segment === 'manual' ? 'active' : ''}
|
||||
onClick={() => setSegment('manual')}
|
||||
>
|
||||
手动
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={segment === 'scan' ? 'active' : ''}
|
||||
onClick={() => setSegment('scan')}
|
||||
>
|
||||
扫码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={segment === 'image' ? 'active' : ''}
|
||||
onClick={() => setSegment('image')}
|
||||
>
|
||||
图片
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{segment === 'scan' ? (
|
||||
<div className="card scan-wrap">
|
||||
<p className="sub">将二维码对准画面,识别成功后会自动控制转手动页并填入。</p>
|
||||
<video ref={videoRef} className="scan" muted playsInline />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{segment === 'image' ? (
|
||||
<div className="card">
|
||||
<p className="sub">选择包含 otpauth 二维码的图片。</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onImageFile(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<p className="sub">粘贴 otpauth 链接可快速填充:</p>
|
||||
<textarea
|
||||
value={paste}
|
||||
placeholder="otpauth://totp/..."
|
||||
onChange={(e) => setPaste(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="btn secondary" style={{ marginTop: 8 }} onClick={onApplyPaste}>
|
||||
解析粘贴
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="card" onSubmit={onManualSubmit}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>账户信息</h2>
|
||||
<div className="field">
|
||||
<label>显示名称</label>
|
||||
<input value={label} onChange={(e) => setLabel(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>发行方(可选)</label>
|
||||
<input value={issuer} onChange={(e) => setIssuer(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Secret(Base32)</label>
|
||||
<input
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>算法</label>
|
||||
<select value={algorithm} onChange={(e) => setAlgorithm(e.target.value)}>
|
||||
<option value="SHA1">SHA1</option>
|
||||
<option value="SHA256">SHA256</option>
|
||||
<option value="SHA512">SHA512</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>位数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={6}
|
||||
max={10}
|
||||
value={digits}
|
||||
onChange={(e) => setDigits(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>周期(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={120}
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
<button className="btn" type="submit" disabled={busy} style={{ width: '100%', marginTop: 8 }}>
|
||||
{busy ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
155
frontend/src/pages/EditAccountPage.tsx
Normal file
155
frontend/src/pages/EditAccountPage.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { fetchAccounts, updateAccount, type AccountDto } from '../api/client';
|
||||
|
||||
export function EditAccountPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const nav = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [label, setLabel] = useState('');
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [algorithm, setAlgorithm] = useState('SHA1');
|
||||
const [digits, setDigits] = useState(6);
|
||||
const [period, setPeriod] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
nav('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await fetchAccounts();
|
||||
const account = list.find((a) => a.id === id);
|
||||
if (cancelled) return;
|
||||
if (!account) {
|
||||
nav('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
applyAccount(account);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
if (!cancelled) nav('/login', { replace: true });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, nav]);
|
||||
|
||||
function applyAccount(a: AccountDto) {
|
||||
setLabel(a.label);
|
||||
setIssuer(a.issuer ?? '');
|
||||
setAlgorithm(a.algorithm);
|
||||
setDigits(a.digits);
|
||||
setPeriod(a.period);
|
||||
setSecret('');
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!id) return;
|
||||
const trimmedLabel = label.trim();
|
||||
if (!trimmedLabel) {
|
||||
setError('请填写显示名称');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateAccount(id, {
|
||||
label: trimmedLabel,
|
||||
issuer: issuer.trim() || null,
|
||||
secret: secret.trim() || undefined,
|
||||
algorithm,
|
||||
digits,
|
||||
period,
|
||||
});
|
||||
nav('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main>
|
||||
<p className="sub">加载中…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="top-nav">
|
||||
<Link className="btn secondary" to="/">
|
||||
返回
|
||||
</Link>
|
||||
<span style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<img className="top-nav-logo" src="/logo.png" alt="" width={28} height={28} />
|
||||
编辑账户
|
||||
</span>
|
||||
<span style={{ width: 72 }} />
|
||||
</div>
|
||||
|
||||
<form className="card" onSubmit={onSubmit}>
|
||||
<div className="field">
|
||||
<label>显示名称</label>
|
||||
<input value={label} onChange={(e) => setLabel(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>发行方(可选)</label>
|
||||
<input value={issuer} onChange={(e) => setIssuer(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Secret(Base32)</label>
|
||||
<input
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
placeholder="留空表示不修改密钥"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>算法</label>
|
||||
<select value={algorithm} onChange={(e) => setAlgorithm(e.target.value)}>
|
||||
<option value="SHA1">SHA1</option>
|
||||
<option value="SHA256">SHA256</option>
|
||||
<option value="SHA512">SHA512</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>位数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={6}
|
||||
max={10}
|
||||
value={digits}
|
||||
onChange={(e) => setDigits(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>周期(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={120}
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
<button className="btn" type="submit" disabled={busy} style={{ width: '100%', marginTop: 8 }}>
|
||||
{busy ? '保存中…' : '保存修改'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
368
frontend/src/pages/HomePage.tsx
Normal file
368
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
deleteAccount,
|
||||
exportAll,
|
||||
fetchAccounts,
|
||||
importItems,
|
||||
logout,
|
||||
type AccountDto,
|
||||
} from '../api/client';
|
||||
import { IconButton } from '../components/IconButton';
|
||||
import { IconEdit, IconLink, IconTrash } from '../components/icons';
|
||||
import { TotpCard } from '../components/TotpCard';
|
||||
import { buildShareUrl } from '../lib/shareUrl';
|
||||
|
||||
function CodeRow({ a, onDelete }: { a: AccountDto; onDelete: (id: string) => void }) {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
async function onCopyShare() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildShareUrl(a.shareToken));
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 1500);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TotpCard
|
||||
label={a.label}
|
||||
issuer={a.issuer}
|
||||
code={a.code}
|
||||
expiresInSeconds={a.expiresInSeconds}
|
||||
period={a.period}
|
||||
toolbar={
|
||||
<>
|
||||
<IconButton label="复制分享链接" copied={linkCopied} onClick={() => void onCopyShare()}>
|
||||
<IconLink />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="编辑"
|
||||
to={`/edit/${encodeURIComponent(a.id)}`}
|
||||
>
|
||||
<IconEdit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="删除"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (confirm('确定删除此条目?')) onDelete(a.id);
|
||||
}}
|
||||
>
|
||||
<IconTrash />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const UNCATEGORIZED = '__uncategorized__';
|
||||
|
||||
type CategoryKey = 'all' | typeof UNCATEGORIZED | string;
|
||||
|
||||
function buildCategories(accounts: AccountDto[]): { key: CategoryKey; label: string; count: number }[] {
|
||||
const issuerCounts = new Map<string, number>();
|
||||
let uncategorized = 0;
|
||||
for (const a of accounts) {
|
||||
const issuer = a.issuer?.trim();
|
||||
if (!issuer) {
|
||||
uncategorized += 1;
|
||||
} else {
|
||||
issuerCounts.set(issuer, (issuerCounts.get(issuer) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const items: { key: CategoryKey; label: string; count: number }[] = [
|
||||
{ key: 'all', label: '全部', count: accounts.length },
|
||||
];
|
||||
for (const [issuer, count] of [...issuerCounts.entries()].sort((a, b) =>
|
||||
a[0].localeCompare(b[0], 'zh'),
|
||||
)) {
|
||||
items.push({ key: issuer, label: issuer, count });
|
||||
}
|
||||
if (uncategorized > 0) {
|
||||
items.push({ key: UNCATEGORIZED, label: '未分类', count: uncategorized });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function filterByCategory(accounts: AccountDto[], key: CategoryKey): AccountDto[] {
|
||||
if (key === 'all') return accounts;
|
||||
if (key === UNCATEGORIZED) {
|
||||
return accounts.filter((a) => !a.issuer?.trim());
|
||||
}
|
||||
return accounts.filter((a) => a.issuer?.trim() === key);
|
||||
}
|
||||
|
||||
function MenuIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M4 7h16M4 12h16M4 17h16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const [accounts, setAccounts] = useState<AccountDto[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState<CategoryKey>('all');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches,
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const list = await fetchAccounts();
|
||||
setAccounts(list);
|
||||
setError(null);
|
||||
} catch {
|
||||
nav('/login', { replace: true });
|
||||
}
|
||||
}, [nav]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => void load(), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
async function onLogout() {
|
||||
await logout();
|
||||
nav('/login', { replace: true });
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
try {
|
||||
const data = await exportAll();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `sprout2fa-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '导出失败');
|
||||
}
|
||||
}
|
||||
|
||||
function onImportPick() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/json,.json';
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const text = String(reader.result || '');
|
||||
const j = JSON.parse(text) as { items?: unknown };
|
||||
if (!Array.isArray(j.items)) {
|
||||
setError('文件格式无效:缺少 items 数组');
|
||||
return;
|
||||
}
|
||||
const r = await importItems(
|
||||
j.items as {
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
}[],
|
||||
);
|
||||
alert(`已导入 ${r.imported} 条`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '导入失败');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
const categories = useMemo(
|
||||
() => (accounts ? buildCategories(accounts) : []),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const visibleAccounts = useMemo(
|
||||
() => (accounts ? filterByCategory(accounts, category) : []),
|
||||
[accounts, category],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accounts) return;
|
||||
if (category === 'all') return;
|
||||
const stillValid = categories.some((c) => c.key === category);
|
||||
if (!stillValid) setCategory('all');
|
||||
}, [accounts, categories, category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [drawerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setDrawerOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [drawerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 640px)');
|
||||
const update = () => {
|
||||
setIsMobile(mq.matches);
|
||||
if (!mq.matches) setDrawerOpen(false);
|
||||
};
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
function selectCategory(key: CategoryKey) {
|
||||
setCategory(key);
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
if (accounts === null) {
|
||||
return (
|
||||
<main>
|
||||
<p className="sub">加载中…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const subText =
|
||||
category === 'all'
|
||||
? `${accounts.length} 个账户`
|
||||
: `${visibleAccounts.length} / ${accounts.length}`;
|
||||
|
||||
return (
|
||||
<main className={`home-page${drawerOpen ? ' drawer-open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="drawer-backdrop"
|
||||
aria-label="关闭分类菜单"
|
||||
tabIndex={drawerOpen ? 0 : -1}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
<div className="home-layout">
|
||||
<aside
|
||||
id="category-sidebar"
|
||||
className="category-sidebar"
|
||||
aria-label="分类筛选"
|
||||
aria-hidden={isMobile && !drawerOpen ? true : undefined}
|
||||
>
|
||||
<div className="category-sidebar-title">分类</div>
|
||||
<nav className="category-nav">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
className={`category-item${category === c.key ? ' active' : ''}`}
|
||||
onClick={() => selectCategory(c.key)}
|
||||
>
|
||||
<span className="category-item-label">{c.label}</span>
|
||||
<span className="category-item-count">{c.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="home-main">
|
||||
<div className="top-nav">
|
||||
<div className="top-nav-start">
|
||||
<button
|
||||
type="button"
|
||||
className="btn icon drawer-toggle"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="category-sidebar"
|
||||
aria-label={drawerOpen ? '关闭分类' : '打开分类'}
|
||||
onClick={() => setDrawerOpen((v) => !v)}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
<div className="top-nav-title">
|
||||
<img
|
||||
className="top-nav-logo"
|
||||
src="/logo.png"
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
/>
|
||||
<h1 style={{ margin: 0 }}>验证码</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn secondary" onClick={() => void onLogout()}>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
<p className="sub">{subText}</p>
|
||||
|
||||
<div className="row-actions">
|
||||
<Link className="btn" to="/add">
|
||||
添加
|
||||
</Link>
|
||||
<button type="button" className="btn secondary" onClick={() => void onExport()}>
|
||||
导出
|
||||
</button>
|
||||
<button type="button" className="btn secondary" onClick={onImportPick}>
|
||||
导入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<div className="card">
|
||||
<p>暂无条目,点击「添加」扫码或手动输入。</p>
|
||||
</div>
|
||||
) : visibleAccounts.length === 0 ? (
|
||||
<div className="card">
|
||||
<p>该分类下暂无账户。</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleAccounts.map((a) => (
|
||||
<CodeRow
|
||||
key={a.id}
|
||||
a={a}
|
||||
onDelete={async (id) => {
|
||||
try {
|
||||
await deleteAccount(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
frontend/src/pages/IndexPage.tsx
Normal file
11
frontend/src/pages/IndexPage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { HomePage } from './HomePage';
|
||||
import { SharePage } from './SharePage';
|
||||
|
||||
/** 有 ?token= 时走公开分享页,否则为需登录的首页 */
|
||||
export function IndexPage() {
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get('token')?.trim();
|
||||
if (token) return <SharePage token={token} />;
|
||||
return <HomePage />;
|
||||
}
|
||||
54
frontend/src/pages/LoginPage.tsx
Normal file
54
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { login } from '../api/client';
|
||||
|
||||
export function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(password);
|
||||
nav('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="brand">
|
||||
<img className="brand-logo" src="/logo.png" alt="" width={72} height={72} />
|
||||
<h1>Sprout2FA</h1>
|
||||
</div>
|
||||
<p className="sub">输入应用密码以继续。首次使用默认密码请在部署说明中查看。</p>
|
||||
<div className="card">
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="pw">密码</label>
|
||||
<input
|
||||
id="pw"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="应用密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
<button className="btn" type="submit" disabled={busy} style={{ width: '100%', marginTop: 8 }}>
|
||||
{busy ? '验证中…' : '进入'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
68
frontend/src/pages/SharePage.tsx
Normal file
68
frontend/src/pages/SharePage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { fetchSharedAccount, type SharedAccountDto } from '../api/client';
|
||||
import { TotpCard } from '../components/TotpCard';
|
||||
|
||||
export function SharePage({ token }: { token: string }) {
|
||||
const [account, setAccount] = useState<SharedAccountDto | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const a = await fetchSharedAccount(token);
|
||||
setAccount(a);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setAccount(null);
|
||||
setError(e instanceof Error ? e.message : '无法加载');
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => void load(), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (error && !account) {
|
||||
return (
|
||||
<main className="share-page">
|
||||
<div className="card">
|
||||
<p className="error">{error}</p>
|
||||
<p className="sub" style={{ marginBottom: 0 }}>
|
||||
分享链接无效或已被撤销,请联系分享者获取新链接。
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<main className="share-page">
|
||||
<p className="sub">加载中…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="share-page">
|
||||
<div className="top-nav">
|
||||
<div className="top-nav-title">
|
||||
<img className="top-nav-logo" src="/logo.png" alt="" width={36} height={36} />
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>验证码</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TotpCard
|
||||
label={account.label}
|
||||
issuer={account.issuer}
|
||||
code={account.code}
|
||||
expiresInSeconds={account.expiresInSeconds}
|
||||
period={account.period}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
579
frontend/src/styles/global.css
Normal file
579
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,579 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #ecfdf5;
|
||||
--surface: #ffffff;
|
||||
--text: #14532d;
|
||||
--muted: #4d7c59;
|
||||
--accent: #166534;
|
||||
--accent2: #22c55e;
|
||||
--danger: #b91c1c;
|
||||
--radius: 12px;
|
||||
--shadow: 0 8px 24px rgba(20, 83, 45, 0.12);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'PingFang SC',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: max(16px, env(safe-area-inset-top))
|
||||
max(16px, env(safe-area-inset-right))
|
||||
max(24px, env(safe-area-inset-bottom))
|
||||
max(16px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.app-shell:has(.home-page) {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.home-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.home-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 148px;
|
||||
position: sticky;
|
||||
top: max(16px, env(safe-area-inset-top));
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.category-sidebar-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
padding: 0 8px 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.category-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.category-item.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category-item.active .category-item-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.category-item-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-item-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 99px;
|
||||
background: #dcfce7;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-nav-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn.icon {
|
||||
padding: 8px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.btn.icon.secondary {
|
||||
background: #e2e8f0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell:has(.home-page) {
|
||||
max-width: 520px;
|
||||
padding: max(8px, env(safe-area-inset-top))
|
||||
max(12px, env(safe-area-inset-right))
|
||||
max(16px, env(safe-area-inset-bottom))
|
||||
max(12px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.drawer-toggle {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-layout {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: rgba(20, 83, 45, 0.4);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease, visibility 0.22s ease;
|
||||
}
|
||||
|
||||
.home-page.drawer-open .drawer-backdrop {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.category-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
width: min(272px, 82vw);
|
||||
margin: 0;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: max(16px, env(safe-area-inset-top)) 12px
|
||||
max(16px, env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
transform: translateX(-105%);
|
||||
transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 4px 0 24px rgba(20, 83, 45, 0.18);
|
||||
}
|
||||
|
||||
.home-page.drawer-open .category-sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.category-sidebar-title {
|
||||
font-size: 0.85rem;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.home-main .top-nav {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.home-main .top-nav-title h1 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.home-main .top-nav-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.home-main .sub {
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.home-main .row-actions {
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.home-main .row-actions .btn {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.home-main .card {
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.home-main .totp-code {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: #e2e8f0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent2);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.totp-card {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.totp-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.totp-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.totp-card-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.totp-card-issuer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
background: #ecfdf5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.icon-btn.danger {
|
||||
background: #fee2e2;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.icon-btn.copied {
|
||||
background: #dcfce7;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.totp-code-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.totp-code-hit {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.totp-code-hit:hover .totp-code {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.totp-code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 1.85rem;
|
||||
letter-spacing: 0.16em;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.totp-card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.totp-card-foot .progress {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.totp-card-timer {
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
min-width: 2.2em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 5px;
|
||||
background: #dcfce7;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent2);
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.top-nav-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.top-nav-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.segment button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segment button.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
video.scan {
|
||||
width: 100%;
|
||||
border-radius: var(--radius);
|
||||
background: #000;
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.scan-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.share-page .app-shell,
|
||||
.share-page {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
56
frontend/vite.config.ts
Normal file
56
frontend/vite.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
// 仅在使用 `cd frontend && npm run dev` 时需要:另开终端运行 `wrangler dev`,API 会转到 8787
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8787',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
manifest: {
|
||||
name: 'Sprout2FA',
|
||||
short_name: 'Sprout2FA',
|
||||
description: '网页版 2FA / TOTP 管理器',
|
||||
theme_color: '#14532d',
|
||||
background_color: '#ecfdf5',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/logo.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// logo.png 可能较大,不加入 SW 预缓存,避免超过默认 2MiB 限制;页面仍正常引用 /logo.png
|
||||
globPatterns: ['**/*.{js,css,html,svg,ico,woff2}'],
|
||||
globIgnores: ['**/logo.png'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }: { url: URL }) => url.pathname.startsWith('/api'),
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user