feat(grades): gpa formula system - linear per-band gp (start~top), course category weights (editable), formula display, seed aligned to school standard

This commit is contained in:
root
2026-10-17 15:30:15 +08:00
parent f075f45b7c
commit a0e2ded426
21 changed files with 159 additions and 79 deletions
+54 -26
View File
@@ -317,12 +317,12 @@ function ensureBuiltin(db) {
['维度:兴趣生活', '兴趣生活', '#db2777', 40] ['维度:兴趣生活', '兴趣生活', '#db2777', 40]
]); ]);
seed('grade_band', ['label', 'color', 'min', 'max', 'sort', 'uuid', 'hlc', 'origin'], [ seed('grade_band', ['label', 'color', 'min', 'max', 'sort', 'gp', 'gp_top', 'uuid', 'hlc', 'origin'], [
['梯度:优秀', '优秀', '#16803c', 90, 100, 10], ['梯度:优秀', '优秀', '#16803c', 90, 101, 10, 4.0, 5.0],
['梯度:良好', '良好', '#0891b2', 80, 90, 20], ['梯度:良好', '良好', '#0891b2', 80, 90, 20, 3.0, 3.9],
['梯度:中等', '中等', '#d97706', 70, 80, 30], ['梯度:中等', '中等', '#d97706', 70, 80, 30, 2.0, 2.9],
['梯度:及格', '及格', '#ea580c', 60, 70, 40], ['梯度:及格', '及格', '#ea580c', 60, 70, 40, 1.0, 1.9],
['梯度:不及格', '不及格', '#dc2626', 0, 60, 50] ['梯度:不及格', '不及格', '#dc2626', 0, 60, 50, 0, 0]
]); ]);
seed('metric_def', ['key', 'label', 'unit', 'agg', 'section', 'uuid', 'hlc', 'origin'], [ seed('metric_def', ['key', 'label', 'unit', 'agg', 'section', 'uuid', 'hlc', 'origin'], [
@@ -338,9 +338,16 @@ function openDb(dbPath) {
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8')); d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
// 个人信息表兜底(确保任何挂载形态下都存在) // 个人信息表兜底(确保任何挂载形态下都存在)
d.exec("CREATE TABLE IF NOT EXISTS profile (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, content TEXT NOT NULL DEFAULT '{}', deleted_at TEXT, origin TEXT, hlc TEXT)"); d.exec("CREATE TABLE IF NOT EXISTS profile (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, content TEXT NOT NULL DEFAULT '{}', deleted_at TEXT, origin TEXT, hlc TEXT)");
// 绩点公式:梯度带携带每档绩点(gp),各校规则不同可自行修改 // 绩点公式:梯度带携带每档绩点(gp 起)与满档绩点(gp_top,档内线性折算),各校规则不同可自行修改
const hasGp = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('grade_band') WHERE name='gp'").get().c; const hasGp = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('grade_band') WHERE name='gp'").get().c;
if (Number(hasGp) === 0) d.exec("ALTER TABLE grade_band ADD COLUMN gp REAL"); if (Number(hasGp) === 0) d.exec("ALTER TABLE grade_band ADD COLUMN gp REAL");
const hasGpTop = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('grade_band') WHERE name='gp_top'").get().c;
if (Number(hasGpTop) === 0) d.exec("ALTER TABLE grade_band ADD COLUMN gp_top REAL");
// 课程类别(权重系数)
const hasCat = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('course_grade') WHERE name='category'").get().c;
if (Number(hasCat) === 0) d.exec("ALTER TABLE course_grade ADD COLUMN category TEXT");
// 应用设置(绩点权重等)
d.exec("CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
migrateSync(d); migrateSync(d);
ensureBuiltin(d); ensureBuiltin(d);
alignBuiltinUuids(d); alignBuiltinUuids(d);
@@ -417,20 +424,20 @@ export const DEMO_PROFILE = {
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点][] } // 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点][] }
export const DEMO_SEMESTERS = [ export const DEMO_SEMESTERS = [
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [ { term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
['高等数学(上)', 5, 78, 3.0], ['大学英语(一)', 3, 85, 3.7], ['程序设计基础', 4, 91, 4.0], ['高等数学(上)', 5, 78, 3.0, '必修课'], ['大学英语(一)', 3, 85, 3.7, '必修课'], ['程序设计基础', 4, 91, 4.0, '必修课'],
['思想道德与法治', 2, 88, 3.9], ['体育(一)', 1, 82, 3.5] ['思想道德与法治', 2, 88, 3.9, '必修课'], ['体育(一)', 1, 82, 3.5, '必修课']
]}, ]},
{ term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [ { term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [
['高等数学(下)', 5, 84, 3.7], ['线性代数', 3, 76, 2.9], ['数据结构', 4, 90, 4.0], ['高等数学(下)', 5, 84, 3.7, '必修课'], ['线性代数', 3, 76, 2.9, '必修课'], ['数据结构', 4, 90, 4.0, '必修课'],
['大学英语(二)', 3, 87, 3.8], ['中国近现代史纲要', 2, 84, 3.5], ['体育(二)', 1, 86, 3.8] ['大学英语(二)', 3, 87, 3.8, '必修课'], ['中国近现代史纲要', 2, 84, 3.5, '必修课'], ['体育(二)', 1, 86, 3.8, '必修课']
]}, ]},
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [ { 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, 88, 3.9, '必修课'], ['操作系统', 4, 92, 4.0, '必修课'], ['计算机网络', 4, 86, 3.8, '必修课'],
['数据库原理', 3, 94, 4.0], ['马克思主义基本原理', 2, 81, 3.3] ['数据库原理', 3, 94, 4.0, '必修课'], ['马克思主义基本原理', 2, 81, 3.3, '必修课']
]}, ]},
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [ { term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
['软件工程', 3, 90, 4.0], ['信息安全导论', 3, 95, 4.0], ['密码学基础', 3, 89, 3.9], ['软件工程', 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] ['Web 应用开发', 2, 96, 4.0, '专业选修课'], ['数字逻辑', 3, 79, 3.1, '必修课'], ['体育(四)', 1, 90, 4.0, '必修课']
]} ]}
]; ];
@@ -892,7 +899,7 @@ function listFootprints(d, query) {
if (score == null) return null; if (score == null) return null;
let hit = bands.find(b => score >= b.min && score < b.max); let hit = bands.find(b => score >= b.min && score < b.max);
if (!hit) hit = bands.find(b => score >= b.min && score <= b.max); if (!hit) hit = bands.find(b => score >= b.min && score <= b.max);
return hit ? { id: hit.id, label: hit.label, color: hit.color, gp: hit.gp ?? null } : null; return hit ? { id: hit.id, label: hit.label, color: hit.color, gp: hit.gp ?? null, gpTop: hit.gp_top ?? null } : null;
}; };
const semesters = d.prepare('SELECT * FROM semester WHERE ' + DEL + ' ORDER BY term DESC').all(); const semesters = d.prepare('SELECT * FROM semester WHERE ' + DEL + ' ORDER BY term DESC').all();
const activeSemIds = new Set(semesters.map(s => s.id)); const activeSemIds = new Set(semesters.map(s => s.id));
@@ -922,9 +929,15 @@ function listFootprints(d, query) {
.filter(s => s.gpa != null) .filter(s => s.gpa != null)
.sort((a, b) => a.term.localeCompare(b.term)) .sort((a, b) => a.term.localeCompare(b.term))
.map(s => ({ term: s.term, gpa: s.gpa, avg: s.stats.avg })); .map(s => ({ term: s.term, gpa: s.gpa, avg: s.stats.avg }));
let weights = { 必修课: 1.2, 专业选修课: 1.1, 人文素质选修课: 1.0, 其他: 1.0 };
try {
const wr = d.prepare("SELECT value FROM app_settings WHERE key='gpa_formula'").get();
if (wr) weights = { ...weights, ...JSON.parse(wr.value).weights };
} catch {}
return { return {
semesters, semesters,
bands, bands,
weights,
overall: { overall: {
avg: overallAvg, avg: overallAvg,
credits: Math.round(courses.reduce((a, c) => a + (c.credit || 0), 0) * 10) / 10, credits: Math.round(courses.reduce((a, c) => a + (c.credit || 0), 0) * 10) / 10,
@@ -982,14 +995,14 @@ function listFootprints(d, query) {
withTx(d, () => { withTx(d, () => {
if (id) { if (id) {
const h = hlcNext(d); const h = hlcNext(d);
d.prepare('UPDATE course_grade SET semester_id=?, course_name=?, credit=?, score=?, grade_point=?, hlc=? WHERE id=?') d.prepare('UPDATE course_grade SET semester_id=?, course_name=?, credit=?, score=?, grade_point=?, category=?, hlc=? WHERE id=?')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, h, id); .run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, p.category ?? null, h, id);
touchOplog(d, 'course_grade', getUuid(d, 'course_grade', id), 'upsert', h); touchOplog(d, 'course_grade', getUuid(d, 'course_grade', id), 'upsert', h);
} else { } else {
const h = hlcNext(d); const h = hlcNext(d);
const uuid = uuidv7(); const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)') id = Number(d.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, category, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, uuid, h, getDeviceId(d)).lastInsertRowid); .run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, p.category ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'course_grade', uuid, 'upsert', h); touchOplog(d, 'course_grade', uuid, 'upsert', h);
} }
}); });
@@ -1010,14 +1023,14 @@ function listFootprints(d, query) {
withTx(d, () => { withTx(d, () => {
if (id) { if (id) {
const h = hlcNext(d); const h = hlcNext(d);
d.prepare('UPDATE grade_band SET label=?, color=?, min=?, max=?, sort=?, gp=?, hlc=? WHERE id=?') d.prepare('UPDATE grade_band SET label=?, color=?, min=?, max=?, sort=?, gp=?, gp_top=?, hlc=? WHERE id=?')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, h, id); .run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, p.gpTop ?? null, h, id);
touchOplog(d, 'grade_band', getUuid(d, 'grade_band', id), 'upsert', h); touchOplog(d, 'grade_band', getUuid(d, 'grade_band', id), 'upsert', h);
} else { } else {
const h = hlcNext(d); const h = hlcNext(d);
const uuid = uuidv7(); const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO grade_band (label, color, min, max, sort, gp, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)') id = Number(d.prepare('INSERT INTO grade_band (label, color, min, max, sort, gp, gp_top, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?)')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, uuid, h, getDeviceId(d)).lastInsertRowid); .run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, p.gpTop ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'grade_band', uuid, 'upsert', h); touchOplog(d, 'grade_band', uuid, 'upsert', h);
} }
}); });
@@ -1125,6 +1138,21 @@ function listFootprints(d, query) {
return { ok: true }; return { ok: true };
} }
case 'gpa_settings_get': {
const row = d.prepare("SELECT value FROM app_settings WHERE key='gpa_formula'").get();
let weights = { 必修课: 1.2, 专业选修课: 1.1, 人文素质选修课: 1.0, 其他: 1.0 };
if (row) { try { weights = { ...weights, ...JSON.parse(row.value).weights }; } catch {} }
return { weights };
}
case 'gpa_settings_upsert': {
const p = payload;
const weights = p.weights ?? {};
d.prepare("INSERT INTO app_settings (key, value) VALUES ('gpa_formula', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
.run(JSON.stringify({ weights }));
return { ok: true, weights };
}
case 'profile_get': { case 'profile_get': {
// 个人信息(单行 JSON):供个人信息页与简历生成使用 // 个人信息(单行 JSON):供个人信息页与简历生成使用
const row = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get(); const row = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get();
@@ -1209,8 +1237,8 @@ function listFootprints(d, query) {
for (const s of DEMO_SEMESTERS) { for (const s of DEMO_SEMESTERS) {
const r = handleCommand('semester_upsert', { term: s.term, gpa: s.gpa, rank: s.rank, total_students: s.total }); const r = handleCommand('semester_upsert', { term: s.term, gpa: s.gpa, rank: s.rank, total_students: s.total });
const sid = r.item.id; const sid = r.item.id;
for (const [name, credit, score, gp] of s.courses) { for (const [name, credit, score, gp, category] of s.courses) {
handleCommand('course_upsert', { semester_id: sid, course_name: name, credit, score, grade_point: gp }); handleCommand('course_upsert', { semester_id: sid, course_name: name, credit, score, grade_point: gp, category });
} }
sems++; sems++;
} }
+5 -1
View File
@@ -123,7 +123,7 @@ export const gradesOverview = () => call<GradesOverview>('grades_overview');
export const semesterUpsert = (p: { id?: number; term: string; gpa?: number | null; rank?: number | null; total_students?: number | null }) => export const semesterUpsert = (p: { id?: number; term: string; gpa?: number | null; rank?: number | null; total_students?: number | null }) =>
call<{ item: { id: number } }>('semester_upsert', p); call<{ item: { id: number } }>('semester_upsert', p);
export const semesterDelete = (id: number) => call<{ ok: true }>('semester_delete', { id }); export const semesterDelete = (id: number) => call<{ ok: true }>('semester_delete', { id });
export const courseUpsert = (p: { id?: number; semester_id: number; course_name: string; credit: number; score?: number | null; grade_point?: number | null }) => export const courseUpsert = (p: { id?: number; semester_id: number; course_name: string; credit: number; score?: number | null; grade_point?: number | null; category?: string | null }) =>
call<{ item: { id: number } }>('course_upsert', p); call<{ item: { id: number } }>('course_upsert', p);
export const courseDelete = (id: number) => call<{ ok: true }>('course_delete', { id }); export const courseDelete = (id: number) => call<{ ok: true }>('course_delete', { id });
export const bandUpsert = (p: GradeBand) => call<{ item: { id: number } }>('band_upsert', p); export const bandUpsert = (p: GradeBand) => call<{ item: { id: number } }>('band_upsert', p);
@@ -218,6 +218,10 @@ export const adminDeleteUser = (userId: number | string, baseUrl?: string) =>
export const adminDeleteInvite = (id: number, baseUrl?: string) => export const adminDeleteInvite = (id: number, baseUrl?: string) =>
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl); adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
// ---- 绩点公式设置 ----
export const gpaSettingsGet = () => call<{ weights: Record<string, number> }>('gpa_settings_get', {});
export const gpaSettingsUpsert = (weights: Record<string, number>) => call<{ ok: true; weights: Record<string, number> }>('gpa_settings_upsert', { weights });
// ---- 个人信息 / 简历 ---- // ---- 个人信息 / 简历 ----
export const profileGet = () => call<{ content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig } }>('profile_get', {}); export const profileGet = () => call<{ content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig } }>('profile_get', {});
export const profileUpsert = (content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig }) => export const profileUpsert = (content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig }) =>
+3
View File
@@ -196,6 +196,7 @@ export interface GradeBand {
max: number; max: number;
sort: number; sort: number;
gp?: number | null; gp?: number | null;
gpTop?: number | null;
} }
export interface BandInfo { id: number; label: string; color: string; } export interface BandInfo { id: number; label: string; color: string; }
@@ -207,6 +208,7 @@ export interface CourseGrade {
credit: number; credit: number;
score?: number | null; score?: number | null;
grade_point?: number | null; grade_point?: number | null;
category?: string | null;
band?: BandInfo | null; band?: BandInfo | null;
} }
@@ -230,6 +232,7 @@ export interface GradesOverview {
gpa_trend: { term: string; gpa: number; avg: number | null }[]; gpa_trend: { term: string; gpa: number; avg: number | null }[];
tier_dist: { label: string; color: string; count: number }[]; tier_dist: { label: string; color: string; count: number }[];
}; };
weights?: Record<string, number>;
} }
// ---------- 个人信息 ---------- // ---------- 个人信息 ----------
+62 -17
View File
@@ -5,9 +5,9 @@ import { BarChart, LineChart, PieChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'; import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { Plus, Trash2, Pencil, Calculator, GraduationCap } from 'lucide-react'; import { Plus, Trash2, Pencil, Calculator, GraduationCap } from 'lucide-react';
import { bandDelete, bandUpsert, courseDelete, courseUpsert, gradesOverview, semesterDelete, semesterUpsert } from '@/lib/api'; import { bandDelete, bandUpsert, courseDelete, courseUpsert, gpaSettingsUpsert, gradesOverview, semesterDelete, semesterUpsert } from '@/lib/api';
import type { CourseGrade, GradeBand, SemesterGrades } from '@/lib/types'; import type { CourseGrade, GradeBand, SemesterGrades } from '@/lib/types';
import { Badge, Button, Card, CardBody, CardHeader, Confirm, Dialog, Empty, Field, Input, useToast } from '@/components/ui'; import { Badge, Button, Card, CardBody, CardHeader, Confirm, Dialog, Empty, Field, Input, Select, useToast } from '@/components/ui';
import { useChart, inkColor, useThemeTick } from '@/hooks/useChart'; import { useChart, inkColor, useThemeTick } from '@/hooks/useChart';
echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]); echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
@@ -17,20 +17,31 @@ function termYear(term: string): string {
return m ? m[1] + '~' + m[2] + ' 学年' : '其他学期'; return m ? m[1] + '~' + m[2] + ' 学年' : '其他学期';
} }
function autoGpa(courses: CourseGrade[], bands?: { min: number; max: number; gp?: number | null }[]): number | null { function autoGpa(courses: CourseGrade[], bands?: { min: number; max: number; gp?: number | null; gpTop?: number | null }[], weights?: Record<string, number>): number | null {
// 按梯度公式补算缺失绩点:分数落在哪一档 → 该档绩点(各校规则可在梯度设置中修改) // 绩点公式(各校规则不同,梯度设置中可改):
// 课程绩点 = 档内线性折算(分数落在哪档 → gp(档首)~gpTop(档顶) 插值;未填 gpTop 则取固定档绩点)
// 课程学分绩点 = 课程绩点 × 课程权重系数 × 课程学分
// 平均学分绩点 GPA = Σ(课程学分绩点) ÷ Σ(课程学分)
const gpOf = (c: CourseGrade): number | null => { const gpOf = (c: CourseGrade): number | null => {
if (c.grade_point != null) return c.grade_point; if (c.grade_point != null) return c.grade_point;
if (bands && c.score != null) { if (bands && c.score != null) {
const hit = bands.find(b => c.score! >= b.min && (c.score! < b.max || (b.max >= 100 && c.score! <= b.max))); const b = bands.find(x => c.score! >= x.min && (c.score! < x.max || (x.max >= 100 && c.score! <= x.max)));
if (hit && hit.gp != null) return hit.gp; if (b && b.gp != null) {
if (b.gpTop != null) {
const topScore = b.max >= 100 ? b.max : b.max - 1;
const slope = (b.gpTop - b.gp) / Math.max(1, topScore - b.min);
return Math.round((b.gp + (c.score! - b.min) * slope) * 100) / 100;
}
return b.gp;
}
} }
return null; return null;
}; };
const wOf = (c: CourseGrade): number => (weights ? (weights[c.category ?? '其他'] ?? weights['其他'] ?? 1) : 1);
const withGp = courses.map(c => ({ ...c, grade_point: gpOf(c) })).filter(c => c.grade_point != null && c.credit > 0); const withGp = courses.map(c => ({ ...c, grade_point: gpOf(c) })).filter(c => c.grade_point != null && c.credit > 0);
if (!withGp.length) return null; if (!withGp.length) return null;
const credits = withGp.reduce((a, c) => a + c.credit, 0); const credits = withGp.reduce((a, c) => a + c.credit, 0);
const weighted = withGp.reduce((a, c) => a + (c.grade_point as number) * c.credit, 0); const weighted = withGp.reduce((a, c) => a + (c.grade_point as number) * wOf(c) * c.credit, 0);
return Math.round((weighted / credits) * 100) / 100; return Math.round((weighted / credits) * 100) / 100;
} }
@@ -105,7 +116,7 @@ export function GradesPage() {
</div> </div>
{/* 成绩梯度设置 */} {/* 成绩梯度设置 */}
<BandEditor bands={s?.bands ?? []} /> <BandEditor bands={s?.bands ?? []} weights={s?.weights} />
{/* 学年学科成绩 */} {/* 学年学科成绩 */}
<div className="mb-3 mt-5 flex items-center justify-between"> <div className="mb-3 mt-5 flex items-center justify-between">
@@ -155,7 +166,7 @@ function MetricCard({ label, value }: { label: string; value: number | string })
} }
// ---------- 梯度设置 ---------- // ---------- 梯度设置 ----------
function BandEditor({ bands }: { bands: GradeBand[] }) { function BandEditor({ bands, weights }: { bands: GradeBand[]; weights?: Record<string, number> }) {
const qc = useQueryClient(); const qc = useQueryClient();
const toast = useToast(); const toast = useToast();
const [adding, setAdding] = useState<GradeBand | null>(null); const [adding, setAdding] = useState<GradeBand | null>(null);
@@ -178,7 +189,7 @@ function BandEditor({ bands }: { bands: GradeBand[] }) {
<Card> <Card>
<CardHeader <CardHeader
title="成绩梯度" title="成绩梯度"
extra={<span className="text-[11px] text-ink-3">各校规则不同:每档可设定级与绩点,「自动绩点」按此计算</span>} extra={<span className="text-[11px] text-ink-3">绩点按档内线性折算(起~顶),各校规则不同可修改</span>}
/> />
<CardBody className="p-0"> <CardBody className="p-0">
<div className="divide-y divide-line"> <div className="divide-y divide-line">
@@ -196,12 +207,35 @@ function BandEditor({ bands }: { bands: GradeBand[] }) {
onCancel={() => setAdding(null)} onCancel={() => setAdding(null)}
/> />
) : ( ) : (
<Button size="sm" variant="secondary" onClick={() => setAdding({ id: 0, label: '', color: '#3056d3', min: 0, max: 60, gp: null, sort: (bands.at(-1)?.sort ?? 0) + 10 })}> <Button size="sm" variant="secondary" onClick={() => setAdding({ id: 0, label: '', color: '#3056d3', min: 0, max: 60, gp: null, gpTop: null, sort: (bands.at(-1)?.sort ?? 0) + 10 })}>
<Plus size={13} /> 添加等级 <Plus size={13} /> 添加等级
</Button> </Button>
)} )}
</div> </div>
</CardBody> </CardBody>
<div className="border-t border-line px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-semibold text-ink-2">课程权重系数</span>
<span className="text-[11px] text-ink-3">课程学分绩点 = 绩点 × 权重 × 学分</span>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(weights ?? {}).map(([cat, w]) => (
<div key={cat} className="flex items-center gap-1.5 rounded-md border border-line bg-surface-2 px-2.5 py-1">
<span className="text-[11.5px] text-ink-2">{cat}</span>
<Input type="number" step="0.1" className="num h-6 w-[56px] border-0 bg-transparent p-0"
value={String(w)} onChange={e => {
const next = { ...(weights ?? {}), [cat]: Number(e.target.value) || 1 };
void gpaSettingsUpsert(next)
.then(() => qc.invalidateQueries({ queryKey: ['grades'] }))
.catch((err: Error) => toast('error', err.message));
}} />
</div>
))}
</div>
<div className="mt-2 text-[11px] text-ink-3">
公式:平均学分绩点 = Σ(课程绩点 × 权重系数 × 学分) ÷ Σ(学分) · 各校规则不同,可直接修改
</div>
</div>
<Confirm <Confirm
open={!!deleting} open={!!deleting}
title="删除等级" title="删除等级"
@@ -243,7 +277,9 @@ function BandRow({ band, onSave, onDelete, onChange, onCancel, isNew }: {
<span className="text-[11.5px] text-ink-3">—</span> <span className="text-[11.5px] text-ink-3">—</span>
<Input type="number" className="num h-6 w-[64px] border-0 bg-transparent p-0" value={String(value.max)} onChange={e => set({ max: Number(e.target.value) })} /> <Input type="number" className="num h-6 w-[64px] border-0 bg-transparent p-0" value={String(value.max)} onChange={e => set({ max: Number(e.target.value) })} />
<span className="ml-1 text-[11px] text-ink-3">绩点</span> <span className="ml-1 text-[11px] text-ink-3">绩点</span>
<Input type="number" step="0.1" className="num h-6 w-[56px] border-0 bg-transparent p-0" value={value.gp != null ? String(value.gp) : ''} placeholder="—" onChange={e => set({ gp: e.target.value === '' ? null : Number(e.target.value) })} /> <Input type="number" step="0.1" className="num h-6 w-[52px] border-0 bg-transparent p-0 text-right" value={value.gp != null ? String(value.gp) : ''} placeholder="起" onChange={e => set({ gp: e.target.value === '' ? null : Number(e.target.value) })} />
<span className="text-[11.5px] text-ink-3">~</span>
<Input type="number" step="0.1" className="num h-6 w-[52px] border-0 bg-transparent p-0" value={value.gpTop != null ? String(value.gpTop) : ''} placeholder="顶" onChange={e => set({ gpTop: e.target.value === '' ? null : Number(e.target.value) })} />
</div> </div>
<div className="flex items-center gap-1.5 sm:ml-auto"> <div className="flex items-center gap-1.5 sm:ml-auto">
{isNew ? ( {isNew ? (
@@ -283,7 +319,7 @@ function SemesterCard({ sem }: { sem: SemesterGrades }) {
const autoGpaMut = useMutation({ const autoGpaMut = useMutation({
mutationFn: () => { mutationFn: () => {
const gpa = autoGpa(sem.courses, overviewQ.data?.bands); const gpa = autoGpa(sem.courses, overviewQ.data?.bands, overviewQ.data?.weights);
if (gpa == null) throw new Error('请先为课程填写绩点'); if (gpa == null) throw new Error('请先为课程填写绩点');
return semesterUpsert({ id: sem.id, term: sem.term, gpa, rank: sem.rank ?? null, total_students: sem.total_students ?? null }); return semesterUpsert({ id: sem.id, term: sem.term, gpa, rank: sem.rank ?? null, total_students: sem.total_students ?? null });
}, },
@@ -318,7 +354,7 @@ function SemesterCard({ sem }: { sem: SemesterGrades }) {
<div className="-mx-1 overflow-x-auto px-1"><table className="w-full border-collapse text-[13px]"> <div className="-mx-1 overflow-x-auto px-1"><table className="w-full border-collapse text-[13px]">
<thead> <thead>
<tr> <tr>
{['课程', '学分', '分数', '等级', '绩点', ''].map(h => ( {['课程', '学分', '分数', '类别', '等级', '绩点', ''].map(h => (
<th key={h} className="border-b border-line bg-surface-2 px-3 py-2 text-left text-[12px] font-semibold text-ink-3">{h}</th> <th key={h} className="border-b border-line bg-surface-2 px-3 py-2 text-left text-[12px] font-semibold text-ink-3">{h}</th>
))} ))}
</tr> </tr>
@@ -334,6 +370,7 @@ function SemesterCard({ sem }: { sem: SemesterGrades }) {
<td className="num border-b border-line px-3 py-2"> <td className="num border-b border-line px-3 py-2">
{c.score != null ? <span className="font-medium">{c.score}</span> : <span className="text-ink-3">未录入</span>} {c.score != null ? <span className="font-medium">{c.score}</span> : <span className="text-ink-3">未录入</span>}
</td> </td>
<td className="border-b border-line px-3 py-2 text-[12px] text-ink-3">{c.category ?? '—'}</td>
<td className="border-b border-line px-3 py-2"> <td className="border-b border-line px-3 py-2">
{c.band ? <Badge color={c.band.color}>{c.band.label}</Badge> : <span className="text-ink-3">—</span>} {c.band ? <Badge color={c.band.color}>{c.band.label}</Badge> : <span className="text-ink-3">—</span>}
</td> </td>
@@ -410,6 +447,7 @@ function CourseAddRow({ sem }: { sem: SemesterGrades }) {
const [credit, setCredit] = useState(''); const [credit, setCredit] = useState('');
const [score, setScore] = useState(''); const [score, setScore] = useState('');
const [gp, setGp] = useState(''); const [gp, setGp] = useState('');
const [category, setCategory] = useState('必修课');
const addMut = useMutation({ const addMut = useMutation({
mutationFn: () => courseUpsert({ mutationFn: () => courseUpsert({
@@ -428,6 +466,7 @@ function CourseAddRow({ sem }: { sem: SemesterGrades }) {
<td className="px-3 py-2"><Input className="h-7 w-[160px]" placeholder="课程名称" value={name} onChange={e => setName(e.target.value)} /></td> <td className="px-3 py-2"><Input className="h-7 w-[160px]" placeholder="课程名称" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" placeholder="0" value={credit} onChange={e => setCredit(e.target.value)} /></td> <td className="px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" placeholder="0" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" className="num h-7 w-[72px]" placeholder="0-100" value={score} onChange={e => setScore(e.target.value)} /></td> <td className="px-3 py-2"><Input type="number" className="num h-7 w-[72px]" placeholder="0-100" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="px-3 py-2"><Select className="h-7 w-[110px]" value={category} onChange={e => setCategory(e.target.value)} options={['必修课', '专业选修课', '人文素质选修课', '其他'].map(o => ({ value: o, label: o }))} /></td>
<td className="px-3 py-2 text-ink-3">—</td> <td className="px-3 py-2 text-ink-3">—</td>
<td className="px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" placeholder="选填" value={gp} onChange={e => setGp(e.target.value)} /></td> <td className="px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" placeholder="选填" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -446,11 +485,12 @@ function CourseEditRow({ sem, course, onDone }: { sem: SemesterGrades; course: C
const [credit, setCredit] = useState(String(course.credit)); const [credit, setCredit] = useState(String(course.credit));
const [score, setScore] = useState(course.score != null ? String(course.score) : ''); const [score, setScore] = useState(course.score != null ? String(course.score) : '');
const [gp, setGp] = useState(course.grade_point != null ? String(course.grade_point) : ''); const [gp, setGp] = useState(course.grade_point != null ? String(course.grade_point) : '');
const [category, setCategory] = useState(course.category ?? '必修课');
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: () => courseUpsert({ mutationFn: () => courseUpsert({
id: course.id, semester_id: sem.id, course_name: name, credit: Number(credit) || 0, id: course.id, semester_id: sem.id, course_name: name, credit: Number(credit) || 0,
score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp) score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp), category
}), }),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); onDone(); }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); onDone(); },
onError: (e: Error) => toast('error', e.message) onError: (e: Error) => toast('error', e.message)
@@ -461,6 +501,7 @@ function CourseEditRow({ sem, course, onDone }: { sem: SemesterGrades; course: C
<td className="border-b border-line px-3 py-2"><Input className="h-7 w-[160px]" value={name} onChange={e => setName(e.target.value)} /></td> <td className="border-b border-line px-3 py-2"><Input className="h-7 w-[160px]" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" value={credit} onChange={e => setCredit(e.target.value)} /></td> <td className="border-b border-line px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" className="num h-7 w-[72px]" value={score} onChange={e => setScore(e.target.value)} /></td> <td className="border-b border-line px-3 py-2"><Input type="number" className="num h-7 w-[72px]" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Select className="h-7 w-[110px]" value={category} onChange={e => setCategory(e.target.value)} options={['必修课', '专业选修课', '人文素质选修课', '其他'].map(o => ({ value: o, label: o }))} /></td>
<td className="border-b border-line px-3 py-2 text-[12px] text-ink-3">保存后按梯度定级</td> <td className="border-b border-line px-3 py-2 text-[12px] text-ink-3">保存后按梯度定级</td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" value={gp} onChange={e => setGp(e.target.value)} /></td> <td className="border-b border-line px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"> <td className="border-b border-line px-3 py-2">
@@ -482,10 +523,11 @@ function MobileCourseAdd({ sem }: { sem: SemesterGrades }) {
const [credit, setCredit] = useState(''); const [credit, setCredit] = useState('');
const [score, setScore] = useState(''); const [score, setScore] = useState('');
const [gp, setGp] = useState(''); const [gp, setGp] = useState('');
const [category, setCategory] = useState('必修课');
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: () => courseUpsert({ mutationFn: () => courseUpsert({
semester_id: sem.id, course_name: name, credit: Number(credit) || 0, semester_id: sem.id, course_name: name, credit: Number(credit) || 0,
score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp) score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp), category
}), }),
onSuccess: () => { onSuccess: () => {
setName(''); setCredit(''); setScore(''); setGp(''); setName(''); setCredit(''); setScore(''); setGp('');
@@ -502,6 +544,7 @@ function MobileCourseAdd({ sem }: { sem: SemesterGrades }) {
<Input type="number" placeholder="分数" value={score} onChange={e => setScore(e.target.value)} /> <Input type="number" placeholder="分数" value={score} onChange={e => setScore(e.target.value)} />
<Input type="number" step="0.1" placeholder="绩点" value={gp} onChange={e => setGp(e.target.value)} /> <Input type="number" step="0.1" placeholder="绩点" value={gp} onChange={e => setGp(e.target.value)} />
</div> </div>
<Select className="mb-2" value={category} onChange={e => setCategory(e.target.value)} options={['必修课', '专业选修课', '人文素质选修课', '其他'].map(o => ({ value: o, label: o }))} />
<Button size="sm" variant="primary" disabled={!name.trim() || saveMut.isPending} onClick={() => saveMut.mutate()}> <Button size="sm" variant="primary" disabled={!name.trim() || saveMut.isPending} onClick={() => saveMut.mutate()}>
<Plus size={13} /> 添加课程 <Plus size={13} /> 添加课程
</Button> </Button>
@@ -516,10 +559,11 @@ function MobileCourseEdit({ sem, course, onDone }: { sem: SemesterGrades; course
const [credit, setCredit] = useState(String(course.credit)); const [credit, setCredit] = useState(String(course.credit));
const [score, setScore] = useState(course.score != null ? String(course.score) : ''); const [score, setScore] = useState(course.score != null ? String(course.score) : '');
const [gp, setGp] = useState(course.grade_point != null ? String(course.grade_point) : ''); const [gp, setGp] = useState(course.grade_point != null ? String(course.grade_point) : '');
const [category, setCategory] = useState(course.category ?? '必修课');
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: () => courseUpsert({ mutationFn: () => courseUpsert({
id: course.id, semester_id: sem.id, course_name: name, credit: Number(credit) || 0, id: course.id, semester_id: sem.id, course_name: name, credit: Number(credit) || 0,
score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp) score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp), category
}), }),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); onDone(); }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); onDone(); },
onError: (e: Error) => toast('error', e.message) onError: (e: Error) => toast('error', e.message)
@@ -532,6 +576,7 @@ function MobileCourseEdit({ sem, course, onDone }: { sem: SemesterGrades; course
<Input type="number" placeholder="分数" value={score} onChange={e => setScore(e.target.value)} /> <Input type="number" placeholder="分数" value={score} onChange={e => setScore(e.target.value)} />
<Input type="number" step="0.1" placeholder="绩点" value={gp} onChange={e => setGp(e.target.value)} /> <Input type="number" step="0.1" placeholder="绩点" value={gp} onChange={e => setGp(e.target.value)} />
</div> </div>
<Select className="mb-2" value={category} onChange={e => setCategory(e.target.value)} options={['必修课', '专业选修课', '人文素质选修课', '其他'].map(o => ({ value: o, label: o }))} />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Button size="sm" variant="primary" onClick={() => saveMut.mutate()}>保存</Button> <Button size="sm" variant="primary" onClick={() => saveMut.mutate()}>保存</Button>
<Button size="sm" variant="ghost" onClick={onDone}>取消</Button> <Button size="sm" variant="ghost" onClick={onDone}>取消</Button>
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-T-NGjlrZ.js";import{C as c,a as d,b as x}from"./card-C5ifMTlY.js";import{u as N,i as u,a as m}from"./useChart-dAkYJqaV.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-DTwrm6HB.js";import{C as c,a as d,b as x}from"./card-CHSa56zc.js";import{u as N,i as u,a as m}from"./useChart-Bs5kYKHg.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};
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,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-T-NGjlrZ.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-DTwrm6HB.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as L,d as we,f as se,r as T,j as e,B as R,ag as Ce,ae as D,H as B,S as ne,ac as de,P as $,F as Se,a8 as _e,T as ie,I as V,af as ze,v as Re}from"./index-T-NGjlrZ.js";import{C as ae,a as le,b as re}from"./card-C5ifMTlY.js";import{R as Pe}from"./refresh-cw-DXuKsZdS.js";/** import{c as L,d as we,f as se,r as T,j as e,B as R,ah as Ce,af as D,J as B,S as ne,ad as de,P as $,H as Se,a9 as _e,T as ie,I as V,ag as ze,v as Re}from"./index-DTwrm6HB.js";import{C as ae,a as le,b as re}from"./card-CHSa56zc.js";import{R as Pe}from"./refresh-cw-D2g9hAc-.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{j as s,a}from"./index-T-NGjlrZ.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-DTwrm6HB.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
@@ -1,4 +1,4 @@
import{c as e}from"./index-T-NGjlrZ.js";/** import{c as e}from"./index-DTwrm6HB.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{r}from"./index-T-NGjlrZ.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-DTwrm6HB.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="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" /> <link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title> <title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-T-NGjlrZ.js"></script> <script type="module" crossorigin src="/assets/index-DTwrm6HB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B4Ww6089.css"> <link rel="stylesheet" crossorigin href="/assets/index-CRSzz9iX.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>