feat: add reusable private and shared account icons
This commit is contained in:
1 parent
d2b2681698
commit
a4e9d56b8a
44 files changed
+1443
-20
No files matched your search
+24
-4
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export type History = {
|
||||
reason: string;
|
||||
};
|
||||
export type Position = {
|
||||
iconId?: string | null;
|
||||
id: string;
|
||||
kind: string;
|
||||
side: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user