Initial commit: accountbook project
- Node.js backend server - Frontend application - Backup script - Project specification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Icon } from './icons';
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: '首页', icon: 'home' },
|
||||
{ href: '/stats', label: '统计', icon: 'chart' },
|
||||
{ href: '/recurring', label: '周期', icon: 'repeat' },
|
||||
{ href: '/categories', label: '分类', icon: 'tags' },
|
||||
];
|
||||
|
||||
export default function AppShell({ title, subtitle, actions, headerContent, children, contentClassName = '', hideNav = false, compactHeader = false }) {
|
||||
const router = useRouter();
|
||||
|
||||
const activeTab = useMemo(() => {
|
||||
const matched = tabs.find((tab) => tab.href === router.pathname);
|
||||
return matched?.href || null;
|
||||
}, [router.pathname]);
|
||||
|
||||
const headerNode = headerContent || (title ? (
|
||||
<div className="app-header__text">
|
||||
<h1>{title}</h1>
|
||||
{subtitle ? <p>{subtitle}</p> : null}
|
||||
</div>
|
||||
) : null);
|
||||
|
||||
const headerClassName = [
|
||||
'app-header',
|
||||
compactHeader ? 'app-header--compact' : '',
|
||||
headerNode ? 'app-header--with-content' : 'app-header--actions-only',
|
||||
actions ? 'app-header--with-actions' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={`app-shell${hideNav ? ' app-shell--standalone' : ''}`}>
|
||||
<header className={headerClassName}>
|
||||
<div className="app-shell__container app-header__inner">
|
||||
{headerNode ? <div className="app-header__main">{headerNode}</div> : <div className="app-header__spacer" aria-hidden="true" />}
|
||||
{actions ? <div className="app-header__actions">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={`app-content ${contentClassName}`.trim()}>
|
||||
<div className="app-shell__container app-content__inner">{children}</div>
|
||||
</main>
|
||||
|
||||
{!hideNav ? (
|
||||
<nav className="bottom-nav" aria-label="主导航">
|
||||
<div className="app-shell__container bottom-nav__inner">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.href;
|
||||
return (
|
||||
<button
|
||||
key={tab.href}
|
||||
type="button"
|
||||
className={`bottom-nav__item${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => router.push(tab.href)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<Icon name={tab.icon} size={20} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
|
||||
const ConfirmContext = createContext(null);
|
||||
|
||||
export function ConfirmProvider({ children }) {
|
||||
const [dialog, setDialog] = useState(null);
|
||||
|
||||
const confirm = useCallback((options) => {
|
||||
return new Promise((resolve) => {
|
||||
setDialog({
|
||||
title: options.title || '请确认操作',
|
||||
description: options.description || '',
|
||||
confirmText: options.confirmText || '确认',
|
||||
cancelText: options.cancelText || '取消',
|
||||
tone: options.tone || 'danger',
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback((result) => {
|
||||
setDialog((current) => {
|
||||
current?.resolve(result);
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
{dialog ? (
|
||||
<div className="modal-overlay modal-overlay--center" onClick={() => close(false)}>
|
||||
<div className="confirm-card" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||
<div className={`confirm-card__icon confirm-card__icon--${dialog.tone}`}>
|
||||
<Icon name={dialog.tone === 'danger' ? 'trash' : 'info'} size={18} />
|
||||
</div>
|
||||
<div className="confirm-card__body">
|
||||
<h2 id="confirm-title">{dialog.title}</h2>
|
||||
{dialog.description ? <p>{dialog.description}</p> : null}
|
||||
</div>
|
||||
<div className="confirm-card__actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => close(false)}>{dialog.cancelText}</button>
|
||||
<button type="button" className={`btn ${dialog.tone === 'danger' ? 'btn-danger' : 'btn-primary'}`} onClick={() => close(true)}>{dialog.confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const context = useContext(ConfirmContext);
|
||||
if (!context) {
|
||||
throw new Error('useConfirm must be used within ConfirmProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const timers = useRef(new Map());
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((message, options = {}) => {
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
tone: options.tone || 'info',
|
||||
};
|
||||
|
||||
setToasts((current) => [...current, toast]);
|
||||
const duration = options.duration ?? 2600;
|
||||
const timer = setTimeout(() => removeToast(id), duration);
|
||||
timers.current.set(id, timer);
|
||||
return id;
|
||||
}, [removeToast]);
|
||||
|
||||
const value = useMemo(() => ({ showToast, removeToast }), [showToast, removeToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="toast-stack" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast--${toast.tone}`}>
|
||||
<span className="toast__icon">
|
||||
<Icon name={toast.tone === 'success' ? 'check' : toast.tone === 'error' ? 'close' : 'info'} size={16} />
|
||||
</span>
|
||||
<span className="toast__message">{toast.message}</span>
|
||||
<button type="button" className="icon-button icon-button--ghost toast__close" aria-label="关闭提示" onClick={() => removeToast(toast.id)}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
const icons = {
|
||||
home: (
|
||||
<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5.5v-6h-5v6H4a1 1 0 0 1-1-1v-9.5Z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
),
|
||||
chart: (
|
||||
<>
|
||||
<path d="M4 20h16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M7 16v-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M12 16V8" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M17 16v-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
repeat: (
|
||||
<>
|
||||
<path d="M17 3l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M3 7h18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M7 21l-4-4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 17H3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
tags: (
|
||||
<>
|
||||
<path d="M11 4H5a2 2 0 0 0-2 2v6l8.5 8.5a2.1 2.1 0 0 0 3 0l6-6a2.1 2.1 0 0 0 0-3L12 4Z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<circle cx="7.5" cy="8.5" r="1.2" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<path d="M12 8.5A3.5 3.5 0 1 0 12 15.5A3.5 3.5 0 1 0 12 8.5Z" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M19.4 15a1 1 0 0 0 .2 1.1l.1.1a2 2 0 0 1-2.8 2.8l-.1-.1a1 1 0 0 0-1.1-.2 1 1 0 0 0-.6.9V20a2 2 0 0 1-4 0v-.2a1 1 0 0 0-.6-.9 1 1 0 0 0-1.1.2l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1 1 0 0 0 .2-1.1 1 1 0 0 0-.9-.6H4a2 2 0 0 1 0-4h.2a1 1 0 0 0 .9-.6 1 1 0 0 0-.2-1.1l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1 1 0 0 0 1.1.2 1 1 0 0 0 .6-.9V4a2 2 0 0 1 4 0v.2a1 1 0 0 0 .6.9 1 1 0 0 0 1.1-.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1 1 0 0 0-.2 1.1 1 1 0 0 0 .9.6H20a2 2 0 0 1 0 4h-.2a1 1 0 0 0-.4 1Z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="m16 16 4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
plus: (
|
||||
<>
|
||||
<path d="M12 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M5 12h14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
chevronLeft: (
|
||||
<path d="m14.5 6-5 6 5 6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
),
|
||||
chevronRight: (
|
||||
<path d="m9.5 6 5 6-5 6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
),
|
||||
close: (
|
||||
<>
|
||||
<path d="M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M18 6 6 18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
trash: (
|
||||
<>
|
||||
<path d="M4 7h16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M9 4h6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M7 7l1 12a1 1 0 0 0 1 .9h6a1 1 0 0 0 1-.9l1-12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M10 11v5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M14 11v5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
logout: (
|
||||
<>
|
||||
<path d="M10 5H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14 8l5 4-5 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19 12H9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
download: (
|
||||
<>
|
||||
<path d="M12 4v10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="m8 10 4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 19h14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
upload: (
|
||||
<>
|
||||
<path d="M12 20V10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="m8 14 4-4 4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 5h14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
check: (
|
||||
<path d="m5 12 4.2 4.2L19 7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
),
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M12 10v5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="12" cy="7.25" r="1" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
spinner: (
|
||||
<>
|
||||
<path d="M12 3a9 9 0 1 1-6.36 2.64" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" opacity="0.28" />
|
||||
<path d="M5.64 5.64A9 9 0 0 1 12 3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 20, className = '', title }) {
|
||||
return createElement(
|
||||
'svg',
|
||||
{
|
||||
viewBox: '0 0 24 24',
|
||||
width: size,
|
||||
height: size,
|
||||
className,
|
||||
'aria-hidden': title ? undefined : 'true',
|
||||
role: title ? 'img' : 'presentation',
|
||||
fill: 'none',
|
||||
},
|
||||
title ? createElement('title', null, title) : null,
|
||||
icons[name] || icons.info
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user