549 lines
22 KiB
TypeScript
549 lines
22 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useTheme } from '../../hooks/useTheme';
|
||
import BackupManager from '../../components/settings/BackupManager';
|
||
import AppLockSettings from '../../components/settings/AppLockSettings';
|
||
import ChangePassword from '../../components/settings/ChangePassword';
|
||
import { CapsuleSelector } from '../../components/common/CapsuleSelector/CapsuleSelector';
|
||
import { CustomSelect } from '../../components/common/CustomSelect/CustomSelect';
|
||
import { getSupportedCurrencies } from '../../services/exchangeRateService';
|
||
import { updateSettings, updateDefaultAccounts, getSettingsWithDefaults } from '../../services/settingsService';
|
||
import { getAccounts } from '../../services/accountService';
|
||
import type { UserSettings, UserSettingsWithDefaults, Account } from '../../types';
|
||
import { Icon } from '@iconify/react';
|
||
import { ThemePicker } from '../../components/settings/ThemePicker/ThemePicker';
|
||
import './Settings.css';
|
||
|
||
type SettingsTab = 'general' | 'security' | 'data' | 'about';
|
||
|
||
interface GeneralSettings {
|
||
defaultCurrency: string;
|
||
language: string;
|
||
theme: 'light' | 'dark' | 'system';
|
||
accentColor: string;
|
||
dateFormat: string;
|
||
firstDayOfWeek: number;
|
||
}
|
||
|
||
/**
|
||
* Settings Page Component
|
||
* Application settings and preferences
|
||
*/
|
||
function Settings() {
|
||
const { themeMode, setThemeMode } = useTheme();
|
||
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||
const [settings, setSettings] = useState<GeneralSettings>({
|
||
defaultCurrency: localStorage.getItem('defaultCurrency') || 'CNY',
|
||
language: localStorage.getItem('language') || 'zh-CN',
|
||
theme: 'system', // Placeholder, overridden by themeMode
|
||
accentColor: localStorage.getItem('accentColor') || 'amber',
|
||
dateFormat: localStorage.getItem('dateFormat') || 'YYYY-MM-DD',
|
||
firstDayOfWeek: parseInt(localStorage.getItem('firstDayOfWeek') || '1'),
|
||
});
|
||
const [userSettings, setUserSettings] = useState<UserSettingsWithDefaults | null>(null);
|
||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||
const [saveMessage, setSaveMessage] = useState<string>('');
|
||
|
||
const currencies = getSupportedCurrencies();
|
||
|
||
useEffect(() => {
|
||
// Load user settings
|
||
loadUserSettings();
|
||
loadAccounts();
|
||
// Sync settings state with global theme mode
|
||
setSettings(prev => ({ ...prev, theme: themeMode }));
|
||
}, [themeMode]);
|
||
|
||
const loadUserSettings = async () => {
|
||
try {
|
||
const data = await getSettingsWithDefaults();
|
||
setUserSettings(data);
|
||
} catch (err) {
|
||
console.error('Failed to load user settings:', err);
|
||
}
|
||
};
|
||
|
||
const loadAccounts = async () => {
|
||
try {
|
||
const data = await getAccounts();
|
||
setAccounts(data);
|
||
} catch (err) {
|
||
console.error('Failed to load accounts:', err);
|
||
}
|
||
};
|
||
|
||
const handleUserSettingChange = async (key: keyof UserSettings, value: any) => {
|
||
if (!userSettings) return;
|
||
|
||
try {
|
||
const newSettings = { ...userSettings, [key]: value };
|
||
await updateSettings(newSettings);
|
||
setUserSettings(newSettings);
|
||
setSaveMessage('设置已保存');
|
||
setTimeout(() => setSaveMessage(''), 2000);
|
||
} catch (err) {
|
||
console.error('Failed to update settings:', err);
|
||
setSaveMessage('保存失败');
|
||
setTimeout(() => setSaveMessage(''), 2000);
|
||
}
|
||
};
|
||
|
||
const handleDefaultAccountChange = async (key: 'defaultExpenseAccountId' | 'defaultIncomeAccountId', value: number) => {
|
||
if (!userSettings) return;
|
||
|
||
try {
|
||
const updateData = {
|
||
[key]: value
|
||
};
|
||
await updateDefaultAccounts(updateData);
|
||
|
||
// Update local state
|
||
let updatedSettings = { ...userSettings };
|
||
if (key === 'defaultExpenseAccountId') {
|
||
updatedSettings.defaultExpenseAccountId = value;
|
||
// Update the related object for immediate UI feedback if needed
|
||
const account = accounts.find(a => a.id === value);
|
||
if (account) updatedSettings.defaultExpenseAccount = account;
|
||
} else {
|
||
updatedSettings.defaultIncomeAccountId = value;
|
||
const account = accounts.find(a => a.id === value);
|
||
if (account) updatedSettings.defaultIncomeAccount = account;
|
||
}
|
||
|
||
setUserSettings(updatedSettings);
|
||
setSaveMessage('默认账户已更新');
|
||
setTimeout(() => setSaveMessage(''), 2000);
|
||
} catch (err) {
|
||
console.error('Failed to update default account:', err);
|
||
setSaveMessage('更新失败');
|
||
setTimeout(() => setSaveMessage(''), 2000);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
const handleSettingChange = (key: keyof GeneralSettings, value: string | number) => {
|
||
const newSettings = { ...settings, [key]: value };
|
||
setSettings(newSettings);
|
||
localStorage.setItem(key, value.toString());
|
||
|
||
if (key === 'theme') {
|
||
setThemeMode(value as any);
|
||
}
|
||
|
||
setSaveMessage('设置已保存');
|
||
setTimeout(() => setSaveMessage(''), 2000);
|
||
};
|
||
|
||
const handleClearCache = () => {
|
||
if (window.confirm('确定要清除所有缓存数据吗?这不会删除您的账户数据。')) {
|
||
// Clear all cache except auth tokens
|
||
const keysToKeep = ['authToken', 'refreshToken'];
|
||
const allKeys = Object.keys(localStorage);
|
||
allKeys.forEach(key => {
|
||
if (!keysToKeep.includes(key)) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
});
|
||
alert('缓存已清除');
|
||
window.location.reload();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="settings-page">
|
||
<header className="settings-header">
|
||
<h1>设置</h1>
|
||
<p className="settings-subtitle">管理您的应用偏好和数据</p>
|
||
</header>
|
||
|
||
<nav className="settings-tabs">
|
||
<button
|
||
className={`tab-button ${activeTab === 'general' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('general')}
|
||
>
|
||
<span className="tab-icon"><Icon icon="solar:settings-bold-duotone" width="20" /></span>
|
||
<span className="tab-label">通用</span>
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'security' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('security')}
|
||
>
|
||
<span className="tab-icon"><Icon icon="solar:shield-keyhole-bold-duotone" width="20" /></span>
|
||
<span className="tab-label">安全</span>
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'data' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('data')}
|
||
>
|
||
<span className="tab-icon"><Icon icon="solar:database-bold-duotone" width="20" /></span>
|
||
<span className="tab-label">数据</span>
|
||
</button>
|
||
<button
|
||
className={`tab-button ${activeTab === 'about' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('about')}
|
||
>
|
||
<span className="tab-icon"><Icon icon="solar:info-circle-bold-duotone" width="20" /></span>
|
||
<span className="tab-label">关于</span>
|
||
</button>
|
||
</nav>
|
||
|
||
{saveMessage && (
|
||
<div className="save-message">
|
||
✓ {saveMessage}
|
||
</div>
|
||
)}
|
||
|
||
<main className="settings-content">
|
||
{activeTab === 'general' && (
|
||
<section className="general-settings">
|
||
<div className="settings-section">
|
||
<h2>通用设置</h2>
|
||
|
||
<div className="settings-group">
|
||
<h3>显示偏好</h3>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">默认货币</label>
|
||
<p className="settings-item__description">用于显示金额的默认货币</p>
|
||
</div>
|
||
<CustomSelect
|
||
className="settings-custom-select"
|
||
value={settings.defaultCurrency}
|
||
onChange={(value) => handleSettingChange('defaultCurrency', value)}
|
||
options={currencies.map(currency => ({
|
||
value: currency.code,
|
||
label: `${currency.symbol} ${currency.name} (${currency.code})`
|
||
}))}
|
||
/>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">主题模式</label>
|
||
<p className="settings-item__description">选择应用的外观模式</p>
|
||
</div>
|
||
<CustomSelect
|
||
className="settings-custom-select"
|
||
value={themeMode}
|
||
onChange={(value) => handleSettingChange('theme', value)}
|
||
options={[
|
||
{ value: 'system', label: '跟随系统' },
|
||
{ value: 'light', label: '浅色' },
|
||
{ value: 'dark', label: '深色' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">主题色</label>
|
||
<p className="settings-item__description">自定义应用的强调颜色</p>
|
||
</div>
|
||
<div className="theme-color-selector">
|
||
<ThemePicker />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">语言</label>
|
||
<p className="settings-item__description">应用界面语言</p>
|
||
</div>
|
||
<CustomSelect
|
||
className="settings-custom-select"
|
||
value={settings.language}
|
||
onChange={(value) => handleSettingChange('language', value)}
|
||
options={[
|
||
{ value: 'zh-CN', label: '简体中文' },
|
||
{ value: 'zh-TW', label: '繁體中文' },
|
||
{ value: 'en-US', label: 'English' },
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-group">
|
||
<h3>日期和时间</h3>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">日期格式</label>
|
||
<p className="settings-item__description">日期的显示格式</p>
|
||
</div>
|
||
<CustomSelect
|
||
className="settings-custom-select"
|
||
value={settings.dateFormat}
|
||
onChange={(value) => handleSettingChange('dateFormat', value)}
|
||
options={[
|
||
{ value: 'YYYY-MM-DD', label: '2024-01-22' },
|
||
{ value: 'DD/MM/YYYY', label: '22/01/2024' },
|
||
{ value: 'MM/DD/YYYY', label: '01/22/2024' },
|
||
{ value: 'YYYY年MM月DD日', label: '2024年01月22日' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">每周第一天</label>
|
||
<p className="settings-item__description">日历和报表的每周起始日</p>
|
||
</div>
|
||
<CustomSelect
|
||
className="settings-custom-select"
|
||
value={settings.firstDayOfWeek}
|
||
onChange={(value) => handleSettingChange('firstDayOfWeek', value)}
|
||
options={[
|
||
{ value: 0, label: '星期日' },
|
||
{ value: 1, label: '星期一' },
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-group">
|
||
<h3>缓存管理</h3>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">清除缓存</label>
|
||
<p className="settings-item__description">清除应用缓存数据,不会删除账户数据</p>
|
||
</div>
|
||
<button onClick={handleClearCache} className="settings-btn settings-btn-secondary">
|
||
清除缓存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Requirements 5.4, 6.5, 8.25-8.27 */}
|
||
<div className="settings-group">
|
||
<h3>记账设置</h3>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">默认支出账户</label>
|
||
<p className="settings-item__description">AI 记账和快捷记账时的默认扣款账户</p>
|
||
</div>
|
||
<CustomSelect
|
||
className="settings-custom-select"
|
||
value={userSettings?.defaultExpenseAccountId || ''}
|
||
onChange={(value) => handleDefaultAccountChange('defaultExpenseAccountId', Number(value))}
|
||
options={[
|
||
{ value: '', label: '不设置' },
|
||
...accounts
|
||
.filter(a => a.type !== 'credit_card' && a.type !== 'credit_line' ? true : true) // Show all for now, maybe filter logic later
|
||
.map(account => ({
|
||
value: account.id,
|
||
label: account.name
|
||
}))
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">默认收入账户</label>
|
||
<p className="settings-item__description">记录收入时的默认入账账户</p>
|
||
</div>
|
||
<CustomSelect
|
||
className="settings-custom-select"
|
||
value={userSettings?.defaultIncomeAccountId || ''}
|
||
onChange={(value) => handleDefaultAccountChange('defaultIncomeAccountId', Number(value))}
|
||
options={[
|
||
{ value: '', label: '不设置' },
|
||
...accounts
|
||
//.filter(a => a.accountType === 'asset') // Typically income goes to assets
|
||
.map(account => ({
|
||
value: account.id,
|
||
label: account.name
|
||
}))
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">精确记账时间</label>
|
||
<p className="settings-item__description">启用后可记录精确到分钟的交易时间</p>
|
||
</div>
|
||
<label className="settings-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={userSettings?.preciseTimeEnabled ?? true}
|
||
onChange={(e) => handleUserSettingChange('preciseTimeEnabled', e.target.checked)}
|
||
/>
|
||
<span className="settings-toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">图标布局</label>
|
||
<p className="settings-item__description">分类图标选择器的列数</p>
|
||
</div>
|
||
<CapsuleSelector
|
||
options={[
|
||
{ value: 'four', label: '四列' },
|
||
{ value: 'five', label: '五列' },
|
||
{ value: 'six', label: '六列' },
|
||
]}
|
||
value={userSettings?.iconLayout || 'five'}
|
||
onChange={(value) => handleUserSettingChange('iconLayout', value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">图片压缩</label>
|
||
<p className="settings-item__description">上传图片时的压缩质量</p>
|
||
</div>
|
||
<CapsuleSelector
|
||
options={[
|
||
{ value: 'low', label: '标清' },
|
||
{ value: 'medium', label: '高清' },
|
||
{ value: 'high', label: '原画' },
|
||
]}
|
||
value={userSettings?.imageCompression || 'medium'}
|
||
onChange={(value) => handleUserSettingChange('imageCompression', value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">显示报销按钮</label>
|
||
<p className="settings-item__description">在交易表单中显示报销标记按钮</p>
|
||
</div>
|
||
<label className="settings-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={userSettings?.showReimbursementBtn ?? true}
|
||
onChange={(e) => handleUserSettingChange('showReimbursementBtn', e.target.checked)}
|
||
/>
|
||
<span className="settings-toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">显示退款按钮</label>
|
||
<p className="settings-item__description">在交易表单中显示退款标记按钮</p>
|
||
</div>
|
||
<label className="settings-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={userSettings?.showRefundBtn ?? true}
|
||
onChange={(e) => handleUserSettingChange('showRefundBtn', e.target.checked)}
|
||
/>
|
||
<span className="settings-toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="settings-item">
|
||
<div className="settings-item-info">
|
||
<label className="settings-item__label">手机号码</label>
|
||
<p className="settings-item__description">用于接收短信通知(自动记账等)</p>
|
||
</div>
|
||
<div className="settings-input-container">
|
||
<input
|
||
type="tel"
|
||
className="settings-text-input"
|
||
placeholder="请输入手机号"
|
||
value={userSettings?.phone || ''}
|
||
onChange={(e) => handleUserSettingChange('phone', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'security' && (
|
||
<section className="security-settings">
|
||
<ChangePassword />
|
||
<div style={{ marginTop: '2rem' }}></div>
|
||
<AppLockSettings />
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'data' && (
|
||
<section className="data-settings">
|
||
<BackupManager />
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'about' && (
|
||
<section className="about-settings">
|
||
<div className="settings-section">
|
||
<h2>关于应用</h2>
|
||
|
||
<div className="about-content">
|
||
<div className="app-logo">
|
||
<div className="logo-icon">
|
||
<Icon icon="solar:wallet-money-bold-duotone" width="48" className="text-primary" />
|
||
</div>
|
||
<h3>记账应用</h3>
|
||
<p className="version">版本 1.0.0</p>
|
||
</div>
|
||
|
||
<div className="about-info">
|
||
<h4>功能特性</h4>
|
||
<ul className="feature-list">
|
||
<li>✓ 多账户管理</li>
|
||
<li>✓ 收支记录</li>
|
||
<li>✓ 预算管理</li>
|
||
<li>✓ 储蓄目标</li>
|
||
<li>✓ 循环交易</li>
|
||
<li>✓ 数据报表</li>
|
||
<li>✓ 37种货币支持</li>
|
||
<li>✓ 数据备份与恢复</li>
|
||
<li>✓ 应用锁保护</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="about-info">
|
||
<h4>技术栈</h4>
|
||
<div className="tech-stack">
|
||
<span className="tech-badge">React</span>
|
||
<span className="tech-badge">TypeScript</span>
|
||
<span className="tech-badge">Go</span>
|
||
<span className="tech-badge">PostgreSQL</span>
|
||
<span className="tech-badge">Redis</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="about-info">
|
||
<h4>系统信息</h4>
|
||
<dl className="system-info">
|
||
<dt>浏览器:</dt>
|
||
<dd>{navigator.userAgent.split(' ').slice(-2).join(' ')}</dd>
|
||
<dt>平台:</dt>
|
||
<dd>{navigator.platform}</dd>
|
||
<dt>语言:</dt>
|
||
<dd>{navigator.language}</dd>
|
||
</dl>
|
||
</div>
|
||
|
||
<div className="about-footer">
|
||
<p>© 2024 记账应用. All rights reserved.</p>
|
||
<div className="about-links">
|
||
<a href="#" onClick={(e) => e.preventDefault()}>使用条款</a>
|
||
<span>·</span>
|
||
<a href="#" onClick={(e) => e.preventDefault()}>隐私政策</a>
|
||
<span>·</span>
|
||
<a href="#" onClick={(e) => e.preventDefault()}>帮助中心</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Settings;
|