feat(admin): user management - list + high-risk delete with full cascade (lsky, user db, attachments, auth)
This commit is contained in:
+1
-1
@@ -1374,7 +1374,7 @@ function listFootprints(d, query) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { handleCommand, getDb, pullWire, applyWireBatch };
|
return { handleCommand, getDb, pullWire, applyWireBatch, close: () => { try { db.close(); } catch {} } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 默认单例(开发桥接 / seed 兼容) ----------
|
// ---------- 默认单例(开发桥接 / seed 兼容) ----------
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export const adminCreateInvite = (code: string, remark: string, baseUrl?: string
|
|||||||
adminCall<{ code: string }>('/admin/invites/create', { code, remark }, baseUrl);
|
adminCall<{ code: string }>('/admin/invites/create', { code, remark }, baseUrl);
|
||||||
export const adminUpdateInvite = (id: number, patch: { remark?: string; valid?: boolean }, baseUrl?: string) =>
|
export const adminUpdateInvite = (id: number, patch: { remark?: string; valid?: boolean }, baseUrl?: string) =>
|
||||||
adminCall<{ id: number }>('/admin/invites/update', { id, ...patch }, baseUrl);
|
adminCall<{ id: number }>('/admin/invites/update', { id, ...patch }, baseUrl);
|
||||||
|
export const adminDeleteUser = (userId: number | string, baseUrl?: string) =>
|
||||||
|
adminCall<{ ok: true; username: string; remote_deleted: number }>('/admin/users/delete', { user_id: userId }, baseUrl);
|
||||||
export const adminDeleteInvite = (id: number, baseUrl?: string) =>
|
export const adminDeleteInvite = (id: number, baseUrl?: string) =>
|
||||||
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
|
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
import { Check, Copy, Pencil, Plus, RefreshCw, TicketCheck, Trash2, Users2, X } from 'lucide-react';
|
import { AlertTriangle, Check, Copy, Pencil, Plus, RefreshCw, TicketCheck, Trash2, Users2, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
adminCreateInvite, adminDeleteInvite, adminGenerateInvites, adminListInvites,
|
adminCreateInvite, adminDeleteInvite, adminDeleteUser, adminGenerateInvites, adminListInvites,
|
||||||
adminListUsers, adminUpdateInvite, getServerUrl, getToken,
|
adminListUsers, adminUpdateInvite, getServerUrl, getToken,
|
||||||
type InviteRow,
|
type InviteRow,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { Badge, Button, Card, CardBody, CardHeader, Empty, Input } from '@/components/ui';
|
import { Badge, Button, Card, CardBody, CardHeader, Dialog, Empty, Input, useToast } from '@/components/ui';
|
||||||
import { fmtDateTime, fmtSize } from '@/lib/format';
|
import { fmtDateTime, fmtSize } from '@/lib/format';
|
||||||
|
|
||||||
type Filter = 'all' | 'valid' | 'used' | 'disabled';
|
type Filter = 'all' | 'valid' | 'used' | 'disabled';
|
||||||
@@ -18,6 +18,7 @@ function InviteStatus({ i }: { i: InviteRow }) {
|
|||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const toast = useToast();
|
||||||
const base = getServerUrl();
|
const base = getServerUrl();
|
||||||
const meQ = useQuery({ queryKey: ['me-admin'], queryFn: async () => {
|
const meQ = useQuery({ queryKey: ['me-admin'], queryFn: async () => {
|
||||||
const m = await import('@/lib/api'); return m.serverMe(base);
|
const m = await import('@/lib/api'); return m.serverMe(base);
|
||||||
@@ -34,9 +35,22 @@ export function AdminPage() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editDraft, setEditDraft] = useState('');
|
const [editDraft, setEditDraft] = useState('');
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
|
const [delUser, setDelUser] = useState<{ id: number | string; username: string } | null>(null);
|
||||||
|
const [delConfirm, setDelConfirm] = useState('');
|
||||||
|
|
||||||
|
|
||||||
const invalidate = () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); };
|
const invalidate = () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); };
|
||||||
const onErr = (e: unknown) => setActionError((e as Error).message || '操作失败');
|
const onErr = (e: unknown) => setActionError((e as Error).message || '操作失败');
|
||||||
|
const delUserMut = useMutation({
|
||||||
|
mutationFn: (id: number | string) => adminDeleteUser(id, base),
|
||||||
|
onSuccess: (d) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
setDelUser(null); setDelConfirm(''); setActionError('');
|
||||||
|
toast('success', '用户「' + d.username + '」及其全部数据已删除');
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const genMut = useMutation({
|
const genMut = useMutation({
|
||||||
mutationFn: () => adminGenerateInvites(5, base),
|
mutationFn: () => adminGenerateInvites(5, base),
|
||||||
@@ -94,6 +108,39 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 用户管理:查看 + 高危删除 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={'用户管理(' + (usersQ.data?.total ?? 0) + ')'} extra={<Users2 size={14} className="text-ink-3" />} />
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<div className="divide-y divide-line">
|
||||||
|
{(usersQ.data?.users ?? []).map(u => {
|
||||||
|
const isMe = String(u.id) === String(meQ.data?.user_id ?? '');
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-[13px] font-medium text-ink-1">{u.username}</span>
|
||||||
|
{u.is_admin && <Badge color="var(--brand-text)">管理员</Badge>}
|
||||||
|
{isMe && <Badge color="var(--text-3)">当前登录</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="num mt-0.5 text-[11px] text-ink-3">
|
||||||
|
{fmtDateTime(u.created_at)} · 记录 {u.records} · 附件 {u.attachments}({fmtSize(u.attachment_bytes || 0)})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isMe && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-danger hover:bg-danger-subtle"
|
||||||
|
onClick={() => { setDelUser({ id: u.id, username: u.username }); setDelConfirm(''); }}>
|
||||||
|
<Trash2 size={13} /> 删除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!(usersQ.data?.users ?? []).length && <Empty title="暂无用户" />}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title={'邀请码(' + (invitesQ.data?.total ?? 0) + ')'} extra={
|
<CardHeader title={'邀请码(' + (invitesQ.data?.total ?? 0) + ')'} extra={
|
||||||
@@ -231,6 +278,37 @@ export function AdminPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={!!delUser}
|
||||||
|
onClose={() => setDelUser(null)}
|
||||||
|
width={460}
|
||||||
|
title="删除用户(高危操作)"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setDelUser(null)}>取消</Button>
|
||||||
|
<Button variant="primary" className="!bg-danger !text-white" disabled={delConfirm !== delUser?.username || delUserMut.isPending}
|
||||||
|
onClick={() => delUser && delUserMut.mutate(delUser.id)}>
|
||||||
|
<AlertTriangle size={13} /> 永久删除
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="flex flex-col gap-3 text-[12.5px]">
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-danger/30 bg-danger-subtle px-3 py-2.5 text-danger">
|
||||||
|
<AlertTriangle size={15} className="mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<b>此操作不可撤销!</b>将永久删除用户「{delUser?.username}」的全部数据:
|
||||||
|
<ul className="mt-1 list-disc pl-4 text-[12px] leading-relaxed">
|
||||||
|
<li>账号与登录会话(所有设备将被登出)</li>
|
||||||
|
<li>全部记录、成绩、维度与统计数据</li>
|
||||||
|
<li>全部附件文件(服务器本地与图床 COS 上的副本)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-ink-2">请输入用户名 <b className="text-danger">{delUser?.username}</b> 以确认:</div>
|
||||||
|
<Input value={delConfirm} onChange={e => setDelConfirm(e.target.value)} placeholder={delUser?.username ?? ''} autoFocus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 管理(仅 is_admin 登录会话) ----
|
// ---- 管理(仅 is_admin 登录会话) ----
|
||||||
if (route === '/admin/users' || route === '/admin/invites' || route === '/admin/demo-seed' || route === '/admin/attachments/sync-lsky' || route.startsWith('/admin/invites/')) {
|
if (route === '/admin/users' || route === '/admin/users/delete' || route === '/admin/invites' || route === '/admin/demo-seed' || route === '/admin/attachments/sync-lsky' || route.startsWith('/admin/invites/')) {
|
||||||
let auth;
|
let auth;
|
||||||
try { auth = authUser(req); }
|
try { auth = authUser(req); }
|
||||||
catch (e) { return send(e.code || 401, false, e.message); }
|
catch (e) { return send(e.code || 401, false, e.message); }
|
||||||
@@ -177,6 +177,47 @@ const server = http.createServer(async (req, res) => {
|
|||||||
.map(u => ({ ...u, is_admin: !!u.is_admin }));
|
.map(u => ({ ...u, is_admin: !!u.is_admin }));
|
||||||
return send(200, true, { total: users.length, users });
|
return send(200, true, { total: users.length, users });
|
||||||
}
|
}
|
||||||
|
// 用户删除:高危操作,级联清理(图床文件 → 用户库 → 附件目录 → 认证数据)
|
||||||
|
if (req.method === 'POST' && route === '/admin/users/delete') {
|
||||||
|
const body = await readJson(req);
|
||||||
|
const uid = Number(body.user_id);
|
||||||
|
const target = db.prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(uid);
|
||||||
|
if (!target) return send(404, false, '用户不存在');
|
||||||
|
if (target.id === auth.user_id) return send(400, false, '不能删除当前登录的账号自己');
|
||||||
|
if (target.is_admin && db.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get().c <= 1) {
|
||||||
|
return send(400, false, '不能删除最后一位管理员');
|
||||||
|
}
|
||||||
|
// 图床远端文件(尽力而为,不阻塞删除)
|
||||||
|
let remote = 0;
|
||||||
|
try {
|
||||||
|
const atts = commandsFor(target.id).getDb().prepare('SELECT remote_key, remote_thumb_key FROM attachment WHERE deleted_at IS NULL').all();
|
||||||
|
for (const a of atts) {
|
||||||
|
for (const k of [a.remote_key, a.remote_thumb_key]) {
|
||||||
|
if (k && LSKY_API && LSKY_TOKEN) { try { await lskyDelete(k); remote++; } catch {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// 关闭库句柄并删除用户库 + 附件目录
|
||||||
|
try { commandsFor(target.id).close(); } catch {}
|
||||||
|
userCommands.delete(target.id);
|
||||||
|
if (demoUserId === target.id) demoUserId = undefined;
|
||||||
|
for (const f of [target.id + '.db', target.id + '.db-wal', target.id + '.db-shm']) {
|
||||||
|
try { fs.rmSync(path.join(USERS_DIR, f), { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
try { fs.rmSync(path.join(USERS_DIR, String(target.id)), { recursive: true, force: true }); } catch {}
|
||||||
|
// 认证库级联清理
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM current WHERE user_id=?').run(target.id);
|
||||||
|
db.prepare('DELETE FROM attachments WHERE user_id=?').run(target.id);
|
||||||
|
db.prepare('DELETE FROM devices WHERE user_id=?').run(target.id);
|
||||||
|
db.prepare('DELETE FROM users WHERE id=?').run(target.id);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (e) { db.exec('ROLLBACK'); throw e; }
|
||||||
|
console.log('[footprints-sync] 用户已删除:', target.username, '(图床文件 ' + remote + ' 个)');
|
||||||
|
return send(200, true, { ok: true, username: target.username, remote_deleted: remote });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && route === '/admin/invites') {
|
if (req.method === 'GET' && route === '/admin/invites') {
|
||||||
const invites = db.prepare(`
|
const invites = db.prepare(`
|
||||||
SELECT i.id, i.code, i.remark, i.created_at, i.used_by, i.used_at, i.valid,
|
SELECT i.id, i.code, i.remark, i.created_at, i.used_by, i.used_at, i.valid,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{r as g,f as v,j as t,S as j,t as S}from"./index-CshikkKk.js";import{C as c,a as d,b as x}from"./card-D52MYnXt.js";import{u as N,i as u,a as m}from"./useChart-Dwieg_pe.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:"lg: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-BxnC8qm_.js";import{C as c,a as d,b as x}from"./card-DI4NY5IV.js";import{u as N,i as u,a as m}from"./useChart-BO8q7Igh.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:"lg: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
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-CshikkKk.js";import{C,a as M,b as _}from"./card-D52MYnXt.js";import{u as X,i as Q,a as K}from"./useChart-Dwieg_pe.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 R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-BxnC8qm_.js";import{C,a as M,b as _}from"./card-DI4NY5IV.js";import{u as X,i as Q,a as K}from"./useChart-BO8q7Igh.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
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as E,u as P,d as S,r as p,f as U,g as F,j as e,B as f,l as q,a9 as B,F as L,H as k,S as z,I as M,aa as Q,ab as _,ac as v,ad as K}from"./index-CshikkKk.js";/**
|
import{c as E,u as P,d as S,r as p,f as U,g as F,j as e,B as f,l as q,aa as B,F as L,H as k,S as z,I as M,ab as Q,ac as _,ad as v,ae as K}from"./index-BxnC8qm_.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-CshikkKk.js";/**
|
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-BxnC8qm_.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as L,d as oe,f as Q,r as z,j as e,B as E,ae as re,H as O,S as G,aa as Y,P as ee,a6 as ce,ac as U,I as H,T as de,ad as me,v as xe}from"./index-CshikkKk.js";import{C as Z,a as X,b as J}from"./card-D52MYnXt.js";/**
|
import{c as L,d as oe,f as Q,r as z,j as e,B as E,af as re,H as O,S as G,ab as Y,P as ee,a7 as ce,ad as U,I as H,T as de,ae as me,v as xe}from"./index-BxnC8qm_.js";import{C as Z,a as X,b as J}from"./card-DI4NY5IV.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-CshikkKk.js";import{C as Y}from"./card-D52MYnXt.js";/**
|
import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-BxnC8qm_.js";import{C as Y}from"./card-DI4NY5IV.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1 +1 @@
|
|||||||
import{j as s,a}from"./index-CshikkKk.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-BxnC8qm_.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
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{r}from"./index-CshikkKk.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-BxnC8qm_.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};
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
<link rel="icon" href="/icons/pwa-192.png" />
|
<link rel="icon" href="/icons/pwa-192.png" />
|
||||||
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
|
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
|
||||||
<title>足迹 Footprints</title>
|
<title>足迹 Footprints</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CshikkKk.js"></script>
|
<script type="module" crossorigin src="/assets/index-BxnC8qm_.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B0aJKtiv.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-kfEUZTBu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user