feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { useRef } from 'react';
|
||||
import { Paperclip, FileText, Image, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui';
|
||||
import { fmtSize } from '@/lib/format';
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
export interface PendingAtt { file_name: string; mime?: string; size: number; data_b64: string; preview?: string; }
|
||||
|
||||
export function AttachmentPicker({ items, pending, onPick, onDeleteSaved, onDeletePending, onOpenSaved, uploading }: {
|
||||
items: Attachment[];
|
||||
pending: PendingAtt[];
|
||||
onPick: (files: FileList) => void;
|
||||
onDeleteSaved: (id: number) => void;
|
||||
onDeletePending: (index: number) => void;
|
||||
onOpenSaved?: (a: Attachment) => void;
|
||||
uploading?: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const total = items.length + pending.length;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-medium text-ink-2">
|
||||
<Paperclip size={13} />
|
||||
附件({total})
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled={uploading} onClick={() => inputRef.current?.click()}>
|
||||
{uploading ? '上传中…' : '添加文件'}
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
if (e.target.files?.length) onPick(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map(a => (
|
||||
<div key={'s' + a.id} className="flex items-center gap-2 rounded-sm bg-surface px-2 py-1.5 text-[12px]">
|
||||
<FileText size={13} className="text-ink-3 shrink-0" />
|
||||
<span
|
||||
className={'truncate ' + (onOpenSaved ? 'cursor-pointer text-ink-1 hover:text-brand-text hover:underline' : 'text-ink-1')}
|
||||
onClick={() => onOpenSaved?.(a)}
|
||||
title={a.file_name}
|
||||
>
|
||||
{a.file_name}
|
||||
</span>
|
||||
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(a.size)}</span>
|
||||
<button className="text-ink-3 hover:text-danger shrink-0" onClick={() => onDeleteSaved(a.id)} aria-label="删除附件">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pending.map((p, i) => (
|
||||
<div key={'p' + i} className="flex items-center gap-2 rounded-sm bg-surface px-2 py-1.5 text-[12px]">
|
||||
{p.mime?.startsWith('image/') && p.preview ? (
|
||||
<img
|
||||
src={p.preview}
|
||||
alt={p.file_name}
|
||||
className="size-7 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image size={13} className="text-ink-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-ink-1">{p.file_name}</span>
|
||||
<span className="text-[11px] text-brand-text">待上传</span>
|
||||
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(p.size)}</span>
|
||||
<button className="text-ink-3 hover:text-danger shrink-0" onClick={() => onDeletePending(i)} aria-label="移除">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!total && <div className="py-1 text-[12px] text-ink-3">证书扫描件、照片、文档等佐证材料</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function fileToPending(file: File): Promise<PendingAtt> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
const comma = result.indexOf(',');
|
||||
// 图片用 objectURL 做轻量预览(不上 Base64 进 DOM)
|
||||
const preview = file.type?.startsWith('image/') ? URL.createObjectURL(file) : undefined;
|
||||
resolve({
|
||||
file_name: file.name,
|
||||
mime: file.type || undefined,
|
||||
size: file.size,
|
||||
data_b64: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
preview
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { FormField } from '@/lib/types';
|
||||
import { Input, Textarea, Select, Field, type Option } from '@/components/ui';
|
||||
|
||||
export type TaxonomyOptionsMap = Record<string, Option[]>;
|
||||
|
||||
function resolveOptions(f: FormField, taxonomies: TaxonomyOptionsMap): Option[] {
|
||||
if (!f.options) return [];
|
||||
if (Array.isArray(f.options)) return f.options.map(v => ({ value: v, label: v }));
|
||||
return taxonomies[f.options.taxonomy] ?? [];
|
||||
}
|
||||
|
||||
export function DynamicForm({ fields, value, onChange, taxonomies }: {
|
||||
fields: FormField[];
|
||||
value: Record<string, unknown>;
|
||||
onChange: (next: Record<string, unknown>) => void;
|
||||
taxonomies: TaxonomyOptionsMap;
|
||||
}) {
|
||||
if (!fields.length) return null;
|
||||
|
||||
const set = (key: string, v: unknown) => onChange({ ...value, [key]: v });
|
||||
|
||||
const renderField = (f: FormField) => {
|
||||
const v = value[f.key];
|
||||
const commonError = f.required && (v === undefined || v === null || v === '') ? '该项必填' : '';
|
||||
const withUnit = (node: React.ReactNode) =>
|
||||
f.unit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{node}
|
||||
<span className="text-[12px] text-ink-3 shrink-0">{f.unit}</span>
|
||||
</div>
|
||||
) : node;
|
||||
|
||||
switch (f.widget) {
|
||||
case 'textarea':
|
||||
return (
|
||||
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
|
||||
<Textarea value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
|
||||
</Field>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
|
||||
{withUnit(
|
||||
<Input type="number" step="any" className="num" value={v === undefined || v === null ? '' : String(v)}
|
||||
onChange={e => set(f.key, e.target.value === '' ? undefined : Number(e.target.value))} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
|
||||
<Input type="date" value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} />
|
||||
</Field>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
|
||||
<Select
|
||||
value={String(v ?? '')}
|
||||
onChange={e => set(f.key, e.target.value || undefined)}
|
||||
options={[{ value: '', label: '请选择' }, ...resolveOptions(f, taxonomies)]}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case 'multiselect': {
|
||||
const opts = resolveOptions(f, taxonomies);
|
||||
const arr = Array.isArray(v) ? (v as string[]) : [];
|
||||
return (
|
||||
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{opts.map(o => {
|
||||
const active = arr.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={
|
||||
'rounded-sm border px-2 py-0.5 text-[12px] transition-colors ' +
|
||||
(active
|
||||
? 'border-brand bg-brand-subtle text-brand-text'
|
||||
: 'border-line bg-surface text-ink-2 hover:border-line-strong')
|
||||
}
|
||||
onClick={() => set(f.key, active ? arr.filter(x => x !== o.value) : [...arr, o.value])}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!opts.length && <span className="text-[12px] text-ink-3">未配置选项</span>}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
case 'link':
|
||||
return (
|
||||
<Field key={f.key} label={f.label} required={f.required} hint={commonError || '关联记录功能规划中,暂以文本记录'}>
|
||||
<Input value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
|
||||
</Field>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
|
||||
<Input value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
|
||||
{fields.map(f => (
|
||||
<div key={f.key} className={f.widget === 'textarea' ? 'col-span-2' : ''}>
|
||||
{renderField(f)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, type KeyboardEvent } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TagInput({ value, onChange, placeholder }: {
|
||||
value: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const commit = () => {
|
||||
const t = draft.trim();
|
||||
if (t && !value.includes(t)) onChange([...value, t]);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === 'Backspace' && !draft && value.length) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-8 w-full flex-wrap items-center gap-1 rounded-md border border-line bg-surface px-1.5 py-1',
|
||||
'transition-colors hover:border-line-strong focus-within:border-brand'
|
||||
)}
|
||||
>
|
||||
{value.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center gap-0.5 rounded-sm bg-surface-3 px-1.5 text-[12px] text-ink-2">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="text-ink-3 hover:text-ink-1"
|
||||
onClick={() => onChange(value.filter(t => t !== tag))}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="min-w-[80px] flex-1 border-0 bg-transparent text-[13px] outline-none placeholder:text-ink-3"
|
||||
value={draft}
|
||||
placeholder={value.length ? '' : (placeholder ?? '输入后回车添加')}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={commit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Badge, NeutralBadge } from '@/components/ui';
|
||||
import type { FootprintRow } from '@/lib/types';
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
|
||||
draft: { label: '草稿', color: 'var(--text-3)', bg: 'var(--surface-3)' },
|
||||
ongoing: { label: '进行中', color: 'var(--brand-text)', bg: 'var(--brand-subtle)' },
|
||||
submitted: { label: '已提交待果', color: 'var(--warning)', bg: 'var(--warning-subtle)' },
|
||||
finished: { label: '已完结', color: 'var(--success)', bg: 'var(--success-subtle)' }
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const s = STATUS_MAP[status] ?? STATUS_MAP.draft;
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center rounded-sm px-1.5 py-px text-[11px] leading-[18px] font-medium whitespace-nowrap"
|
||||
style={{ color: s.color, backgroundColor: s.bg }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
'国际级': '#7c3aed',
|
||||
'国家级': '#dc2626',
|
||||
'省市级': '#ea580c',
|
||||
'区县级': '#2563eb',
|
||||
'校级': '#16a34a',
|
||||
'院级': '#0891b2'
|
||||
};
|
||||
|
||||
export function LevelBadge({ level }: { level?: string | null }) {
|
||||
if (!level) return null;
|
||||
return <Badge color={LEVEL_COLORS[level] ?? 'var(--text-3)'}>{level}</Badge>;
|
||||
}
|
||||
|
||||
const GRADE_COLORS: Record<string, string> = {
|
||||
'特等奖': '#b45309',
|
||||
'一等奖': '#b45309',
|
||||
'二等奖': '#ea580c',
|
||||
'三等奖': '#2563eb',
|
||||
'优秀奖': '#64748b'
|
||||
};
|
||||
|
||||
export function GradeBadge({ grade }: { grade?: string | null }) {
|
||||
if (!grade) return null;
|
||||
return <Badge color={GRADE_COLORS[grade] ?? 'var(--text-3)'}>{grade}</Badge>;
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role?: string | null }) {
|
||||
if (!role) return null;
|
||||
return <NeutralBadge>{role}</NeutralBadge>;
|
||||
}
|
||||
|
||||
export function NegativeBadge() {
|
||||
return <Badge color="var(--danger)">负面</Badge>;
|
||||
}
|
||||
|
||||
export function TypeBadge({ row }: { row: FootprintRow }) {
|
||||
return (
|
||||
<Badge color={row.type_color}>
|
||||
{row.type_name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Tag, GraduationCap, Trophy, FlaskConical, Briefcase, Network, HeartHandshake,
|
||||
Palette, Award, FileBadge, Star, ShieldAlert, BookOpen, Code2, Languages,
|
||||
Mic2, PenLine, Rocket, Users, Building2, Dumbbell, type LucideIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ICON_CHOICES: { name: string; label: string }[] = [
|
||||
{ name: 'tag', label: '通用' },
|
||||
{ name: 'graduation-cap', label: '学业' },
|
||||
{ name: 'trophy', label: '竞赛' },
|
||||
{ name: 'flask-conical', label: '科研' },
|
||||
{ name: 'briefcase', label: '实习' },
|
||||
{ name: 'network', label: '学生工作' },
|
||||
{ name: 'heart-handshake', label: '志愿' },
|
||||
{ name: 'palette', label: '文体' },
|
||||
{ name: 'award', label: '荣誉' },
|
||||
{ name: 'file-badge', label: '证书' },
|
||||
{ name: 'star', label: '收藏' },
|
||||
{ name: 'shield-alert', label: '惩处' },
|
||||
{ name: 'book-open', label: '阅读' },
|
||||
{ name: 'code-2', label: '技术' },
|
||||
{ name: 'languages', label: '语言' },
|
||||
{ name: 'mic-2', label: '文艺' },
|
||||
{ name: 'pen-line', label: '发表' },
|
||||
{ name: 'rocket', label: '项目' },
|
||||
{ name: 'users', label: '团队' },
|
||||
{ name: 'building-2', label: '组织' },
|
||||
{ name: 'dumbbell', label: '体育' }
|
||||
];
|
||||
|
||||
const MAP: Record<string, LucideIcon> = {
|
||||
'tag': Tag,
|
||||
'graduation-cap': GraduationCap,
|
||||
'trophy': Trophy,
|
||||
'flask-conical': FlaskConical,
|
||||
'briefcase': Briefcase,
|
||||
'network': Network,
|
||||
'heart-handshake': HeartHandshake,
|
||||
'palette': Palette,
|
||||
'award': Award,
|
||||
'file-badge': FileBadge,
|
||||
'star': Star,
|
||||
'shield-alert': ShieldAlert,
|
||||
'book-open': BookOpen,
|
||||
'code-2': Code2,
|
||||
'languages': Languages,
|
||||
'mic-2': Mic2,
|
||||
'pen-line': PenLine,
|
||||
'rocket': Rocket,
|
||||
'users': Users,
|
||||
'building-2': Building2,
|
||||
'dumbbell': Dumbbell
|
||||
};
|
||||
|
||||
export function TypeIcon({ name, color, size = 14, className }: { name: string; color?: string; size?: number; className?: string }) {
|
||||
const Icon = MAP[name] ?? Tag;
|
||||
return <Icon size={size} color={color} className={className} strokeWidth={2} />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { ToastProvider } from '@/components/ui';
|
||||
|
||||
export function AppShell() {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex h-full">
|
||||
{navOpen && (
|
||||
<div className="fixed inset-0 z-30 bg-black/45 lg:hidden" onClick={() => setNavOpen(false)} aria-hidden />
|
||||
)}
|
||||
<Sidebar open={navOpen} onClose={() => setNavOpen(false)} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Header onMenu={() => setNavOpen(true)} />
|
||||
<main className="min-h-0 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Search, Sun, Moon, Plus, Menu } from 'lucide-react';
|
||||
import { Button, Input } from '@/components/ui';
|
||||
|
||||
const TITLES: Record<string, { title: string; sub?: string }> = {
|
||||
'/timeline': { title: '时间线', sub: '按学年与月份回顾全部足迹' },
|
||||
'/records': { title: '记录', sub: '全部足迹的检索与管理' },
|
||||
'/grades': { title: '成绩', sub: '学年学科成绩 · 梯度与总览' },
|
||||
'/dashboard': { title: '统计', sub: '量化个人成长收获' },
|
||||
'/types': { title: '记录类型', sub: '类型与字段模板管理' },
|
||||
'/taxonomy': { title: '字典与维度', sub: '级别、角色与成长维度' }
|
||||
};
|
||||
|
||||
export function Header({ onMenu }: { onMenu?: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState('');
|
||||
const [dark, setDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
|
||||
const meta = TITLES[pathname] ?? { title: '' };
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle('dark', next);
|
||||
localStorage.setItem('fp-theme', next ? 'dark' : 'light');
|
||||
window.dispatchEvent(new CustomEvent('fp-theme-change'));
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-4 border-b border-line bg-surface px-5">
|
||||
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenu} aria-label="打开导航">
|
||||
<Menu size={17} />
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink-1">{meta.title}</h1>
|
||||
{meta.sub && <p className="truncate text-[11px] leading-tight text-ink-3">{meta.sub}</p>}
|
||||
</div>
|
||||
|
||||
<div className="relative ml-auto w-[280px]">
|
||||
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="搜索标题、内容、结果、标签"
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && q.trim()) {
|
||||
navigate('/records?q=' + encodeURIComponent(q.trim()));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label="切换主题">
|
||||
{dark ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" onClick={() => navigate('/records?new=1')}>
|
||||
<Plus size={14} />
|
||||
新建记录
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, ChevronsLeft } from 'lucide-react';
|
||||
import { appInfo, syncStatus } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
section: '工作区',
|
||||
items: [
|
||||
{ to: '/timeline', label: '时间线', icon: CalendarDays },
|
||||
{ to: '/records', label: '记录', icon: Rows3 },
|
||||
{ to: '/grades', label: '成绩', icon: GraduationCap },
|
||||
{ to: '/dashboard', label: '统计', icon: ChartColumn }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '配置',
|
||||
items: [
|
||||
{ to: '/types', label: '记录类型', icon: Shapes },
|
||||
{ to: '/taxonomy', label: '字典与维度', icon: SlidersHorizontal }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function LogoMark() {
|
||||
return (
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden>
|
||||
<rect x="1" y="1" width="24" height="24" rx="6" fill="var(--brand)" />
|
||||
<ellipse cx="11" cy="10" rx="4.2" ry="5.2" fill="#fff" opacity="0.95" transform="rotate(-18 11 10)" />
|
||||
<circle cx="17.5" cy="17.5" r="3.1" fill="#fff" opacity="0.7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const info = useQuery({ queryKey: ['app-info'], queryFn: appInfo, staleTime: Infinity });
|
||||
const sync = useQuery({ queryKey: ['sync-status'], queryFn: syncStatus, refetchInterval: 15000 });
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={
|
||||
'flex w-[224px] shrink-0 flex-col border-r border-line bg-surface' +
|
||||
' max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:z-40 max-lg:transition-transform' +
|
||||
(open ? ' max-lg:translate-x-0' : ' max-lg:-translate-x-full')
|
||||
}
|
||||
>
|
||||
<div className="flex h-14 items-center gap-2.5 border-b border-line px-4">
|
||||
<LogoMark />
|
||||
<div className="leading-tight">
|
||||
<div className="text-[14px] font-semibold text-ink-1">足迹</div>
|
||||
<div className="text-[10px] font-medium tracking-[0.18em] text-ink-3">FOOTPRINTS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3">
|
||||
{NAV.map(group => (
|
||||
<div key={group.section} className="mb-4">
|
||||
<div className="px-2 pb-1.5 text-[11px] font-medium text-ink-3">{group.section}</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex h-8 items-center gap-2.5 rounded-md px-2 text-[13px] transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-subtle font-medium text-brand-text'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon size={15} strokeWidth={2} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-line px-4 py-3">
|
||||
<div className="truncate text-[11px] text-ink-3" title={info.data?.db_path ?? ''}>
|
||||
{info.data ? '数据库:' + info.data.db_path.split(/[\\/]/).slice(-1)[0] : '加载中…'}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-ink-3">
|
||||
<ChevronsLeft size={11} />
|
||||
{sync.data
|
||||
? (sync.data.pending_ops > 0
|
||||
? '本地模式 · 待同步 ' + sync.data.pending_ops
|
||||
: '本地模式 · 数据已就绪')
|
||||
: '…'}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Trash2, Paperclip, FileText } from 'lucide-react';
|
||||
import { attachmentRead, footprintGet, listDimensions, listTypes } from '@/lib/api';
|
||||
import type { Attachment, FormField } from '@/lib/types';
|
||||
import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui';
|
||||
import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges';
|
||||
import { openOrDownload } from '@/lib/file';
|
||||
import { makeThumb } from '@/lib/img';
|
||||
import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format';
|
||||
|
||||
export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
footprintId?: number | null;
|
||||
onEdit?: (id: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['footprint', footprintId],
|
||||
queryFn: () => footprintGet(footprintId!),
|
||||
enabled: open && !!footprintId
|
||||
});
|
||||
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
|
||||
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions, enabled: open });
|
||||
|
||||
const d = detailQ.data?.item;
|
||||
const type = typesQ.data?.items.find(t => t.id === d?.type_id);
|
||||
const dimNames = (d?.dimensions ?? [])
|
||||
.map(id => dimsQ.data?.items.find(x => x.id === id)?.name)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const delMut = useMutation({
|
||||
mutationFn: () => import('@/lib/api').then(m => m.footprintDelete(footprintId!)),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['footprints'] });
|
||||
void qc.invalidateQueries({ queryKey: ['timeline'] });
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
toast('success', '记录已删除');
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
const openAtt = async (a: Attachment) => {
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
|
||||
} catch (e) {
|
||||
toast('error', (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 图片直出:逐张读取为 data URL(渐进渲染)
|
||||
const [imgMap, setImgMap] = useState<Record<number, string>>({});
|
||||
const atts = d?.attachments ?? [];
|
||||
const images = atts.filter(a => a.mime?.startsWith('image/'));
|
||||
const docs = atts.filter(a => !a.mime?.startsWith('image/'));
|
||||
const attsKey = atts.map(a => a.id).join(',');
|
||||
useEffect(() => {
|
||||
setImgMap({});
|
||||
if (!open || !attsKey) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const map: Record<number, string> = {};
|
||||
const list = atts.filter(a => a.mime?.startsWith('image/'));
|
||||
for (const a of list) {
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
if (cancelled) return;
|
||||
// 网格展示用缩略图(降采样至 480px WebP),原图仅在点击查看时读取
|
||||
map[a.id] = await makeThumb(r.data_b64, r.mime || 'image/png');
|
||||
setImgMap({ ...map });
|
||||
} catch {
|
||||
// 解码失败则退回原图
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
map[a.id] = 'data:' + (r.mime || 'image/png') + ';base64,' + r.data_b64;
|
||||
setImgMap({ ...map });
|
||||
} catch { /* 单张失败不影响其余 */ }
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, footprintId, attsKey]);
|
||||
|
||||
// 动态字段渲染
|
||||
const fields: { label: string; value: string }[] = [];
|
||||
if (d && type) {
|
||||
for (const f of (type.form_template ?? []) as FormField[]) {
|
||||
const v = (d.extras ?? {})[f.key];
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
const text = Array.isArray(v) ? v.join('、') : String(v);
|
||||
fields.push({ label: f.label, value: f.unit ? text + ' ' + f.unit : text });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={680}
|
||||
title={d ? d.title : '记录详情'}
|
||||
footer={
|
||||
d ? (
|
||||
<>
|
||||
<Button variant="danger-ghost" className="mr-auto" onClick={() => setConfirmOpen(true)} disabled={delMut.isPending}>
|
||||
<Trash2 size={13} /> 删除
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>关闭</Button>
|
||||
<Button variant="primary" onClick={() => footprintId && onEdit?.(footprintId)}>
|
||||
<Pencil size={13} /> 编辑
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detailQ.isLoading || !d ? (
|
||||
<div className="py-10 text-center text-[13px] text-ink-3">加载中…</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 徽章行 */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge color={d.type_color}>{d.type_name}</Badge>
|
||||
<LevelBadge level={d.level} />
|
||||
<GradeBadge grade={d.award_grade} />
|
||||
<RoleBadge role={d.role} />
|
||||
<StatusBadge status={d.status} />
|
||||
{d.polarity === 'negative' && <NegativeBadge />}
|
||||
{d.rank != null && <Badge color="var(--text-3)">第 {d.rank} 名</Badge>}
|
||||
</div>
|
||||
|
||||
{/* 基本信息网格 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border border-line bg-surface-2 px-3 py-2.5 text-[12.5px]">
|
||||
<MetaItem label="时间" value={fmtRange(d.start_date, d.end_date) || '—'} />
|
||||
<MetaItem label="组织方" value={d.organizer || '—'} />
|
||||
{d.group_id && <MetaItem label="事件组" value={d.group_id} />}
|
||||
{d.parent_id != null && <MetaItem label="上级记录" value={'#' + d.parent_id} />}
|
||||
<MetaItem label="创建于" value={fmtDateTime(d.created_at)} />
|
||||
<MetaItem label="更新于" value={fmtDateTime(d.updated_at)} />
|
||||
</div>
|
||||
|
||||
{d.description && (
|
||||
<Section title="工作内容 / 描述">
|
||||
<p className="whitespace-pre-wrap text-[13px] leading-relaxed text-ink-2">{d.description}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{d.result && (
|
||||
<Section title="结果 / 产出">
|
||||
<p className="whitespace-pre-wrap rounded-md bg-success-subtle px-3 py-2 text-[13px] leading-relaxed text-success">{d.result}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{fields.length > 0 && (
|
||||
<Section title={type ? '类型字段 · ' + type.name : '类型字段'}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
|
||||
{fields.map(f => <MetaItem key={f.label} label={f.label} value={f.value} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(d.metrics && Object.keys(d.metrics).length > 0) && (
|
||||
<Section title="量化指标">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(d.metrics).map(([k, v]) => (
|
||||
<span key={k} className="num rounded-sm border border-line bg-surface-2 px-2 py-0.5 text-[12px] text-ink-2">
|
||||
{k}: <span className="font-medium text-ink-1">{v}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{((d.tags?.length ?? 0) > 0 || dimNames.length > 0) && (
|
||||
<Section title="标签与维度">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{d.tags.map(t => (
|
||||
<span key={t} className="rounded-sm bg-surface-3 px-1.5 py-0.5 text-[12px] text-ink-2">{t}</span>
|
||||
))}
|
||||
{dimNames.map(n => (
|
||||
<span key={n} className="rounded-sm border border-brand/30 bg-brand-subtle px-1.5 py-0.5 text-[12px] text-brand-text">{n}</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title={'附件(' + (d.attachments?.length ?? 0) + ')'}>
|
||||
{atts.length ? (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{images.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="group relative aspect-square overflow-hidden rounded-md border border-line bg-surface-2"
|
||||
onClick={() => openAtt(a)}
|
||||
title={'点击查看原图:' + a.file_name}
|
||||
>
|
||||
{imgMap[a.id] ? (
|
||||
<img
|
||||
src={imgMap[a.id]}
|
||||
alt={a.file_name}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full animate-pulse bg-surface-3" />
|
||||
)}
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/60 to-transparent px-1.5 pb-1 pt-3 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{a.file_name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{docs.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{docs.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-2 text-left text-[12.5px] transition-colors hover:border-line-strong"
|
||||
onClick={() => openAtt(a)}
|
||||
>
|
||||
<FileText size={14} className="shrink-0 text-ink-3" />
|
||||
<span className="truncate text-ink-1">{a.file_name}</span>
|
||||
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(a.size)}</span>
|
||||
<Paperclip size={12} className="shrink-0 text-ink-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[11px] text-ink-3">点击图片查看原图(原始比例)</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-ink-3">暂无佐证附件</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={confirmOpen}
|
||||
title="删除记录"
|
||||
content={d ? '确定删除「' + d.title + '」吗?该操作不可撤销。' : ''}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onOk={() => delMut.mutate()}
|
||||
loading={delMut.isPending}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<span className="shrink-0 text-ink-3">{label}</span>
|
||||
<span className="min-w-0 truncate font-medium text-ink-1" title={value}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-semibold text-ink-2">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
attachmentAdd, attachmentDelete, attachmentList, attachmentRead,
|
||||
footprintGet, footprintList, footprintUpsert, listDimensions, listTaxonomy, listTypes
|
||||
} from '@/lib/api';
|
||||
import type { Attachment } from '@/lib/types';
|
||||
import type { FootprintInput } from '@/lib/types';
|
||||
import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||
import { TagInput } from '@/components/common/TagInput';
|
||||
import { DynamicForm, type TaxonomyOptionsMap } from '@/components/common/DynamicForm';
|
||||
import { AttachmentPicker, fileToPending, type PendingAtt } from '@/components/common/AttachmentPicker';
|
||||
|
||||
interface FormState {
|
||||
type_id: number | null;
|
||||
title: string;
|
||||
organizer: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: string;
|
||||
role: string;
|
||||
level: string;
|
||||
award_grade: string;
|
||||
rank: string;
|
||||
polarity: string;
|
||||
weight: string;
|
||||
description: string;
|
||||
result: string;
|
||||
tags: string[];
|
||||
dimension_ids: number[];
|
||||
group_id: string;
|
||||
parent_id: string;
|
||||
extras: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const BLANK: FormState = {
|
||||
type_id: null, title: '', organizer: '', start_date: '', end_date: '',
|
||||
status: 'draft', role: '', level: '', award_grade: '', rank: '', polarity: 'positive',
|
||||
weight: '3', description: '', result: '', tags: [], dimension_ids: [],
|
||||
group_id: '', parent_id: '', extras: {}
|
||||
};
|
||||
|
||||
const GRADES = ['特等奖', '一等奖', '二等奖', '三等奖', '优秀奖', '无'];
|
||||
const MAX_ATT_BYTES = 80 * 1024 * 1024; // 80MB(Base64 膨胀后约 107MB,低于桥接/服务端 160MB 上限)
|
||||
const STATUS_OPTS = [
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'ongoing', label: '进行中' },
|
||||
{ value: 'submitted', label: '已提交待果' },
|
||||
{ value: 'finished', label: '已完结' }
|
||||
];
|
||||
|
||||
import { openOrDownload } from '@/lib/file';
|
||||
|
||||
export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
footprintId?: number | null;
|
||||
onSaved?: (id: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<FormState>(BLANK);
|
||||
const [pendingAtts, setPendingAtts] = useState<PendingAtt[]>([]);
|
||||
const [uploadingAtt, setUploadingAtt] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
|
||||
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy(), enabled: open });
|
||||
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions, enabled: open });
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['footprint', footprintId],
|
||||
queryFn: () => footprintGet(footprintId!),
|
||||
enabled: open && !!footprintId
|
||||
});
|
||||
const attsQ = useQuery({
|
||||
queryKey: ['attachments', footprintId],
|
||||
queryFn: () => attachmentList(footprintId!),
|
||||
enabled: open && !!footprintId
|
||||
});
|
||||
|
||||
const selectedType = useMemo(
|
||||
() => typesQ.data?.items.find(t => t.id === form.type_id),
|
||||
[typesQ.data, form.type_id]
|
||||
);
|
||||
|
||||
const parentQ = useQuery({
|
||||
queryKey: ['parent-candidates', form.type_id],
|
||||
queryFn: () => footprintList({ types: form.type_id ? [form.type_id] : undefined, page_size: 200, sort: 'date_desc' }),
|
||||
enabled: open && !!form.type_id
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError('');
|
||||
setPendingAtts([]);
|
||||
if (footprintId && detailQ.data) {
|
||||
const d = detailQ.data.item;
|
||||
setForm({
|
||||
type_id: d.type_id, title: d.title, organizer: d.organizer ?? '',
|
||||
start_date: (d.start_date ?? '').slice(0, 10), end_date: (d.end_date ?? '').slice(0, 10),
|
||||
status: d.status, role: d.role ?? '', level: d.level ?? '',
|
||||
award_grade: d.award_grade ?? '', rank: d.rank != null ? String(d.rank) : '',
|
||||
polarity: d.polarity, weight: String(d.weight ?? 3),
|
||||
description: d.description ?? '', result: d.result ?? '',
|
||||
tags: d.tags ?? [], dimension_ids: d.dimensions ?? [],
|
||||
group_id: d.group_id ?? '', parent_id: d.parent_id != null ? String(d.parent_id) : '',
|
||||
extras: d.extras ?? {}
|
||||
});
|
||||
} else {
|
||||
setForm(BLANK);
|
||||
}
|
||||
}, [open, footprintId, detailQ.data]);
|
||||
|
||||
const taxonomies: TaxonomyOptionsMap = useMemo(() => {
|
||||
const map: TaxonomyOptionsMap = {};
|
||||
for (const t of taxQ.data?.items ?? []) {
|
||||
(map[t.kind] ??= []).push({ value: t.value, label: t.value });
|
||||
}
|
||||
return map;
|
||||
}, [taxQ.data]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!form.type_id) throw new Error('请选择记录类型');
|
||||
if (!form.title.trim()) throw new Error('请填写标题');
|
||||
|
||||
const metrics: Record<string, number> = {};
|
||||
for (const f of selectedType?.form_template ?? []) {
|
||||
if (f.metric && f.widget === 'number') {
|
||||
const v = form.extras[f.key];
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) metrics[f.metric] = v;
|
||||
}
|
||||
}
|
||||
const input: FootprintInput = {
|
||||
id: footprintId ?? undefined,
|
||||
type_id: form.type_id,
|
||||
title: form.title.trim(),
|
||||
organizer: form.organizer || null,
|
||||
start_date: form.start_date || null,
|
||||
end_date: form.end_date || null,
|
||||
status: form.status,
|
||||
role: form.role || null,
|
||||
level: form.level || null,
|
||||
award_grade: form.award_grade && form.award_grade !== '无' ? form.award_grade : null,
|
||||
rank: form.rank ? Number(form.rank) : null,
|
||||
polarity: form.polarity,
|
||||
description: form.description,
|
||||
result: form.result,
|
||||
tags: form.tags,
|
||||
dimension_ids: form.dimension_ids,
|
||||
group_id: form.group_id || null,
|
||||
parent_id: form.parent_id ? Number(form.parent_id) : null,
|
||||
weight: Number(form.weight) || 3,
|
||||
extras: form.extras,
|
||||
metrics
|
||||
};
|
||||
const saved = await footprintUpsert(input);
|
||||
for (const p of pendingAtts) {
|
||||
await attachmentAdd({ owner_id: saved.id, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
|
||||
}
|
||||
return saved.id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
void qc.invalidateQueries({ queryKey: ['footprints'] });
|
||||
void qc.invalidateQueries({ queryKey: ['timeline'] });
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
void qc.invalidateQueries({ queryKey: ['footprint'] });
|
||||
void qc.invalidateQueries({ queryKey: ['attachments', id] });
|
||||
void qc.invalidateQueries({ queryKey: ['types'] });
|
||||
toast('success', footprintId ? '记录已更新' : '记录已创建');
|
||||
onSaved?.(id);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message)
|
||||
});
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, v: FormState[K]) => setForm(f => ({ ...f, [key]: v }));
|
||||
|
||||
const delAtt = useMutation({
|
||||
mutationFn: (id: number) => attachmentDelete(id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['attachments', footprintId] })
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={760}
|
||||
title={footprintId ? '编辑记录' : '新建记录'}
|
||||
footer={
|
||||
<>
|
||||
{error && <span className="mr-auto text-[12px] text-danger">{error}</span>}
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
|
||||
{saveMut.isPending ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-x-3 gap-y-3.5">
|
||||
<Field label="类型" required>
|
||||
<Select
|
||||
value={form.type_id != null ? String(form.type_id) : ''}
|
||||
onChange={e => set('type_id', e.target.value ? Number(e.target.value) : null)}
|
||||
options={[
|
||||
{ value: '', label: '请选择' },
|
||||
...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标题" required>
|
||||
<Input value={form.title} onChange={e => set('title', e.target.value)} placeholder="事项名称,如:2025 全国大学生数学建模竞赛" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-x-3 gap-y-3.5">
|
||||
<Field label="组织方 / 所在组织">
|
||||
<Input value={form.organizer} onChange={e => set('organizer', e.target.value)} placeholder="如:中国工业与应用数学学会" />
|
||||
</Field>
|
||||
<Field label="开始日期">
|
||||
<Input type="date" value={form.start_date} onChange={e => set('start_date', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="结束日期">
|
||||
<Input type="date" value={form.end_date} onChange={e => set('end_date', e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 gap-y-3.5">
|
||||
<Field label="角色">
|
||||
<Select value={form.role} onChange={e => set('role', e.target.value)}
|
||||
options={[{ value: '', label: '未设置' }, ...(taxonomies['role'] ?? [])]} />
|
||||
</Field>
|
||||
<Field label="级别">
|
||||
<Select value={form.level} onChange={e => set('level', e.target.value)}
|
||||
options={[{ value: '', label: '未设置' }, ...(taxonomies['level'] ?? [])]} />
|
||||
</Field>
|
||||
<Field label="状态">
|
||||
<Select value={form.status} onChange={e => set('status', e.target.value)} options={STATUS_OPTS} />
|
||||
</Field>
|
||||
<Field label="重要度">
|
||||
<Select value={form.weight} onChange={e => set('weight', e.target.value)}
|
||||
options={[1, 2, 3, 4, 5].map(n => ({ value: String(n), label: String(n) }))} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 gap-y-3.5">
|
||||
<Field label="奖等">
|
||||
<Select value={form.award_grade} onChange={e => set('award_grade', e.target.value)}
|
||||
options={[{ value: '', label: '未设置' }, ...GRADES.map(g => ({ value: g, label: g }))]} />
|
||||
</Field>
|
||||
<Field label="名次">
|
||||
<Input type="number" className="num" value={form.rank} onChange={e => set('rank', e.target.value)} placeholder="如 2" />
|
||||
</Field>
|
||||
<Field label="性质">
|
||||
<Select value={form.polarity} onChange={e => set('polarity', e.target.value)}
|
||||
options={[{ value: 'positive', label: '正面' }, { value: 'negative', label: '负面' }]} />
|
||||
</Field>
|
||||
<Field label="上级记录" hint="晋升链">
|
||||
<Select value={form.parent_id} onChange={e => set('parent_id', e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: '无' },
|
||||
...(parentQ.data?.items ?? [])
|
||||
.filter(p => p.id !== footprintId)
|
||||
.map(p => ({ value: String(p.id), label: p.title.slice(0, 24) }))
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
|
||||
<Field label="工作内容 / 描述">
|
||||
<Textarea value={form.description} onChange={e => set('description', e.target.value)} placeholder="具体做了什么、承担什么分工" />
|
||||
</Field>
|
||||
<Field label="结果 / 产出">
|
||||
<Textarea value={form.result} onChange={e => set('result', e.target.value)} placeholder="获奖等级、成果、最终产品等" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
|
||||
<Field label="标签">
|
||||
<TagInput value={form.tags} onChange={v => set('tags', v)} />
|
||||
</Field>
|
||||
<Field label="成长维度">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-8 items-center">
|
||||
{(dimsQ.data?.items ?? []).map(d => {
|
||||
const active = form.dimension_ids.includes(d.id);
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={
|
||||
'rounded-sm border px-2 py-0.5 text-[12px] transition-colors ' +
|
||||
(active
|
||||
? 'border-brand bg-brand-subtle text-brand-text'
|
||||
: 'border-line bg-surface text-ink-2 hover:border-line-strong')
|
||||
}
|
||||
onClick={() =>
|
||||
set('dimension_ids', active
|
||||
? form.dimension_ids.filter(x => x !== d.id)
|
||||
: [...form.dimension_ids, d.id])
|
||||
}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!dimsQ.data?.items.length && <span className="text-[12px] text-ink-3">尚未配置维度(配置页可添加)</span>}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3">
|
||||
<Field label="事件组 ID" hint="同一场事件的多条记录共享">
|
||||
<Input value={form.group_id} onChange={e => set('group_id', e.target.value)} placeholder="如 g2025001(可选)" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selectedType && selectedType.form_template.length > 0 && (
|
||||
<div className="border-t border-line pt-4">
|
||||
<div className="mb-3 text-[12px] font-semibold text-ink-2">
|
||||
类型字段 · {selectedType.name}
|
||||
</div>
|
||||
<DynamicForm
|
||||
fields={selectedType.form_template}
|
||||
value={form.extras}
|
||||
onChange={v => set('extras', v)}
|
||||
taxonomies={taxonomies}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footprintId ? (
|
||||
<div className="border-t border-line pt-4">
|
||||
<AttachmentPicker
|
||||
items={attsQ.data?.items ?? []}
|
||||
pending={pendingAtts}
|
||||
uploading={uploadingAtt}
|
||||
onPick={async files => {
|
||||
if (!footprintId) return;
|
||||
setUploadingAtt(true);
|
||||
try {
|
||||
let done = 0;
|
||||
for (const f of Array.from(files)) {
|
||||
if (f.size > MAX_ATT_BYTES) {
|
||||
toast('error', '「' + f.name + '」超过 80MB,已跳过');
|
||||
continue;
|
||||
}
|
||||
const p = await fileToPending(f);
|
||||
await attachmentAdd({ owner_id: footprintId, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
|
||||
done++;
|
||||
}
|
||||
if (done > 0) {
|
||||
void qc.invalidateQueries({ queryKey: ['attachments', footprintId] });
|
||||
toast('success', '已上传 ' + done + ' 个附件');
|
||||
}
|
||||
} catch (e) {
|
||||
toast('error', (e as Error).message);
|
||||
} finally {
|
||||
setUploadingAtt(false);
|
||||
}
|
||||
}}
|
||||
onDeleteSaved={id => delAtt.mutate(id)}
|
||||
onDeletePending={() => {}}
|
||||
onOpenSaved={async (a: Attachment) => {
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
|
||||
} catch (e) {
|
||||
toast('error', (e as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1.5 text-[11px] text-ink-3">
|
||||
点击文件名可预览(支持图片 / PDF,其余下载)· 单个文件不超过 80MB
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-line pt-4">
|
||||
<AttachmentPicker
|
||||
items={[]}
|
||||
pending={pendingAtts}
|
||||
onPick={async files => {
|
||||
const list: PendingAtt[] = [];
|
||||
for (const f of Array.from(files)) {
|
||||
if (f.size > MAX_ATT_BYTES) {
|
||||
toast('error', '「' + f.name + '」超过 80MB,已跳过');
|
||||
continue;
|
||||
}
|
||||
list.push(await fileToPending(f));
|
||||
}
|
||||
setPendingAtts(prev => [...prev, ...list]);
|
||||
}}
|
||||
onDeleteSaved={() => {}}
|
||||
onDeletePending={idx => setPendingAtts(prev => prev.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
<div className="mt-1.5 text-[11px] text-ink-3">
|
||||
保存记录时将一并上传 · 单个文件不超过 80MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Badge({ className, color, ...props }: HTMLAttributes<HTMLSpanElement> & { color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm border px-1.5 py-px text-[11px] leading-[18px] font-medium whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
style={color ? {
|
||||
color: color,
|
||||
borderColor: color + '44',
|
||||
backgroundColor: color + '14'
|
||||
} : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NeutralBadge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm border border-line bg-surface-2 px-1.5 py-px text-[11px] leading-[18px] font-medium text-ink-2 whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dot({ color, className }: { color: string; className?: string }) {
|
||||
return <span className={cn('inline-block size-2 rounded-full shrink-0', className)} style={{ backgroundColor: color }} />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors select-none disabled:pointer-events-none disabled:opacity-45',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-brand text-white hover:bg-brand-hover shadow-card',
|
||||
secondary: 'bg-surface text-ink-1 border border-line hover:bg-surface-3 hover:border-line-strong',
|
||||
ghost: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1',
|
||||
subtle: 'bg-brand-subtle text-brand-text hover:brightness-[0.97]',
|
||||
danger: 'bg-danger text-white hover:brightness-110',
|
||||
'danger-ghost': 'text-danger hover:bg-danger-subtle'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-7 px-2.5 text-[12px]',
|
||||
md: 'h-8 px-3 text-[13px]',
|
||||
lg: 'h-9 px-4 text-sm',
|
||||
icon: 'h-8 w-8 p-0',
|
||||
'icon-sm': 'h-7 w-7 p-0'
|
||||
}
|
||||
},
|
||||
defaultVariants: { variant: 'secondary', size: 'md' }
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant, size, ...props }, ref
|
||||
) {
|
||||
return <button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
});
|
||||
|
||||
export { buttonVariants };
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-lg border border-line bg-surface shadow-card', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ title, extra, className }: { title: string; extra?: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5', className)}>
|
||||
<h3 className="text-[13px] font-semibold text-ink-1">{title}</h3>
|
||||
{extra && <div className="flex flex-wrap items-center gap-2">{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('p-4', className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Dialog } from './dialog';
|
||||
import { Button } from './button';
|
||||
|
||||
export function Confirm({ open, title, content, onCancel, onOk, loading }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
content?: string;
|
||||
onCancel: () => void;
|
||||
onOk: () => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} title={title} width={420}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onCancel}>取消</Button>
|
||||
<Button variant="danger" onClick={onOk} disabled={loading}>确认</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-[13px] text-ink-2">{content ?? '该操作不可撤销,请确认。'}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
|
||||
export function Dialog({ open, onClose, title, width = 560, children, footer }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
width?: number;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-6 pt-[8vh]">
|
||||
<div className="fixed inset-0 bg-black/45" onClick={onClose} />
|
||||
<div
|
||||
className="relative w-full rounded-lg border border-line bg-surface shadow-pop"
|
||||
style={{ maxWidth: width }}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-b border-line px-4">
|
||||
<div className="text-sm font-semibold text-ink-1">{title}</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="关闭">
|
||||
<X size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-4 py-4 max-h-[64vh] overflow-y-auto">{children}</div>
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-line px-4 h-14">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Dropdown({ trigger, children, align = 'end', className }: {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode | ((close: () => void) => ReactNode);
|
||||
align?: 'start' | 'end';
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<div onClick={() => setOpen(v => !v)}>{trigger}</div>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-40 mt-1 min-w-[160px] rounded-md border border-line bg-surface py-1 shadow-pop',
|
||||
align === 'end' ? 'right-0' : 'left-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{typeof children === 'function' ? children(() => setOpen(false)) : children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownItem({ className, danger, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { danger?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition-colors',
|
||||
danger ? 'text-danger hover:bg-danger-subtle' : 'text-ink-1 hover:bg-surface-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownSeparator() {
|
||||
return <div className="my-1 border-t border-line" />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Inbox } from 'lucide-react';
|
||||
|
||||
export function Empty({ icon, title, description, action }: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<div className="text-ink-3">{icon ?? <Inbox size={36} strokeWidth={1.5} />}</div>
|
||||
<div className="text-[13px] font-medium text-ink-2">{title}</div>
|
||||
{description && <div className="max-w-[360px] text-[12px] text-ink-3">{description}</div>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Field({ label, required, hint, className, children }: {
|
||||
label: string; required?: boolean; hint?: string; className?: string; children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1.5', className)}>
|
||||
<label className="text-[12px] font-medium text-ink-2">
|
||||
{label}
|
||||
{required && <span className="text-danger ml-0.5">*</span>}
|
||||
{hint && <span className="text-ink-3 font-normal ml-2">{hint}</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { Button, buttonVariants } from './button';
|
||||
export { Input, Textarea } from './input';
|
||||
export { Field } from './field';
|
||||
export { Select } from './select';
|
||||
export type { Option } from './select';
|
||||
export { Badge, NeutralBadge, Dot } from './badge';
|
||||
export { Card, CardHeader, CardBody } from './card';
|
||||
export { Dialog } from './dialog';
|
||||
export { TableWrap, Table, Th, Td, Tr } from './table';
|
||||
export { Dropdown, DropdownItem, DropdownSeparator } from './dropdown';
|
||||
export { Empty } from './empty';
|
||||
export { ToastProvider, useToast } from './toast';
|
||||
export { Confirm } from './confirm';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const base = 'w-full rounded-md border border-line bg-surface px-2.5 text-[13px] text-ink-1 placeholder:text-ink-3 transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input(
|
||||
{ className, ...props }, ref
|
||||
) {
|
||||
return <input ref={ref} className={cn(base, 'h-8', className)} {...props} />;
|
||||
});
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function Textarea(
|
||||
{ className, ...props }, ref
|
||||
) {
|
||||
return <textarea ref={ref} className={cn(base, 'py-2 min-h-[72px] resize-y leading-relaxed', className)} {...props} />;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { forwardRef, type SelectHTMLAttributes } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Option { value: string; label: string; }
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement> & { options?: Option[] }>(
|
||||
function Select({ className, options, children, ...props }, ref) {
|
||||
return (
|
||||
<div className={cn('relative', className ? className.split(' ').filter(c => /^w-|^min-w|^flex-/.test(c)).join(' ') : '')}>
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full h-8 appearance-none rounded-md border border-line bg-surface pl-2.5 pr-7 text-[13px] text-ink-1',
|
||||
'transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)
|
||||
: children}
|
||||
</select>
|
||||
<ChevronDown size={14} className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-ink-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TableWrap({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('overflow-x-auto', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
|
||||
return <table className={cn('w-full border-collapse text-[13px]', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'sticky top-0 z-10 border-b border-line bg-surface-2 px-3 py-2 text-left text-[12px] font-semibold text-ink-3 whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <td className={cn('border-b border-line px-3 py-2 align-middle text-ink-1', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Tr({ className, onClick, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return (
|
||||
<tr
|
||||
className={cn('group transition-colors', onClick && 'cursor-pointer hover:bg-surface-2', className)}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||
import { CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ToastKind = 'success' | 'error' | 'info';
|
||||
interface ToastItem { id: number; kind: ToastKind; text: string; }
|
||||
|
||||
const ToastCtx = createContext<(kind: ToastKind, text: string) => void>(() => {});
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastCtx);
|
||||
}
|
||||
|
||||
const icons: Record<ToastKind, ReactNode> = {
|
||||
success: <CheckCircle2 size={15} className="text-success" />,
|
||||
error: <AlertCircle size={15} className="text-danger" />,
|
||||
info: <Info size={15} className="text-brand" />
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
const seq = useRef(0);
|
||||
|
||||
const push = useCallback((kind: ToastKind, text: string) => {
|
||||
const id = ++seq.current;
|
||||
setItems(prev => [...prev, { id, kind, text }]);
|
||||
setTimeout(() => setItems(prev => prev.filter(t => t.id !== id)), 3200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={push}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-5 right-5 z-[60] flex flex-col gap-2">
|
||||
{items.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
'pointer-events-auto flex items-center gap-2 rounded-md border border-line bg-surface px-3 py-2 text-[13px] text-ink-1 shadow-pop',
|
||||
'animate-in'
|
||||
)}
|
||||
>
|
||||
{icons[t.kind]}
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user