Compare commits

...
6 Commits
44 changed files with 461 additions and 249 deletions
+47 -20
View File
@@ -5,6 +5,7 @@ import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { loadCert, CERT_BY_TITLE } from './demo-certs.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', '.data');
@@ -412,6 +413,27 @@ export const DEMO_ROWS = [
['证书技能', '计算机技术与软件专业技术资格(软件设计师)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
'备考软件设计师(中级)', '', ['软考'], ['职业发展'],
{}, {}],
['竞赛', 'ACM-ICPC 国际大学生程序设计竞赛', '核心', '国家级', '银奖', '中国大学生程序设计竞赛组委会', '2025-10-18', '2025-10-19', 'finished',
'赛区内团队排名前 5%,与队友分工负责图论与动态规划、数据结构与搜索方向', '亚洲区域赛银奖', ['算法', '团队协作'], ['学业深造'],
{ my_work: '图论建模与状态转移设计', team_members: '王五、赵六' }, {}],
['竞赛', '中国大学生计算机设计大赛', '主导', '国家级', '二等奖', '中国大学生计算机设计大赛组织委员会', '2025-05-10', '2025-07-30', 'finished',
'作品《基于流量特征的入侵检测系统》,负责整体方案设计与检测算法实现', '全国总决赛二等奖', ['计算机', '安全'], ['学业深造'],
{ my_work: '方案设计、检测算法实现', team_members: '张三、李四' }, {}],
['竞赛', '第三届"强网杯"全国大学生网络安全挑战赛', '核心', '国家级', '二等奖', '战略支援部队信息工程大学', '2024-11-09', '2024-11-11', 'finished',
'CTF 攻防模式,负责 Web 渗透与 RSA 共模攻击类密码题', '半决赛二等奖', ['CTF', '密码', '安全'], ['职业发展'],
{ my_work: 'Web 渗透与密码工具编写', team_members: '张三、王五' }, {}],
['竞赛', '"挑战杯"全国大学生课外学术科技作品竞赛(省赛)', '核心', '省市级', '三等奖', '共青团重庆市委员会', '2025-03-15', '2025-06-20', 'finished',
'作品「基于流量特征的入侵检测研究」,负责研究报告撰写与答辩', '省赛三等奖', ['科研', '学术'], ['学业深造'],
{ my_work: '研究报告撰写' }, {}],
['竞赛', '全国大学生英语竞赛', '参加', '省市级', '三等奖', '高等学校大学外语教学研究会', '2024-04-14', '2024-04-14', 'finished',
'C 类笔试,词汇与阅读优势明显', 'C类省级三等奖', ['英语'], ['学业深造'],
{ score: '88' }, {}],
['竞赛', '院"新生杯"程序设计竞赛', '参加', '二级学院', '一等奖', '计算机网络学院', '2023-11-18', '2023-11-18', 'finished',
'大一入门竞赛,C 语言实现图书管理系统', '院级一等奖', ['编程'], ['学业深造'],
{}, {}],
['竞赛', '全国大学生密码技术竞赛', '参加', '国家级', null, '中国密码学会', '2025-11-20', null, 'ongoing',
'组队备战国赛(SM9 聚群签名方向)', '', ['密码', '学术'], ['学业深造'],
{}, {}],
['实习实践', '某安全公司渗透测试实习生', '参加', null, null, '某网络安全科技有限公司', '2025-07-01', '2025-08-25', 'finished',
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
@@ -446,27 +468,27 @@ export const DEMO_PROFILE = {
resume: DEMO_RESUME
};
// 演示附件用真实 PNG(1x1,可在预览查看并进入综测证明材料)
const DEMO_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点, 类别][] }
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点, 类别][] }
// 演示成绩与成绩梯度公式一致:分段线性 gp(90→4.0 至 100→5.0,80→3.0 至 89→3.9 …),
// 学期 GPA 为分类加权(必修 1.2 / 专业选修 1.1),与 grades_overview 的综合算法结果吻合。
export const DEMO_SEMESTERS = [
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
['高等数学(上)', 5, 78, 3.0, '必修课'], ['大学英语(一)', 3, 85, 3.7, '必修课'], ['程序设计基础', 4, 91, 4.0, '必修课'],
['思想道德与法治', 2, 88, 3.9, '必修课'], ['体育(一)', 1, 82, 3.5, '必修课']
{ term: '2023-2024-1', gpa: 4.13, rank: 48, total: 156, courses: [
['高等数学(上)', 5, 78, 2.8, '必修课'], ['大学英语(一)', 3, 85, 3.5, '必修课'], ['程序设计基础', 4, 91, 4.1, '必修课'],
['思想道德与法治', 2, 88, 3.8, '必修课'], ['体育(一)', 1, 82, 3.2, '必修课']
]},
{ term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [
['高等数学(下)', 5, 84, 3.7, '必修课'], ['线性代数', 3, 76, 2.9, '必修课'], ['数据结构', 4, 90, 4.0, '必修课'],
['大学英语(二)', 3, 87, 3.8, '必修课'], ['中国近现代史纲要', 2, 84, 3.5, '必修课'], ['体育(二)', 1, 86, 3.8, '必修课']
{ term: '2023-2024-2', gpa: 4.15, rank: 36, total: 156, courses: [
['高等数学(下)', 5, 84, 3.4, '必修课'], ['线性代数', 3, 76, 2.6, '必修课'], ['数据结构', 4, 90, 4.0, '必修课'],
['大学英语(二)', 3, 87, 3.7, '必修课'], ['中国近现代史纲要', 2, 84, 3.4, '必修课'], ['体育(二)', 1, 86, 3.6, '必修课']
]},
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [
['概率论与数理统计', 3, 88, 3.9, '必修课'], ['操作系统', 4, 92, 4.0, '必修课'], ['计算机网络', 4, 86, 3.8, '必修课'],
['数据库原理', 3, 94, 4.0, '必修课'], ['马克思主义基本原理', 2, 81, 3.3, '必修课'], ['体育(三)', 1, 88, 3.9, '必修课']
{ term: '2024-2025-1', gpa: 4.63, rank: 11, total: 156, courses: [
['概率论与数理统计', 3, 88, 3.8, '必修课'], ['操作系统', 4, 92, 4.18, '必修课'], ['计算机网络', 4, 86, 3.6, '必修课'],
['数据库原理', 3, 94, 4.36, '必修课'], ['马克思主义基本原理', 2, 81, 3.1, '必修课'], ['体育(三)', 1, 88, 3.8, '必修课']
]},
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
['软件工程', 3, 90, 4.0, '必修课'], ['信息安全导论', 3, 95, 4.0, '必修课'], ['密码学基础', 3, 89, 3.9, '必修课'],
['Web 应用开发', 2, 96, 4.0, '专业选修课'], ['数字逻辑', 3, 79, 3.1, '必修课'], ['体育(四)', 1, 90, 4.0, '必修课']
{ term: '2024-2025-2', gpa: 4.65, rank: 7, total: 156, courses: [
['软件工程', 3, 90, 4.0, '必修课'], ['信息安全导论', 3, 95, 4.45, '必修课'], ['密码学基础', 3, 89, 3.9, '必修课'],
['Web 应用开发', 2, 96, 4.55, '专业选修课'], ['数字逻辑', 3, 79, 2.9, '必修课'], ['体育(四)', 1, 90, 4.0, '必修课']
]}
];
@@ -1261,11 +1283,16 @@ function listFootprints(d, query) {
dimension_ids: (dimNames || []).map((nm) => dimIds[nm]).filter(Boolean),
}});
n++;
// 演示附件:竞赛/荣誉记录附带证书图片(可预览、可进综测证明材料)
if (/竞赛|优秀共青团员/.test(String(title)) && status === 'finished') {
try {
handleCommand('attachment_add', { owner_id: fr.item.id, file_name: '获奖证书.png', mime: 'image/png', size: DEMO_PNG.length, data_b64: DEMO_PNG, thumb_b64: DEMO_PNG });
} catch {}
// 演示附件:按标题匹配对应证书图片(可预览、可进综测证明材料)
const certKey = CERT_BY_TITLE.find((p2) => p2[0].test(String(title)))?.[1];
if (certKey && status === 'finished') {
const cert = loadCert(certKey);
if (cert) {
try {
const att = handleCommand('attachment_add', { owner_id: fr.id, ...cert });
console.log('[demo-seed] 证书附件已挂到「' + title + '」:', att.item?.id || att.item?.uuid || '?');
} catch (e) { console.log('[demo-seed] 证书附件失败 ' + title + ':', e.message); }
}
}
}
let sems = 0;
+35
View File
@@ -0,0 +1,35 @@
// 演示证书图片索引:demo 播种时按记录标题匹配,附加对应证书 WebP 附件。
// 图片为 Pillow 离线绘制的获奖证书(720x508 WebP + 480px 缩略图),仅作演示数据。
// 如需替换:直接覆盖 demo-certs/*.webp 即可,无需改代码。
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DIR = path.join(__dirname, 'demo-certs');
/**
* 主名(如 cert-ctf)→ 证书附件负载(attachment_add 所需格式);文件缺失返回 undefined
*/
export function loadCert(assetName) {
try {
const data = fs.readFileSync(path.join(DIR, assetName + '.webp'));
let thumbB64 = '';
try { thumbB64 = fs.readFileSync(path.join(DIR, assetName + '.thumb.webp')).toString('base64'); } catch {}
return {
file_name: '获奖证书.webp', mime: 'image/webp',
data_b64: data.toString('base64'), thumb_b64: thumbB64, size: data.length,
};
} catch { return undefined; }
}
/** 记录标题 → 证书资源名(demo 播种时逐条匹配) */
export const CERT_BY_TITLE = [
[/信息安全竞赛/, 'cert-ctf'],
[/数学建模/, 'cert-math'],
[/蓝桥杯/, 'cert-lanqiao'],
[/计算机设计大赛/, 'cert-cd'],
[/英语竞赛/, 'cert-eng'],
[/ACM-ICPC/, 'cert-acm'],
[/优秀共青团员/, 'cert-wusi'],
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+8 -50
View File
@@ -1,54 +1,12 @@
// 演示数据:仅当足迹表为空时写入,用于界面验收
import { getDb, buildSearchText, migrateSync, uuidv7, hlcNext, DEMO_ROWS, DEMO_SEMESTERS } from './db.mjs';
// 演示数据播种(本地开发兼容入口)
// 与云端 demo 账号共用同一份 demo_seed 命令层逻辑(app/server/db.mjs),
// 演示内容统一维护于 DEMO_ROWS / DEMO_SEMESTERS,竞赛/荣誉记录附带证书演示附件。
import { handleCommand, getDb, migrateSync } from './db.mjs';
const db = getDb();
const count = db.prepare('SELECT COUNT(*) AS c FROM footprint').get().c;
if (count > 0) {
console.log('已存在 ' + count + ' 条记录,跳过演示数据(如需重建请删除 app/.data 目录)');
const r = handleCommand('demo_seed', {});
if (!r.seeded) {
console.log('已存在 ' + r.footprints + ' 条记录 / ' + r.semesters + ' 个学期,跳过演示数据(如需重建请删除 app/.data 目录)');
process.exit(0);
}
const types = Object.fromEntries(
db.prepare('SELECT id, name FROM footprint_type').all().map(r => [r.name, r.id])
);
const dims = Object.fromEntries(
db.prepare('SELECT id, name FROM dimension').all().map(r => [r.name, r.id])
);
// [type, title, role, level, grade, organizer, start, end, status, desc, result, tags, dims, extras, metrics]
// 数据统一维护在 ./db.mjs(DEMO_ROWS / DEMO_SEMESTERS)
const ins = db.prepare(`INSERT INTO footprint (type_id, title, role, level, award_grade, organizer,
start_date, end_date, status, description, result, tags, search_text, extras, metrics)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
const setDim = db.prepare('INSERT OR IGNORE INTO footprint_dimension (footprint_id, dimension_id) VALUES (?,?)');
for (const [t, title, role, level, grade, organizer, start, end, status, desc, result, tags, dimNames, extras, metrics] of DEMO_ROWS) {
const search = buildSearchText(title, desc, result, tags, extras);
const r = ins.run(types[t], title, role, level, grade, organizer, start, end, status, desc, result,
JSON.stringify(tags), search, JSON.stringify(extras), JSON.stringify(metrics));
const id = Number(r.lastInsertRowid);
for (const name of dimNames) {
if (dims[name]) setDim.run(id, dims[name]);
}
}
console.log('已写入 ' + DEMO_ROWS.length + ' 条演示记录');
// ---------- 学年学科成绩演示 ----------
const semIns = db.prepare('INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)');
const couIns = db.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)');
for (const s of DEMO_SEMESTERS) {
const h1 = hlcNext(db);
const sid = Number(semIns.run(s.term, s.gpa, s.rank, s.total, uuidv7(), h1, 'seed').lastInsertRowid);
for (const [name, credit, score, gp] of s.courses) {
couIns.run(sid, name, credit, score, gp, uuidv7(), hlcNext(db), 'seed');
}
}
console.log('已写入 ' + DEMO_SEMESTERS.length + ' 个学期、' + DEMO_SEMESTERS.reduce((a, s) => a + s.courses.length, 0) + ' 门课程成绩');
migrateSync(getDb());
console.log('同步元数据(uuid/hlc/origin)已回填');
console.log('演示数据已写入:足迹 ' + r.footprints + ' 条、学期成绩 ' + r.semesters + ' 个(竞赛/荣誉记录附带证书演示附件)');
+2
View File
@@ -195,6 +195,8 @@ async function adminGet<T>(path: string, baseUrl?: string): Promise<T> {
export interface InviteRow {
id: number; code: string; remark: string | null; created_at: string;
used_by: string | null; used_at: string | null; valid: boolean;
/** 已使用邀请码对应的账号已被注销(状态固定展示为「已销户」) */
holder_gone?: boolean;
used_by_name?: string | null; created_by_name?: string | null;
}
export interface AdminUserRow {
+3 -3
View File
@@ -75,9 +75,9 @@ function normLevel(level?: string | null): keyof typeof ZC_DEFAULT_RULES.table2
function normGrade(grade?: string | null): keyof typeof ZC_DEFAULT_RULES.table2['国家级'] | null {
const v = (grade ?? '').trim();
if (!v) return null;
if (/特等|一等|冠军|第1名|第一名/.test(v)) return '一等奖';
if (/二等|亚军|第2名|第3名|第二名|第三名/.test(v)) return '二等奖';
if (/三等|第[4-6]名|第四名|第五名|第六名/.test(v)) return '三等奖';
if (/特等|一等|金(色)?奖|冠军|第1名|第一名/.test(v)) return '一等奖';
if (/二等|银(色)?奖|亚军|第2名|第3名|第二名|第三名/.test(v)) return '二等奖';
if (/三等|铜(色)?奖|第[4-6]名|第四名|第五名|第六名/.test(v)) return '三等奖';
if (/优秀|优胜|第[7-9]名|第七名/.test(v)) return '优秀奖';
return null;
}
+7 -26
View File
@@ -9,9 +9,10 @@ import {
import { Badge, Button, Card, CardBody, CardHeader, Dialog, Empty, Input, useToast } from '@/components/ui';
import { fmtDateTime, fmtSize } from '@/lib/format';
type Filter = 'all' | 'valid' | 'used' | 'disabled';
type Filter = 'all' | 'valid' | 'used' | 'gone' | 'disabled';
function InviteStatus({ i }: { i: InviteRow }) {
if (i.used_by && i.holder_gone) return <Badge color="var(--danger)">已销户</Badge>;
if (i.used_by) return <Badge color="var(--text-3)">已使用</Badge>;
return i.valid ? <Badge color="var(--success)">可用</Badge> : <Badge color="var(--warning)">已停用</Badge>;
}
@@ -86,10 +87,10 @@ export function AdminPage() {
});
const invites = useMemo(() => invitesQ.data?.invites ?? [], [invitesQ.data]);
const users = usersQ.data?.users ?? [];
const filtered = useMemo(() => invites.filter((i) => {
if (filter === 'valid') return i.valid && !i.used_by;
if (filter === 'used') return !!i.used_by;
if (filter === 'used') return !!i.used_by && !i.holder_gone;
if (filter === 'gone') return !!i.used_by && !!i.holder_gone;
if (filter === 'disabled') return !i.valid && !i.used_by;
return true;
}), [invites, filter]);
@@ -158,7 +159,7 @@ export function AdminPage() {
</CardBody>
</Card>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title={'邀请码(' + (invitesQ.data?.total ?? 0) + ')'} extra={
<div className="flex gap-1.5">
@@ -193,7 +194,7 @@ export function AdminPage() {
)}
<div className="flex gap-1 border-b border-line px-4 py-2 text-[12px]">
{([['all', '全部'], ['valid', '可用'], ['used', '已使用'], ['disabled', '已停用']] as [Filter, string][]).map(([k, label]) => (
{([['all', '全部'], ['valid', '可用'], ['used', '已使用'], ['gone', '已销户'], ['disabled', '已停用']] as [Filter, string][]).map(([k, label]) => (
<button key={k}
className={'rounded-md px-2.5 py-1 transition-colors ' + (filter === k ? 'bg-brand-subtle font-medium text-brand-text' : 'text-ink-3 hover:bg-surface-3')}
onClick={() => setFilter(k)}>{label}</button>
@@ -275,26 +276,6 @@ export function AdminPage() {
)}
</CardBody>
</Card>
<Card>
<CardHeader title={'用户(' + (usersQ.data?.total ?? 0) + ')'} />
<CardBody className="p-0">
{users.length ? (
<div className="divide-y divide-line max-h-[520px] overflow-y-auto">
{users.map((u) => (
<div key={u.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-[12.5px]">
<span className="font-medium text-ink-1">{u.username}</span>
{u.is_admin && <Badge color="var(--brand-text)">管理员</Badge>}
<span className="num ml-auto text-ink-3">{u.devices} 设备 · {u.records} 记录 · {fmtSize(u.attachment_bytes)}</span>
</div>
))}
</div>
) : (
<Empty icon={<Users2 size={32} strokeWidth={1.5} />} title="暂无用户" />
)}
</CardBody>
</Card>
</div>
<Dialog
open={!!delUser}
onClose={() => setDelUser(null)}
@@ -339,7 +320,7 @@ export function AdminPage() {
</>}>
<div className="text-[12.5px] leading-relaxed text-ink-2">
将<b>删除现有 demo 账号及其全部数据</b>,然后重新创建并播种全套虚拟演示数据
(个人信息档案、16 条足迹、4 学期成绩、简历内容)。
(个人信息档案、25 条足迹(含 10 项竞赛获奖与证书附件)、4 学期学科成绩、简历内容)。
<div className="mt-2 text-ink-3">demo / demo1234 · 仅影响演示账号,其他用户不受影响。</div>
</div>
</Dialog>
+206 -61
View File
@@ -1,62 +1,130 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Download, Images, Printer, RotateCcw } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Download, Printer, RotateCcw } from 'lucide-react';
import { attachmentList, attachmentThumb, footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api';
import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types';
import { computeZongce, gradeLabel, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
import { Button, Dialog, Select, useToast } from '@/components/ui';
import { Badge, Button, Dialog, Select, useToast } from '@/components/ui';
interface Evidence { recordId: number; title: string; images: string[] }
interface Evidence { recordId: number; title: string; meta: string; images: string[] }
function esc(t: unknown) { return String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// MHTML:把图片内嵌进 Word(.doc)的可靠方式
// ---------------- Word 导出(MHTML 单文件 .doc,图片内嵌) ----------------
function buildMhtml(html: string, images: { name: string; dataUri: string }[]): Blob {
const boundary = '----=_fp_zongce';
const parts: string[] = [];
parts.push('--' + boundary + '\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n' + html + '\r\n');
parts.push(
'MIME-Version: 1.0\r\n' +
'Content-Type: multipart/related; type="text/html"; boundary="' + boundary + '"\r\n\r\n' +
'--' + boundary + '\r\n' +
'Content-Location: document.html\r\n' +
'Content-Type: text/html; charset="utf-8"\r\n' +
'Content-Transfer-Encoding: 8bit\r\n\r\n' + html + '\r\n',
);
for (const img of images) {
const idx = img.dataUri.indexOf(',');
const meta = img.dataUri.slice(0, idx);
const b64 = img.dataUri.slice(idx + 1);
const mime = /data:([^;]+)/.exec(meta)?.[1] ?? 'image/png';
parts.push('--' + boundary + '\r\nContent-Type: ' + mime + '\r\nContent-Transfer-Encoding: base64\r\nContent-Location: ' + img.name + '\r\n\r\n' + b64 + '\r\n');
parts.push(
'--' + boundary + '\r\n' +
'Content-Location: ' + img.name + '\r\n' +
'Content-Type: ' + mime + '\r\n' +
'Content-Transfer-Encoding: base64\r\n\r\n' + b64 + '\r\n',
);
}
parts.push('--' + boundary + '--\r\n');
return new Blob([parts.join('')], { type: 'multipart/related; type="text/html"; boundary="' + boundary + '"' });
}
const W_TD = 'border:0.75pt solid #4a4a4a;padding:4pt 7pt;font-size:10.5pt;vertical-align:middle';
const W_TH = W_TD + ';background:#e8eaf0;font-weight:bold;font-family:SimHei;letter-spacing:1pt';
/** 综合素质测评表:A4 纵向、黑体标题、表格化分项、附图两列栅格 */
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[]): Blob {
const f = (s: unknown) => esc(s);
const school = f(profile.school || '云川理工学院');
const infoCell = (v: unknown, label: string, half = false) =>
'<td style="' + W_TH + ';width:' + (half ? '16%' : '14%') + '">' + label + '</td><td style="' + W_TD + '">' + f(v || '') + '</td>';
// 分板块明细表
const body = result.sections.map(sec => {
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
const rows = sec.items.map(it =>
'<tr>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999">' + esc(it.title) + '</td>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999;font-size:10pt;color:#555">' + esc(it.note || it.source) + '</td>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999;text-align:right;width:70pt">' + esc(it.score) + '</td></tr>').join('');
return '<h2 style="font-size:13pt;margin:12pt 0 5pt">' + esc(sec.label) + '(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)</h2>' +
'<table style="width:100%;border-collapse:collapse;font-size:11pt">' + rows + '</table>';
'<td style="' + W_TD + '">' + f(it.title) + (it.overridden ? '(已手动修正)' : '') + '</td>' +
'<td style="' + W_TD + ';color:#555;font-size:9.5pt">' + f(it.note || it.source) + '</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(it.score) + '</td></tr>',
).join('');
return (
'<table style="border-collapse:collapse;width:100%;margin:6pt 0 0" border="1">' +
'<tr><td style="' + W_TH + ';width:34%">考评项目</td>' +
'<td style="' + W_TH + '">评分依据</td>' +
'<td style="' + W_TH + ';width:64pt;text-align:center">分值</td></tr>' +
rows +
'<tr><td colspan="2" style="' + W_TD + ';text-align:right;background:#f4f5f7"><b>' + f(sec.label) + '小计</b>(板块上限 ' + f(sec.cap) + ' 分)</td>' +
'<td style="' + W_TD + ';text-align:right;background:#f4f5f7"><b class="num">' + f(subtotal) + '</b></td></tr>' +
'</table>'
);
}).join('');
// 证明材料附图:每条记录一个组块,图两列(约 200pt 宽)
const images: { name: string; dataUri: string }[] = [];
const annex = evidence.map(ev => {
let imgs = '';
ev.images.forEach((uri, i) => {
const name = 'ev' + ev.recordId + '_' + i;
let figNo = 0;
const grids = evidence.map(ev => {
const imgs = ev.images.map(uri => {
figNo++;
const name = 'ev' + ev.recordId + '_' + (images.length);
images.push({ name, dataUri: uri });
imgs += '<div style="font-size:10pt;color:#555">图 ' + (i + 1) + '</div><img src="' + name + '" style="width:300pt">';
});
return '<div style="margin-bottom:10pt"><b style="font-size:11pt">' + esc(ev.title) + '</b>' + imgs + '</div>';
return '<div style="text-align:center"><img src="' + name + '" style="width:196pt;border:0.75pt solid #8a8a8a" alt="附图"><br>' +
'<span style="font-size:9pt;color:#444">图 ' + figNo + ' ' + f(ev.title) + '</span></div>';
}).join('');
return '<tr><td style="' + W_TD + ';width:26%;vertical-align:top;background:#fafafa"><b style="font-size:10pt">' + f(ev.title) + '</b><br>' +
'<span style="font-size:9pt;color:#666">' + f(ev.meta) + '</span></td>' +
'<td style="' + W_TD + '"><table style="width:100%;border-collapse:collapse"><tr><td style="padding:2pt">' + imgs + '</td></tr></table></td></tr>';
}).join('');
const html = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
'<head><meta charset="utf-8"><title>综合素质测评</title></head>' +
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
'<h1 style="text-align:center;font-size:18pt;margin:0 0 6pt">学生综合素质测评</h1>' +
'<div style="text-align:center;color:#555;font-size:11pt">' + esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '</div>' +
'<p style="text-align:center;font-size:13pt;background:#f2f2f2;padding:6pt"><b>综合素质总分:' + esc(result.total) + ' 分</b></p>' +
body + (annex ? '<h2 style="font-size:13pt;margin:16pt 0 6pt">附:证明材料</h2>' + annex : '') +
'<table style="width:100%;border-collapse:collapse;margin-top:24pt;font-size:11pt"><tr>' +
'<td style="width:33%">学生签名:______</td><td style="width:33%">辅导员签名:______</td><td>日期:______</td></tr></table>' +
'</body></html>';
const annex = grids
? '<div style="font-family:SimHei;font-size:12pt;margin:14pt 0 4pt">附:加分证明材料(' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图)</div>' +
'<table style="border-collapse:collapse;width:100%" border="1">' + grids + '</table>'
: '';
const html =
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
'<head><meta charset="utf-8"><title>学生综合素质测评表</title>' +
'<style>' +
'@page WordSection1 { size:595.3pt 841.9pt; margin:56.7pt 62pt 62pt; mso-footer: f1; }' +
'div.WordSection1 { page:WordSection1; }' +
'body { font-family:SimSun,serif; font-size:10.5pt; color:#1a1a1a; }' +
'h1.title { font-family:SimHei; font-size:16pt; text-align:center; letter-spacing:2pt; margin:0 0 4pt; }' +
'p.sub { text-align:center; font-size:10pt; color:#333; margin:0; }' +
'table { border-collapse:collapse; }' +
'.total td { background:#eef1f6; font-family:SimHei; font-size:12pt; }' +
'.num { font-family:Calibri,SimSun; }' +
'</style></head>' +
'<body><div class="WordSection1">' +
'<div style="text-align:center;font-size:10.5pt;letter-spacing:1pt;color:#333">' + school + '</div>' +
'<h1 class="title">学生综合素质测评表</h1>' +
'<p class="sub">测评学年:' + f(termLabel) + '</p>' +
'<table style="border-collapse:collapse;width:100%;margin-top:10pt" border="1">' +
'<tr>' + infoCell(profile.studentId, '学 号') + infoCell(profile.name, '姓 名') + infoCell(profile.gender, '性 别') + '</tr>' +
'<tr>' + infoCell(profile.college, '学 院') + infoCell(profile.major, '专 业') + infoCell(profile.className, '班 级') + '</tr>' +
'<tr>' + infoCell(profile.political, '政治面貌') +
'<td style="' + W_TH + '">测评学年</td><td style="' + W_TD + '">' + f(termLabel) + '</td>' +
'<td style="' + W_TH + '">测评总分</td><td style="' + W_TD + '"><b class="num" style="font-size:12pt">' + f(result.total) + '</b> / 100 分</td></tr>' +
'</table>' +
body +
'<table style="border-collapse:collapse;width:100%;margin-top:8pt" border="1"><tr class="total">' +
'<td style="' + W_TD + ';text-align:left;font-family:SimHei">综合素质测评总分</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(result.total) + '</td></tr></table>' +
annex +
'<table style="width:100%;border-collapse:collapse;margin-top:22pt;font-size:10.5pt"><tr>' +
'<td style="width:25%">学生签名:_____</td>' +
'<td style="width:25%">班长签名:_____</td>' +
'<td style="width:25%">辅导员签名:_____</td>' +
'<td>日期:______</td></tr></table>' +
'<p style="font-size:9pt;color:#555;margin-top:10pt">说明:本表依据《计算机与网络安全学院学生综合素质测评评分细则》自动生成;带「已手动修正」标记的分值为辅导员核准后的手动结果。导出前请核对加分记录与证明材料一致。</p>' +
'</div></body></html>';
return buildMhtml(html, images);
}
@@ -73,6 +141,7 @@ export function AssessmentPage() {
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
const [evidence, setEvidence] = useState<Evidence[]>([]);
const [initOpen, setInitOpen] = useState(false);
const [zoom, setZoom] = useState<{ src: string; title: string; index: number } | null>(null);
useEffect(() => {
if (!stored) return;
@@ -90,10 +159,10 @@ export function AssessmentPage() {
bands: gradesQ.data?.bands, weights: gradesQ.data?.weights,
profile, rules, ov,
}),
[records, gradesQ.data, profile, rules, ov, year]
[records, gradesQ.data, profile, rules, ov, year],
);
// 证明材料:匹配条目对应记录的附件缩略图
// 证明材料:匹配条目对应记录的附件缩略图(每条最多 2 张)
const matchedIds = useMemo(() => {
const ids: number[] = [];
result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) ids.push(it.fromRecord); }));
@@ -109,11 +178,14 @@ export function AssessmentPage() {
try {
const attList = await attachmentList(rid);
const images: string[] = [];
for (const att of attList.items.filter(x => x.id).slice(0, 3)) {
for (const att of attList.items.filter(x => x.id).slice(0, 2)) {
const th = await attachmentThumb(att.id);
if (th.data_b64) images.push('data:' + (att.mime === 'image/jpeg' ? 'image/jpeg' : 'image/webp') + ';base64,' + th.data_b64);
}
if (images.length) out.push({ recordId: rid, title: rec?.title ?? '记录 ' + rid, images });
if (images.length) {
const meta = [rec?.type_name, rec?.level, rec?.award_grade].filter(Boolean).join(' · ');
out.push({ recordId: rid, title: rec?.title ?? '记录 ' + rid, meta, images });
}
} catch {}
}
if (alive) setEvidence(out);
@@ -121,6 +193,13 @@ export function AssessmentPage() {
return () => { alive = false; };
}, [matchedIds.join(',')]);
// 全部放平的图序列(灯箱上一张/下一张用)
const flatImages = useMemo(() => {
const arr: { src: string; title: string }[] = [];
for (const ev of evidence) for (const src of ev.images) arr.push({ src, title: ev.title });
return arr;
}, [evidence]);
const persistOv = (next: ZcOverrides) => {
setOvMap(m => ({ ...m, [year]: next }));
void profileUpsert({ info: profile, zongce: { rules, overrides: { ...ovMap, [year]: next } } }).catch(e => toast('error', e.message));
@@ -133,47 +212,81 @@ export function AssessmentPage() {
const a = document.createElement('a');
a.href = url; a.download = '综合素质测评_' + termLabel + '.doc'; a.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
toast('success', '综测材料已导出' + (evidence.length ? '(含 ' + evidence.length + ' 条证明材料图片)' : ''));
toast('success', '综测表已导出(.doc,含证明材料附图 ' + flatImages.length + ' 张)');
};
const zoomIdx = zoom ? flatImages.findIndex(x => x.src === zoom.src) : -1;
const gotoZoom = (delta: number) => {
const next = zoomIdx + delta;
if (next >= 0 && next < flatImages.length) {
setZoom({ src: flatImages[next].src, title: flatImages[next].title, index: next });
}
};
const totalImgs = flatImages.length;
return (
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-[15px] font-semibold text-ink-1">综合测评(按学年自动计算)</h2>
<div>
<h2 className="text-[15px] font-semibold text-ink-1">综合测评</h2>
<p className="mt-0.5 text-[11.5px] text-ink-3">按学年依据学院细则自动计算,可手动修正,支持导出 Word 测评表</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)}
options={[...Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.split('-').slice(0, 2).join('-')))).map(y => ({ value: y, label: gradeLabel(y, profile.eduStart) })), { value: 'all', label: '全部学年' }]} />
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出材料</Button>
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button>
<Button variant="secondary" onClick={() => setInitOpen(true)}><RotateCcw size={13} /> 初始化</Button>
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button>
</div>
</div>
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-5 shadow-card sm:p-8">
<h1 className="text-center text-[18px] font-bold text-neutral-900">学生综合素质测评</h1>
<div className="mt-1 text-center text-[12px] text-neutral-500">{profile.name ?? ''} · {profile.className ?? ''} · 统计周期:{termLabel}</div>
<div className="print-sheet mx-auto w-full max-w-[820px] rounded-xl border border-line bg-white shadow-card sm:p-8">
<div className="text-center text-[11px] tracking-widest text-neutral-500">{profile.school || '云川理工学院'}</div>
<h1 className="mt-0.5 text-center text-[19px] font-bold tracking-wide text-neutral-900">学生综合素质测评表</h1>
<div className="mt-1 text-center text-[12px] text-neutral-500">测评学年:{termLabel}</div>
<div className="mt-5 rounded-md bg-neutral-100 px-4 py-2 text-center text-[15px] font-bold text-neutral-900">
综合素质总分:<span className="num">{result.total}</span> 分
{/* 学生信息栏 */}
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-neutral-300 bg-neutral-300 text-[12.5px] sm:grid-cols-3">
{[
['学 号', profile.studentId], ['姓 名', profile.name], ['性 别', profile.gender],
['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className],
['政治面貌', profile.political], ['统计周期', termLabel],
].map(([label, val]) => (
<div key={label as string} className="flex bg-white">
<span className="w-[62px] shrink-0 bg-neutral-50 px-2 py-1.5 font-medium text-neutral-500">{label}</span>
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-neutral-900">{val ?? '—'}</span>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-center rounded-lg bg-gradient-to-r from-neutral-900 to-neutral-700 px-4 py-2.5 text-white">
<span className="text-[12px] opacity-80">综合素质测评总分</span>
<span className="num ml-3 text-[26px] font-bold leading-none">{result.total}</span>
<span className="ml-1.5 text-[12px] opacity-80">/ 100 分</span>
</div>
{result.sections.map(sec => (
<div key={sec.key} className="mt-5">
<div className="flex items-baseline justify-between border-b-2 border-neutral-800 pb-1">
<span className="text-[13.5px] font-bold text-neutral-900">{sec.label}(上限 {sec.cap} 分)</span>
<span className="num text-[12.5px] text-neutral-600">
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)} 分
<div key={sec.key} className="mt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-brand"></span>
<span className="text-[13.5px] font-bold text-neutral-900">{sec.label}</span>
<Badge className="no-print" color="var(--text-3)">上限 {sec.cap} 分</Badge>
</div>
<span className="num rounded-md bg-neutral-100 px-2 py-0.5 text-[12px] font-semibold text-neutral-700">
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)}
</span>
</div>
<div className="mt-2 flex flex-col gap-1.5">
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-neutral-200 bg-neutral-50/60 p-3">
{sec.items.map(it => (
<div key={it.itemKey} className="flex items-center gap-2 text-[12.5px]">
<span className="min-w-0 flex-1">
<span className="text-neutral-900">{it.title}</span>
<span className="ml-2 text-[11px] text-neutral-400">{it.source}</span>
{it.note && <div className="text-[11px] text-neutral-400">{it.note}</div>}
{it.note && <div className="text-[11px] leading-relaxed text-neutral-400">{it.note}</div>}
</span>
<input type="number" step="0.1" className="num w-[72px] rounded-sm border border-neutral-200 px-1 py-0.5 text-right text-[12px]"
<input type="number" step="0.1" className="num w-[72px] rounded-md border border-neutral-200 bg-white px-1.5 py-0.5 text-right text-[12px] focus:border-brand focus:outline-none"
value={it.score} onChange={e => {
if (it.itemKey === 'conduct' || it.itemKey === 'integrity') {
persistOv({ ...ov, [it.itemKey]: Number(e.target.value) });
@@ -189,27 +302,59 @@ export function AssessmentPage() {
</div>
))}
{evidence.length > 0 && (
<div className="mt-6 border-t border-neutral-300 pt-4">
<div className="mb-3 text-[13.5px] font-bold text-neutral-900">附:证明材料</div>
{evidence.map(ev => (
<div key={ev.recordId} className="mb-4">
<div className="text-[12px] font-semibold text-neutral-800">{ev.title}</div>
<div className="mt-1.5 flex flex-wrap gap-3">
{ev.images.map((src, i) => (
<img key={i} src={src} alt={'证明 ' + (i + 1)} className="max-h-[180px] rounded border border-neutral-200" />
))}
</div>
{/* 证明材料:图片画廊 */}
{(evidence.length > 0 || totalImgs > 0) && (
<div className="mt-7">
<div className="flex items-center justify-between border-t border-neutral-200 pt-4">
<div className="flex items-center gap-2">
<Images size={15} className="text-neutral-500" />
<span className="text-[13.5px] font-bold text-neutral-900">附:加分证明材料</span>
<span className="text-[11.5px] text-neutral-400">共 {evidence.length} 项记录 · {totalImgs} 张图片(点击可放大)</span>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{evidence.map(ev => (
<div key={ev.recordId} className="rounded-lg border border-neutral-200 bg-white p-2.5 transition-shadow hover:shadow-md">
<div className="flex items-baseline justify-between gap-2">
<div className="min-w-0 truncate text-[12.5px] font-semibold text-neutral-800">{ev.title}</div>
<div className="shrink-0 text-[10.5px] text-neutral-400">{ev.meta}</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{ev.images.map((src, i) => (
<button key={i} onClick={() => setZoom({ src, title: ev.title, index: flatImages.findIndex(x => x.src === src) })}
className="group overflow-hidden rounded-md border border-neutral-200 bg-neutral-50">
<img src={src} alt={'证明 ' + (i + 1)} className="aspect-[4/3] w-full object-cover transition-transform group-hover:scale-[1.03]" />
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值;
「恢复自动」清除本学年全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
</div>
</div>
{/* 图片灯箱 */}
<Dialog
open={!!zoom}
onClose={() => setZoom(null)}
width={920}
title={zoom?.title ?? '' + (zoom ? '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : '')}
footer={
<div className="flex w-full items-center justify-between">
<Button variant="secondary" disabled={zoomIdx <= 0} onClick={() => gotoZoom(-1)}><ChevronLeft size={13} /> 上一张</Button>
<span className="num text-[12px] text-ink-3">{zoom ? (zoomIdx + 1) + ' / ' + totalImgs : ''}</span>
<Button variant="primary" disabled={zoomIdx >= totalImgs - 1} onClick={() => gotoZoom(1)}>下一张 <ChevronRight size={13} /></Button>
</div>
}>
{zoom && <img src={zoom.src} alt={zoom.title} className="mx-auto max-h-[74vh] rounded-md" />}
</Dialog>
<Dialog
open={initOpen}
onClose={() => setInitOpen(false)}
+3 -3
View File
@@ -57,7 +57,7 @@ const rootMe = await api(A, 'GET', '/auth/me', undefined, T);
ok(rootMe.body?.data?.username === 'root' && rootMe.body?.data?.is_admin === true, 'root 是管理员', rootMe.body);
const fp = await api(A, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D);
ok(fp.body?.data?.total === 18, '演示足迹 18 条(播种成功)', fp.body?.data?.total);
ok(fp.body?.data?.total === 25, '演示足迹 25 条(播种成功,含 10 项竞赛+证书附件)', fp.body?.data?.total);
const grades = await api(A, 'POST', '/api/grades_overview', {}, D);
ok(grades.body?.data?.semesters?.length === 4, '演示学期 4 个', grades.body?.data?.semesters?.length);
const tl = await api(A, 'POST', '/api/timeline', { query: {} }, D);
@@ -167,7 +167,7 @@ ok(reseed.body?.data?.seeded === false, '重复播种幂等跳过', reseed.body)
// demo 重建路由(管理员):重置 demo 数据为初始播种态
const re2 = await api(A, 'POST', '/admin/demo-rebuild', {}, T);
ok(re2.status === 200 && re2.body?.data?.seeded?.footprints === 18, 'demo 重建路由可达', re2.body);
ok(re2.status === 200 && re2.body?.data?.seeded?.footprints === 25, 'demo 重建路由可达', re2.body);
// ---------- Phase B:同一数据目录「重启」(新模块实例 + 新端口) ----------
console.log('\n== Phase B:重启后(持久化 + 不重复播种) ==');
@@ -180,7 +180,7 @@ const rootLogin2 = await api(B, 'POST', '/auth/login', { username: 'root', passw
ok(rootLogin2.status === 200, '重启后 root 可登录', rootLogin2.body);
const T2 = rootLogin2.body.data.token;
const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D2);
ok(fp2.body?.data?.total === 18, '重启后仍是 18 条(测试足迹已被级联删除)', fp2.body?.data?.total);
ok(fp2.body?.data?.total === 25, '重启后仍是 25 条(测试足迹已被级联删除)', fp2.body?.data?.total);
const list2 = (await api(B, 'GET', '/admin/invites', undefined, T2)).body.data.invites;
ok(list2.some((i) => i.code === 'XIONG-FRIEND-2025'), '邀请码跨重启持久化');
+2
View File
@@ -112,6 +112,8 @@ CREATE TABLE IF NOT EXISTS attachments (
);`);
const hasRemark = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='remark'").get().c;
if (Number(hasRemark) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN remark TEXT;");
const hasHolderGone = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='holder_gone'").get().c;
if (Number(hasHolderGone) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN holder_gone INTEGER NOT NULL DEFAULT 0;");
return db;
}
+52 -4
View File
@@ -28,12 +28,52 @@ async function lskyUpload(buf, fileName, mime) {
return { url, thumb_url: sanitizeImageUrl(j?.data?.thumbnail_url) || undefined, key: j.data.key };
}
function remoteAttsOf(uid) {
try { return commandsFor(uid).getDb().prepare('SELECT remote_key, remote_thumb_key, remote_url, remote_thumb FROM attachment').all(); }
catch { return []; }
}
// blob 目录与 attachments.mjs/db.mjs 同源(DATA_DIR env 或 cwd/data);删除账号时整目录清空
function userBlobDir(uid) {
return path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'blobs', String(uid));
}
// 递归删除目录并统计实际移除的文件个数
function rmDirCounted(dir) {
let n = 0;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
for (const e of entries) {
const fp = path.join(dir, e.name);
if (e.isDirectory()) { n += rmDirCounted(fp); try { fs.rmdirSync(fp); } catch {} }
else { try { fs.unlinkSync(fp); n++; } catch {} }
}
try { fs.rmdirSync(dir); } catch {}
return n;
}
// 用户档案库附件清单(rel_path 相对 USERS_DIR;删除账号时物理清文件)
function localAttachmentFiles(uid) {
const out = new Set();
try {
for (const a of commandsFor(uid).getDb().prepare('SELECT rel_path, thumb_rel_path FROM attachment').all()) {
for (const rel of [a.rel_path, a.thumb_rel_path]) if (rel) out.add(String(rel));
}
} catch {}
return out;
}
function rmLocalAttachmentFiles(uid) {
let n = 0;
for (const rel of localAttachmentFiles(uid)) {
try { fs.rmSync(path.join(USERS_DIR, rel), { force: true }); n++; } catch {}
try { fs.rmdirSync(path.dirname(path.join(USERS_DIR, rel))); } catch {}
}
return n;
}
async function deleteUserCascade(db, target) {
// 图床远端:附件 remote keys + 用户资产(头像等)
let remote = 0;
let localFiles = 0;
if (LSKY_API && LSKY_TOKEN) {
try {
const atts = commandsFor(target.id).getDb().prepare('SELECT remote_key, remote_thumb_key FROM attachment WHERE deleted_at IS NULL').all();
const atts = remoteAttsOf(target.id);
for (const a of atts) {
for (const k of [a.remote_key, a.remote_thumb_key]) {
if (k) { try { await lskyDelete(k); remote++; } catch {} }
@@ -46,22 +86,30 @@ async function deleteUserCascade(db, target) {
} catch {}
}
try { db.prepare('DELETE FROM user_assets WHERE user_id=?').run(target.id); } catch {}
// 本地落盘数据:档案库 blob 目录、档案库附件文件、sqlite 三件套 —— 全部清空不占空间
try { localFiles += rmLocalAttachmentFiles(target.id); } 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 {}
try { localFiles += rmDirCounted(path.join(USERS_DIR, String(target.id))); } catch {}
try { localFiles += rmDirCounted(userBlobDir(target.id)); } catch {}
console.log('[footprints-sync] 用户 ' + target.id + ' 数据已销毁: 图床' + remote + ' 个副本, 本地 ' + localFiles + ' 个文件');
db.exec('BEGIN');
try {
db.prepare('DELETE FROM tokens WHERE user_id=?').run(target.id);
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 changes 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);
// 其 used 邀请码标记为「已销户」
db.prepare('UPDATE invite_codes SET holder_gone=1 WHERE used_by=?').run(target.id);
db.exec('COMMIT');
} catch (e) { db.exec('ROLLBACK'); throw e; }
try { db.exec('VACUUM'); } catch {} // 回收空间
return remote;
}
async function lskyDelete(key) {
@@ -242,13 +290,13 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && route === '/admin/invites') {
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, i.holder_gone,
u.username AS used_by_name, cu.username AS created_by_name
FROM invite_codes i
LEFT JOIN users u ON u.id = i.used_by
LEFT JOIN users cu ON cu.id = i.created_by
ORDER BY i.id DESC`).all()
.map(i => ({ ...i, valid: !!i.valid }));
.map(i => ({ ...i, valid: !!i.valid, holder_gone: !!i.holder_gone }));
return send(200, true, { total: invites.length, invites });
}
if (req.method === 'POST' && route === '/admin/invites/generate') {
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
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-D-R037K4.js";import{C as c,a as d,b as x}from"./card-Br1zrGHT.js";import{u as N,i as u,a as m}from"./useChart-59NNyLSS.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-Dplf5hVV.js";import{C as c,a as d,b as x}from"./card-C932WdWm.js";import{u as N,i as u,a as m}from"./useChart-D3ki8T58.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,4 +1,4 @@
import{c as $,f as O,j as e,E as H,w as U,u as k,d as C,r as u,g as y,x as W,y as J,B as j,P as E,I as g,z as Y,C as A,A as V,D as B,F as X,m as S,l as Q,T as q,H as Z,J as z,K as M,S as T,M as D}from"./index-D-R037K4.js";import{C as w,a as G,b as F}from"./card-Br1zrGHT.js";import{u as ee,i as K,a as P}from"./useChart-59NNyLSS.js";import{u as se,i as te,b as ae,a as re,c as ne,d as le,e as ie,f as oe}from"./echarts-BIWefrIc.js";/**
import{c as $,f as O,j as e,E as H,w as U,u as k,d as C,r as u,g as y,x as W,y as J,B as j,P as E,I as g,z as Y,C as A,A as V,D as B,F as X,m as S,l as Q,T as q,H as Z,J as z,K as M,S as T,M as D}from"./index-Dplf5hVV.js";import{C as w,a as G,b as F}from"./card-C932WdWm.js";import{u as ee,i as K,a as P}from"./useChart-D3ki8T58.js";import{u as se,i as te,b as ae,a as re,c as ne,d as le,e as ie,f as oe}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 E,u as P,d as S,r as p,f as q,g as F,j as e,B as f,l as B,ac as L,H as z,J as k,S as M,I as Q,ad as _,ae as K,af as v,ag as T}from"./index-D-R037K4.js";/**
import{c as E,u as P,d as S,r as p,f as q,g as F,j as e,B as f,l as B,ac as L,H as z,J as k,S as M,I as Q,ad as _,ae as K,af as v,ag as T}from"./index-Dplf5hVV.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -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-D-R037K4.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-Dplf5hVV.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
@@ -1,4 +1,4 @@
import{c as D,u as K,d as I,f as L,r,g as q,j as e,B as p,P as F,N as O,m as _,l as H,T as P,C as R,O as G,H as J,J as i,I as j,Q as U,S as z,U as V,V as W,q as X}from"./index-D-R037K4.js";import{C as Y}from"./card-Br1zrGHT.js";/**
import{c as D,u as K,d as I,f as L,r,g as q,j as e,B as p,P as F,N as O,m as _,l as H,T as P,C as R,O as G,H as J,J as i,I as j,Q as U,S as z,U as V,V as W,q as X}from"./index-Dplf5hVV.js";import{C as Y}from"./card-C932WdWm.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-D-R037K4.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-Dplf5hVV.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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
import{c as t}from"./index-Dplf5hVV.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const e=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],c=t("ChevronRight",e);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const a=[["path",{d:"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2",key:"143wyd"}],["path",{d:"M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6",key:"1itne7"}],["rect",{x:"6",y:"14",width:"12",height:"8",rx:"1",key:"1ue0tg"}]],n=t("Printer",a);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const o=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],i=t("RotateCcw",o);export{c as C,n as P,i as R};
@@ -1,11 +0,0 @@
import{c as t}from"./index-D-R037K4.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const e=[["path",{d:"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2",key:"143wyd"}],["path",{d:"M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6",key:"1itne7"}],["rect",{x:"6",y:"14",width:"12",height:"8",rx:"1",key:"1ue0tg"}]],c=t("Printer",e);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const a=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],h=t("RotateCcw",a);export{c as P,h as R};
@@ -1 +1 @@
import{r}from"./index-D-R037K4.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-Dplf5hVV.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};
+2 -2
View File
@@ -9,8 +9,8 @@
<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-D-R037K4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9uCsgi6.css">
<script type="module" crossorigin src="/assets/index-Dplf5hVV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8H8oQlD.css">
</head>
<body>
<div id="root"></div>