feat: add reusable private and shared account icons

This commit is contained in:
陈煜 committed 2026-10-01 18:55:15 +08:00
1 parent d2b2681698
commit a4e9d56b8a
44 files changed
+1443 -20

No files matched your search

+24 -4
View File
@@ -33,6 +33,7 @@ import {
type Total,
} from './api';
import './style.css';
import { IconLibrary, iconUrl } from './IconLibrary';
const labels: Record<string, string> = {
overview: '资产总览',
account: '账户',
@@ -606,7 +607,12 @@ export default function App() {
<div className="page-heading">
<div>
<p className="eyebrow">WORTHPATH / {today()}</p>
<h1>{p ? p.name : labels[page]}</h1>
<h1>
{p?.iconId && (
<img className="detail-icon" src={iconUrl(p.iconId)} alt="账户图标" />
)}
{p ? p.name : labels[page]}
</h1>
<p className="muted">
{page === 'overview'
? '你的财富全貌,从这里开始。'
@@ -889,7 +895,9 @@ export default function App() {
>
<div>
<span className="position-icon">
{p.kind === 'asset' ? (
{p.iconId ? (
<img src={iconUrl(p.iconId)} alt="账户图标" />
) : p.kind === 'asset' ? (
<Landmark />
) : p.kind === 'debt' ? (
<HandCoins />
@@ -943,6 +951,9 @@ export default function App() {
)}
{page === 'settings' && (
<>
<section className="panel">
<IconLibrary />
</section>
<div className="settings-grid">
<section className="panel">
<div className="panel-title">
@@ -1079,7 +1090,7 @@ export default function App() {
<section className="panel">
<h2>清空本账号数据</h2>
<p className="muted">
清除本账号全部账户、资产、债务、历史和汇率(包括隐藏项目);保留登录账号及个人设置。请先下载备份并确认文件已保存。
清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。
</p>
{clearStep === 0 ? (
<a
@@ -1138,7 +1149,7 @@ export default function App() {
<h2>数据备份与恢复</h2>
<p className="muted">
ZIP 内分文件保存可读
JSON,包括全部账户、资产、债务、历史、关联、币种、设置和汇率,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率及图标,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
</p>
<div className="actions">
<a
@@ -1289,6 +1300,7 @@ export default function App() {
() =>
api('/positions/' + mp!.id, 'PATCH', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
}),
@@ -1305,6 +1317,7 @@ export default function App() {
() =>
api('/positions', 'POST', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
kind,
side,
hidden: f.get('hidden') === 'on',
@@ -1331,6 +1344,13 @@ export default function App() {
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
隐藏此项目(密码验证后可查看和编辑)
</label>
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
<IconLibrary
key={modal.p?.id || 'new-account'}
picker
initialId={modal.p?.iconId}
/>
)}
<Field label="名称">
<input
name="name"
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useState, useRef } from 'react';
import { api } from './api';
type Icon = { id: string; name: string; shared: boolean; source: string | null };
export const iconUrl = (id: string) => '/api/icons/' + encodeURIComponent(id) + '/image';
export function IconLibrary({
initialId,
picker = false,
}: {
initialId?: string | null;
picker?: boolean;
}) {
const [selected, select] = useState(initialId || ''),
[q, search] = useState(''),
[page, setPage] = useState(1),
[items, setItems] = useState<Icon[]>([]),
[total, setTotal] = useState(0),
[loading, setLoading] = useState(false),
[name, setName] = useState(''),
[shared, setShared] = useState(false),
[confirmed, setConfirmed] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[notice, setNotice] = useState(''),
[refresh, setRefresh] = useState(0);
const file = useRef<HTMLInputElement>(null);
useEffect(() => {
let active = true;
setLoading(true);
const timer = setTimeout(() => {
void api<{ items: Icon[]; total: number }>(
'/icons?q=' + encodeURIComponent(q) + '&page=' + page,
)
.then((v) => {
if (active) {
setItems(v.items);
setTotal(v.total);
}
})
.catch((e) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [q, page, refresh]);
async function upload() {
const image = file.current?.files?.[0];
setError('');
setNotice('');
if (!image) {
setError('请选择图片');
return;
}
if (!name.trim() || (shared && (!/\p{Script=Han}/u.test(name) || !confirmed))) {
setError('请填写名称;共享图标须含中文并确认公开');
return;
}
if (image.size > 2 * 1024 * 1024) {
setError('图片不能超过 2 MB');
return;
}
setBusy(true);
try {
const form = new FormData();
form.append('file', image);
form.append('name', name);
form.append('shared', String(shared));
if (shared) form.append('confirmed', 'true');
const res = await fetch('/api/icons/upload', {
method: 'POST',
body: form,
credentials: 'same-origin',
});
const v = await res.json();
if (!res.ok) throw Error(v.message || '上传失败');
if (picker) select(v.id);
setNotice(
shared ? '图标已发布,所有登录用户均可搜索和使用' : '图标已存入我的图标,仅自己可见',
);
setName('');
setConfirmed(false);
if (file.current) file.current.value = '';
search('');
setPage(1);
setRefresh((n) => n + 1);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section
className="icon-library"
aria-label="账户图标库"
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
e.target instanceof HTMLInputElement &&
e.target.type !== 'checkbox'
)
e.preventDefault();
}}
>
<h2>{picker ? '选择账户图标' : '图标库'}</h2>
<p className="muted">
搜索共享图标和我的图标,选择后可在多个账户复用。直接上传默认仅自己可见。
</p>
{picker && (
<div className="icon-selection">
<input type="hidden" name="iconId" value={selected} />
{selected ? (
<>
<img src={iconUrl(selected)} alt="当前账户图标" />
<span>已选择图标</span>
<button type="button" className="text" onClick={() => select('')}>
移除图标
</button>
</>
) : (
<span>使用默认账户图标</span>
)}
</div>
)}
<label>
搜索图标名称
<input
type="search"
value={q}
placeholder="例如:工商银行、支付宝"
onChange={(e) => {
search(e.target.value);
setPage(1);
}}
maxLength={100}
/>
</label>
{loading ? (
<p role="status">正在加载图标…</p>
) : (
<>
<div className="icon-grid">
{items.map((i) =>
picker ? (
<button
type="button"
className={'icon-option' + (selected === i.id ? ' selected' : '')}
key={i.id}
aria-pressed={picker ? selected === i.id : undefined}
onClick={() => select(i.id)}
>
<img src={iconUrl(i.id)} alt="" loading="lazy" />
<span>{i.name}</span>
<small>{i.shared ? '共享' : '仅自己'}</small>
</button>
) : (
<div className="icon-option" key={i.id}>
<img src={iconUrl(i.id)} alt="" loading="lazy" />
<span>{i.name}</span>
<small>{i.shared ? '共享' : '仅自己'}</small>
</div>
),
)}
</div>
{!items.length && <p>没有匹配图标,可以在下方上传。</p>}
<div className="icon-pagination">
<span>共 {total} 个图标</span>
<button
className="secondary"
type="button"
disabled={page === 1}
onClick={() => setPage((n) => n - 1)}
>
上一页
</button>
<button
className="secondary"
type="button"
disabled={page * 60 >= total}
onClick={() => setPage((n) => n + 1)}
>
下一页
</button>
</div>
</>
)}
<details className="icon-upload">
<summary>上传我的图标 / 导入共享图标</summary>
<label>
图标名称(共享时必须含中文)
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={100} />
</label>
<label>
图片文件(PNG、JPG、WebP,最大 2 MB)
<input ref={file} type="file" accept="image/png,image/jpeg,image/webp" />
</label>
<label className="check-line">
<input
type="checkbox"
checked={shared}
onChange={(e) => {
setShared(e.target.checked);
setConfirmed(false);
}}
/>
发布到共享图标库
</label>
{shared && (
<label className="check-line">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
确认此图片可公开,所有用户均可搜索并复用;已发布图标会保留供他人使用
</label>
)}
<button className="primary" type="button" disabled={busy} onClick={() => void upload()}>
{busy ? '正在上传…' : shared ? '导入共享图标' : '上传私有图标'}
</button>
</details>
{error && (
<p role="alert" className="icon-error">
{error}
</p>
)}
{notice && <p role="status">{notice}</p>}
</section>
);
}
+1
View File
@@ -43,6 +43,7 @@ export type History = {
reason: string;
};
export type Position = {
iconId?: string | null;
id: string;
kind: string;
side: string;
+95
View File
@@ -901,3 +901,98 @@ footer {
font-size: 11px;
}
}
.position-icon img {
width: 32px;
height: 32px;
object-fit: contain;
}
.icon-library {
display: grid;
gap: 12px;
min-width: 0;
}
.icon-library h2,
.icon-library p {
margin: 0;
}
.icon-library label {
display: grid;
gap: 6px;
}
.icon-library .check-line {
display: flex;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
max-height: 340px;
overflow: auto;
padding: 4px;
}
.icon-option {
background: var(--surface, #fff);
color: inherit;
border: 1px solid #dce3e5;
padding: 10px 6px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.icon-option img {
width: 40px;
height: 40px;
object-fit: contain;
}
.icon-option span {
font-size: 12px;
overflow-wrap: anywhere;
}
.icon-option small {
color: #64748b;
}
.icon-option:disabled {
opacity: 1;
cursor: default;
}
.icon-option.selected {
border: 2px solid #087e70;
background: #effbf7;
}
.icon-selection,
.icon-pagination {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.icon-selection img {
width: 40px;
height: 40px;
object-fit: contain;
}
.icon-upload {
border-top: 1px solid #dce3e5;
padding-top: 12px;
}
.icon-upload summary {
cursor: pointer;
margin-bottom: 10px;
}
.icon-upload label {
margin-bottom: 10px;
}
.icon-error {
color: #b42318;
}
.detail-icon {
width: 42px;
height: 42px;
object-fit: contain;
vertical-align: middle;
margin-right: 12px;
}