feat: custom types can bind a field layout (profile) - layout select in type editor, respected in record form

This commit is contained in:
root
2026-10-17 10:45:04 +08:00
parent 79dada4226
commit 74884ce73a
19 changed files with 109 additions and 92 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
+8 -1
View File
@@ -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;
}
+1
View File
@@ -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;
+8 -1
View File
@@ -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"
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,f as v,j as t,S as j,t as S}from"./index-BpXGbBE-.js";import{C as c,a as d,b as x}from"./card-DMi0z8Hh.js";import{u as N,i as u,a as m}from"./useChart-D4kPoPsx.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
import{r as g,f as v,j as t,S as j,t as S}from"./index-C0Fp67iE.js";import{C as c,a as d,b as x}from"./card-DIWmzy0c.js";import{u as N,i as u,a as m}from"./useChart-BQT9jMdm.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
@@ -1,4 +1,4 @@
import{c as A,f as I,j as e,E as O,w as $,u as k,d as S,r as u,g as y,x as H,y as W,B as g,P as q,C as Q,z as J,A as T,D as U,m as N,l as G,T as E,I as j,F as Y,H as C,J as B,K as V}from"./index-BpXGbBE-.js";import{C as f,a as z,b as _}from"./card-DMi0z8Hh.js";import{u as X,i as M,a as L}from"./useChart-D4kPoPsx.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
import{c as A,f as I,j as e,E as O,w as $,u as k,d as S,r as u,g as y,x as H,y as W,B as g,P as q,C as Q,z as J,A as T,D as U,m as N,l as G,T as E,I as j,F as Y,H as C,J as B,K as V}from"./index-C0Fp67iE.js";import{C as f,a as z,b as _}from"./card-DIWmzy0c.js";import{u as X,i as M,a as L}from"./useChart-BQT9jMdm.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as pe,j as e,a as b,r as s,b as fe,u as he,d as ve,e as ge,f as L,g as je,I as ye,S as N,B as k,P as Q,h as ee,L as te,G as se,R as be,i as ae,k as Ne,l as ke,T as we,E as ne,m as Ce,n as Ee,o as Se,C as _e,p as ze,q as qe,s as Le,t as Re,v as De}from"./index-BpXGbBE-.js";/**
import{c as pe,j as e,a as b,r as s,b as fe,u as he,d as ve,e as ge,f as L,g as je,I as ye,S as N,B as k,P as Q,h as ee,L as te,G as se,R as be,i as ae,k as Ne,l as ke,T as we,E as ne,m as Ce,n as Ee,o as Se,C as _e,p as ze,q as qe,s as Le,t as Re,v as De}from"./index-C0Fp67iE.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{j as s,a}from"./index-BpXGbBE-.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
import{j as s,a}from"./index-C0Fp67iE.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r}from"./index-BpXGbBE-.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
import{r}from"./index-C0Fp67iE.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
+1 -1
View File
@@ -8,7 +8,7 @@
<link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-BpXGbBE-.js"></script>
<script type="module" crossorigin src="/assets/index-C0Fp67iE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D6K8zE2F.css">
</head>
<body>