Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c72474d123 | ||
|
|
d534cae0e1 | ||
|
|
265edc9009 | ||
|
|
0fdba1944e | ||
|
|
b7ad7ef5dc | ||
|
|
48aa5dc07f |
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -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 + ' 个(竞赛/荣誉记录附带证书演示附件)');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
// 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)}
|
||||
|
||||
@@ -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'), '邀请码跨重启持久化');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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.
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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>
|
||||
|
||||