feat: custom types can bind a field layout (profile) - layout select in type editor, respected in record form
This commit is contained in:
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS footprint_type (
|
||||
color TEXT NOT NULL DEFAULT '#64748b',
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
form_template TEXT NOT NULL DEFAULT '[]',
|
||||
profile TEXT,
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0,
|
||||
closed INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
|
||||
+6
-5
@@ -125,6 +125,7 @@ function preMigrateColumns(d) {
|
||||
}
|
||||
};
|
||||
for (const t of SYNC_TABLES) addCols(t, SYNC_COLS);
|
||||
addCols('footprint_type', [['profile', 'TEXT']]);
|
||||
addCols('attachment', [['sha256', 'TEXT'], ['thumb_rel_path', 'TEXT'], ['remote_url', 'TEXT'], ['remote_thumb', 'TEXT'], ['remote_key', 'TEXT'], ['remote_thumb_key', 'TEXT']]);
|
||||
}
|
||||
|
||||
@@ -564,7 +565,7 @@ function listFootprints(d, query) {
|
||||
const items = d.prepare(`
|
||||
SELECT t.*, (SELECT COUNT(*) FROM footprint f WHERE f.type_id = t.id AND f.deleted_at IS NULL) AS usage_count
|
||||
FROM footprint_type t WHERE t.deleted_at IS NULL ORDER BY t.sort, t.id`).all()
|
||||
.map(t => ({ ...t, form_template: JSON.parse(t.form_template || '[]'), is_builtin: !!t.is_builtin, closed: !!t.closed }));
|
||||
.map(t => ({ ...t, form_template: JSON.parse(t.form_template || '[]'), profile: t.profile ?? null, is_builtin: !!t.is_builtin, closed: !!t.closed }));
|
||||
return { items };
|
||||
}
|
||||
|
||||
@@ -575,8 +576,8 @@ function listFootprints(d, query) {
|
||||
if (id) {
|
||||
withTx(d, () => {
|
||||
const h = hlcNext(d);
|
||||
d.prepare('UPDATE footprint_type SET name=?, icon=?, color=?, sort=?, form_template=?, closed=?, hlc=? WHERE id=?')
|
||||
.run(p.name, p.icon, p.color, p.sort, tpl, p.closed ? 1 : 0, h, id);
|
||||
d.prepare('UPDATE footprint_type SET name=?, icon=?, color=?, sort=?, form_template=?, profile=?, closed=?, hlc=? WHERE id=?')
|
||||
.run(p.name, p.icon, p.color, p.sort, tpl, p.profile ?? null, p.closed ? 1 : 0, h, id);
|
||||
touchOplog(d, 'footprint_type', getUuid(d, 'footprint_type', id), 'upsert', h);
|
||||
});
|
||||
} else {
|
||||
@@ -584,8 +585,8 @@ function listFootprints(d, query) {
|
||||
const h = hlcNext(d);
|
||||
const uuid = uuidv7();
|
||||
id = Number(d.prepare(
|
||||
'INSERT INTO footprint_type (name, icon, color, sort, form_template, closed, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
.run(p.name, p.icon, p.color, p.sort, tpl, p.closed ? 1 : 0, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||||
'INSERT INTO footprint_type (name, icon, color, sort, form_template, profile, closed, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(p.name, p.icon, p.color, p.sort, tpl, p.profile ?? null, p.closed ? 1 : 0, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||||
touchOplog(d, 'footprint_type', uuid, 'upsert', h);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
|
||||
() => typesQ.data?.items.find(t => t.id === form.type_id),
|
||||
[typesQ.data, form.type_id]
|
||||
);
|
||||
const profile = getTypeProfile(selectedType?.name);
|
||||
const profile = getTypeProfile(selectedType?.name, selectedType?.profile);
|
||||
|
||||
const parentQ = useQuery({
|
||||
queryKey: ['parent-candidates', form.type_id],
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export const syncStatus = () => call<SyncStatus>('sync_status');
|
||||
|
||||
// ---- footprint type ----
|
||||
export const listTypes = () => call<{ items: FootprintType[] }>('type_list');
|
||||
export const upsertType = (p: { id?: number; name: string; icon: string; color: string; sort: number; form_template: FormField[]; closed?: boolean }) =>
|
||||
export const upsertType = (p: { id?: number; name: string; icon: string; color: string; sort: number; form_template: FormField[]; profile?: string; closed?: boolean }) =>
|
||||
call<{ item: FootprintType }>('type_upsert', p);
|
||||
export const deleteType = (id: number) => call<{ ok: true }>('type_delete', { id });
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ const PROFILES: Record<string, TypeProfile> = {
|
||||
|
||||
const DEFAULT_PROFILE: TypeProfile = { core: ['level', 'role', 'award_grade', 'organizer'] };
|
||||
|
||||
export function getTypeProfile(typeName?: string | null): TypeProfile {
|
||||
export const PROFILE_OPTIONS = [
|
||||
{ value: '', label: '通用布局(级别/角色/奖等/组织方)' },
|
||||
...Object.keys(PROFILES).map(k => ({ value: k, label: k + '式布局' })),
|
||||
];
|
||||
|
||||
/** 自定义类型可绑定一种布局(profileKey),否则按类型名匹配,最后回退通用 */
|
||||
export function getTypeProfile(typeName?: string | null, profileKey?: string | null): TypeProfile {
|
||||
if (profileKey && PROFILES[profileKey]) return PROFILES[profileKey];
|
||||
if (!typeName) return DEFAULT_PROFILE;
|
||||
return PROFILES[typeName] ?? DEFAULT_PROFILE;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface FootprintType {
|
||||
color: string;
|
||||
sort: number;
|
||||
form_template: FormField[];
|
||||
profile?: string | null;
|
||||
is_builtin: boolean;
|
||||
closed: boolean;
|
||||
usage_count?: number;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Badge, Button, Card, Confirm, Dialog, Field, Input, Select, useToast
|
||||
} from '@/components/ui';
|
||||
import { TypeIcon, ICON_CHOICES } from '@/components/common/typeIcons';
|
||||
import { PROFILE_OPTIONS } from '@/lib/typeProfiles';
|
||||
|
||||
const PALETTE = ['#3056d3', '#7c3aed', '#0891b2', '#0f766e', '#16a34a', '#65a30d', '#d97706', '#dc2626', '#db2777', '#64748b'];
|
||||
|
||||
@@ -117,6 +118,7 @@ function TypeEditor({ source, onClose, onSaved }: { source: FootprintType | null
|
||||
const [sort, setSort] = useState(String(source?.sort ?? 100));
|
||||
const [closed, setClosed] = useState(source?.closed ?? false);
|
||||
const [fields, setFields] = useState<FormField[]>(source?.form_template ?? []);
|
||||
const [profile, setProfile] = useState(source?.profile ?? '');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const saveMut = useMutation({
|
||||
@@ -137,6 +139,7 @@ function TypeEditor({ source, onClose, onSaved }: { source: FootprintType | null
|
||||
icon, color,
|
||||
sort: Number(sort) || 100,
|
||||
form_template: fields,
|
||||
profile: profile || undefined,
|
||||
closed
|
||||
});
|
||||
},
|
||||
@@ -166,6 +169,7 @@ function TypeEditor({ source, onClose, onSaved }: { source: FootprintType | null
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-[1fr_120px_100px] gap-3">
|
||||
|
||||
<Field label="名称" required>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:学科竞赛" />
|
||||
</Field>
|
||||
@@ -225,7 +229,10 @@ function TypeEditor({ source, onClose, onSaved }: { source: FootprintType | null
|
||||
</Field>
|
||||
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Field label="字段布局" hint="决定录入表单的核心字段组合(新建记录时的「类型要素」)">
|
||||
<Select value={profile} onChange={e => setProfile(e.target.value)} options={PROFILE_OPTIONS} />
|
||||
</Field>
|
||||
<div className="mb-2 mt-2 flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold text-ink-2">字段模板({fields.length})</span>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user