|
@@ -0,0 +1,303 @@
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// Mock API Layer — Single Source of Truth with localStorage persistence
|
|
|
|
|
+// All scores (macro + sub-metric) are pre-generated and stored deterministically
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+export interface User {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ username: string;
|
|
|
|
|
+ role: 'stu' | 'teacher';
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ avatar: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface AuthResponse {
|
|
|
|
|
+ token: string;
|
|
|
|
|
+ user: User;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Per-metric score record: metricId → score (0-100)
|
|
|
|
|
+export type MetricScores = Record<string, number>;
|
|
|
|
|
+
|
|
|
|
|
+export interface MockStudent {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ overall: number;
|
|
|
|
|
+ K: number;
|
|
|
|
|
+ A: number;
|
|
|
|
|
+ S: number;
|
|
|
|
|
+ D: number;
|
|
|
|
|
+ avatar: string;
|
|
|
|
|
+ // Pre-generated sub-metric scores, e.g. { "K1": 88, "K2": 82, "K3": 90, "A1": 85, ... }
|
|
|
|
|
+ metricScores: MetricScores;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface ClassStats {
|
|
|
|
|
+ averages: { K: number; A: number; S: number; D: number };
|
|
|
|
|
+ distributions: Record<string, { tier: string; count: number; percent: string }[]>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface LeaderboardEntry {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ score: number;
|
|
|
|
|
+ avatar: string;
|
|
|
|
|
+ bestAt: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── All metric IDs grouped by dimension ────────────────────────────────────────
|
|
|
|
|
+// This is the structural truth — must match metricsData.ts
|
|
|
|
|
+const METRIC_IDS: Record<string, string[]> = {
|
|
|
|
|
+ K: ['K1', 'K2', 'K3'],
|
|
|
|
|
+ A: ['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7'],
|
|
|
|
|
+ S: ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7'],
|
|
|
|
|
+ D: ['D1', 'D2', 'D3', 'D4'],
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// ── localStorage Keys ──────────────────────────────────────────────────────────
|
|
|
|
|
+const STORAGE_KEY = 'edumetric_students';
|
|
|
|
|
+
|
|
|
|
|
+// ── Deterministic seeded RNG ───────────────────────────────────────────────────
|
|
|
|
|
+function createSeededRng(seed: number) {
|
|
|
|
|
+ let s = seed;
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ const x = Math.sin(s++) * 10000;
|
|
|
|
|
+ return x - Math.floor(x);
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Data Generation (runs once, then persisted) ────────────────────────────────
|
|
|
|
|
+function generateStudents(): MockStudent[] {
|
|
|
|
|
+ return Array.from({ length: 40 }).map((_, i) => {
|
|
|
|
|
+ const isExcellent = i % 5 === 0;
|
|
|
|
|
+ const isStruggling = i % 7 === 0;
|
|
|
|
|
+
|
|
|
|
|
+ let baseScore = 80;
|
|
|
|
|
+ if (isExcellent) baseScore = 92;
|
|
|
|
|
+ else if (isStruggling) baseScore = 65;
|
|
|
|
|
+ else baseScore = 75 + (i % 15);
|
|
|
|
|
+
|
|
|
|
|
+ const majorCode = i % 2 === 0 ? '125' : '188';
|
|
|
|
|
+ const indexCode = (i + 1).toString().padStart(3, '0');
|
|
|
|
|
+ const studentId = `23${majorCode}0${indexCode}`;
|
|
|
|
|
+
|
|
|
|
|
+ // Macro dimension scores
|
|
|
|
|
+ const K = baseScore + (i % 4) - 2;
|
|
|
|
|
+ const A = baseScore + (i % 5) - 1;
|
|
|
|
|
+ const S = baseScore - (i % 3);
|
|
|
|
|
+ const D = baseScore + (i % 6);
|
|
|
|
|
+ const macros: Record<string, number> = { K, A, S, D };
|
|
|
|
|
+
|
|
|
|
|
+ // Generate deterministic sub-metric scores per student
|
|
|
|
|
+ const rng = createSeededRng(i * 1000 + 42);
|
|
|
|
|
+ const metricScores: MetricScores = {};
|
|
|
|
|
+ for (const [dim, ids] of Object.entries(METRIC_IDS)) {
|
|
|
|
|
+ const dimScore = macros[dim];
|
|
|
|
|
+ for (const metricId of ids) {
|
|
|
|
|
+ const offset = Math.floor(rng() * 9) - 4;
|
|
|
|
|
+ metricScores[metricId] = Math.max(0, Math.min(100, dimScore + offset));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const surnames = [
|
|
|
|
|
+ '李', '王', '赵', '陈', '刘', '张', '周', '吴', '黄', '孙',
|
|
|
|
|
+ '徐', '马', '朱', '胡', '林', '郭', '何', '高', '罗', '郑',
|
|
|
|
|
+ '梁', '谢', '宋', '唐', '韩', '冯', '董', '程', '蔡', '袁',
|
|
|
|
|
+ '许', '叶', '余', '彭', '苏', '潘', '杜', '曹', '戴', '魏',
|
|
|
|
|
+ ];
|
|
|
|
|
+ const givenNames = [
|
|
|
|
|
+ '子涵', '佳怡', '宇航', '梓轩', '星雨', '梦瑶', '浩然', '思源', '雨萱', '天翔',
|
|
|
|
|
+ '若琳', '明哲', '语嫣', '博文', '诗涵', '晨阳', '雅静', '俊杰', '欣怡', '泽宇',
|
|
|
|
|
+ '芷若', '翰林', '婉清', '凌峰', '乐天', '安琪', '鸿飞', '嘉懿', '瑾瑜', '睿渊',
|
|
|
|
|
+ '文昊', '修洁', '黎昕', '烨磊', '晟睿', '靖琪', '致远', '逸飞', '昊然', '皓轩',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: studentId,
|
|
|
|
|
+ name: surnames[i] + givenNames[i],
|
|
|
|
|
+ overall: baseScore,
|
|
|
|
|
+ K, A, S, D,
|
|
|
|
|
+ avatar: '/images/profile-avatar.png',
|
|
|
|
|
+ metricScores,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Init / Read from localStorage ──────────────────────────────────────────────
|
|
|
|
|
+function initMockData(): MockStudent[] {
|
|
|
|
|
+ if (typeof window === 'undefined') {
|
|
|
|
|
+ return generateStudents();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const stored = localStorage.getItem(STORAGE_KEY);
|
|
|
|
|
+ if (stored) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(stored) as MockStudent[];
|
|
|
|
|
+ // Validate that sub-metric scores exist (handle old format)
|
|
|
|
|
+ if (parsed.length > 0 && parsed[0].metricScores) {
|
|
|
|
|
+ return parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Corrupted — regenerate
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = generateStudents();
|
|
|
|
|
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
|
|
|
+ return data;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── User Accounts ──────────────────────────────────────────────────────────────
|
|
|
|
|
+function getUsers(): Record<string, User> {
|
|
|
|
|
+ const students = initMockData();
|
|
|
|
|
+ return {
|
|
|
|
|
+ stu: {
|
|
|
|
|
+ id: students[0].id,
|
|
|
|
|
+ username: 'stu',
|
|
|
|
|
+ role: 'stu',
|
|
|
|
|
+ name: students[0].name,
|
|
|
|
|
+ avatar: students[0].avatar,
|
|
|
|
|
+ },
|
|
|
|
|
+ teacher: {
|
|
|
|
|
+ id: 'tea_001',
|
|
|
|
|
+ username: 'teacher',
|
|
|
|
|
+ role: 'teacher',
|
|
|
|
|
+ name: '刘钦',
|
|
|
|
|
+ avatar: '/images/profile-avatar.png',
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Helpers ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
|
+
|
|
|
|
|
+// ── Public Mock API ────────────────────────────────────────────────────────────
|
|
|
|
|
+export const mockApi = {
|
|
|
|
|
+ // ── Auth ────────────────────────────────────────────────────────────────────
|
|
|
|
|
+ login: async (username: string, password: string): Promise<AuthResponse> => {
|
|
|
|
|
+ await delay(600);
|
|
|
|
|
+ if (password !== 'passwd') throw new Error('密码错误');
|
|
|
|
|
+ const users = getUsers();
|
|
|
|
|
+ const user = users[username];
|
|
|
|
|
+ if (!user) throw new Error('用户不存在');
|
|
|
|
|
+ return {
|
|
|
|
|
+ token: `mock_jwt_token_${user.id}_${Date.now()}`,
|
|
|
|
|
+ user,
|
|
|
|
|
+ };
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ verifySession: async (token: string | null): Promise<User | null> => {
|
|
|
|
|
+ await delay(300);
|
|
|
|
|
+ if (!token) return null;
|
|
|
|
|
+ const users = getUsers();
|
|
|
|
|
+ if (token.includes(users.stu.id)) return users.stu;
|
|
|
|
|
+ if (token.includes('tea_001')) return users.teacher;
|
|
|
|
|
+ return null;
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // ── Students ────────────────────────────────────────────────────────────────
|
|
|
|
|
+ getStudents: async (): Promise<MockStudent[]> => {
|
|
|
|
|
+ await delay(400);
|
|
|
|
|
+ return initMockData();
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ getStudentById: async (id: string): Promise<MockStudent | undefined> => {
|
|
|
|
|
+ await delay(200);
|
|
|
|
|
+ return initMockData().find(s => s.id === id);
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // ── Per-Student Metric Scores ───────────────────────────────────────────────
|
|
|
|
|
+ // Returns the pre-generated sub-metric scores for a specific student
|
|
|
|
|
+ getStudentMetrics: async (studentId: string): Promise<MetricScores | null> => {
|
|
|
|
|
+ await delay(200);
|
|
|
|
|
+ const student = initMockData().find(s => s.id === studentId);
|
|
|
|
|
+ return student?.metricScores ?? null;
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // ── Class Statistics (Teacher Dashboard) ────────────────────────────────────
|
|
|
|
|
+ getClassStats: async (): Promise<ClassStats> => {
|
|
|
|
|
+ await delay(500);
|
|
|
|
|
+ const students = initMockData();
|
|
|
|
|
+ const total = students.length || 1;
|
|
|
|
|
+
|
|
|
|
|
+ const sums = students.reduce(
|
|
|
|
|
+ (acc, stu) => {
|
|
|
|
|
+ acc.K += stu.K;
|
|
|
|
|
+ acc.A += stu.A;
|
|
|
|
|
+ acc.S += stu.S;
|
|
|
|
|
+ acc.D += stu.D;
|
|
|
|
|
+ return acc;
|
|
|
|
|
+ },
|
|
|
|
|
+ { K: 0, A: 0, S: 0, D: 0 },
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const averages = {
|
|
|
|
|
+ K: Math.round(sums.K / total),
|
|
|
|
|
+ A: Math.round(sums.A / total),
|
|
|
|
|
+ S: Math.round(sums.S / total),
|
|
|
|
|
+ D: Math.round(sums.D / total),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const bucket = (key: 'K' | 'A' | 'S' | 'D') => {
|
|
|
|
|
+ let excellent = 0, good = 0, fair = 0, poor = 0;
|
|
|
|
|
+ students.forEach(stu => {
|
|
|
|
|
+ const score = stu[key];
|
|
|
|
|
+ if (score >= 90) excellent++;
|
|
|
|
|
+ else if (score >= 80) good++;
|
|
|
|
|
+ else if (score >= 70) fair++;
|
|
|
|
|
+ else poor++;
|
|
|
|
|
+ });
|
|
|
|
|
+ return [
|
|
|
|
|
+ { tier: '优秀 (90+)', count: excellent, percent: `${Math.round((excellent / total) * 100)}%` },
|
|
|
|
|
+ { tier: '良好 (80-89)', count: good, percent: `${Math.round((good / total) * 100)}%` },
|
|
|
|
|
+ { tier: '一般 (70-79)', count: fair, percent: `${Math.round((fair / total) * 100)}%` },
|
|
|
|
|
+ { tier: '需改进 (<70)', count: poor, percent: `${Math.round((poor / total) * 100)}%` },
|
|
|
|
|
+ ];
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const distributions: ClassStats['distributions'] = {
|
|
|
|
|
+ K: bucket('K'),
|
|
|
|
|
+ A: bucket('A'),
|
|
|
|
|
+ S: bucket('S'),
|
|
|
|
|
+ D: bucket('D'),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return { averages, distributions };
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // ── Leaderboard ─────────────────────────────────────────────────────────────
|
|
|
|
|
+ getLeaderboard: async (limit = 5): Promise<LeaderboardEntry[]> => {
|
|
|
|
|
+ await delay(300);
|
|
|
|
|
+ const students = initMockData();
|
|
|
|
|
+ return [...students]
|
|
|
|
|
+ .sort((a, b) => b.overall - a.overall)
|
|
|
|
|
+ .slice(0, limit)
|
|
|
|
|
+ .map(stu => {
|
|
|
|
|
+ const scores = [
|
|
|
|
|
+ { k: 'K (知识覆盖)', v: stu.K },
|
|
|
|
|
+ { k: 'A (AI交互)', v: stu.A },
|
|
|
|
|
+ { k: 'S (代码产出)', v: stu.S },
|
|
|
|
|
+ { k: 'D (团队协作)', v: stu.D },
|
|
|
|
|
+ ];
|
|
|
|
|
+ scores.sort((a, b) => b.v - a.v);
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: stu.id,
|
|
|
|
|
+ name: stu.name,
|
|
|
|
|
+ score: stu.overall,
|
|
|
|
|
+ avatar: stu.avatar,
|
|
|
|
|
+ bestAt: scores[0].k,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // ── Student Self Profile ────────────────────────────────────────────────────
|
|
|
|
|
+ getMyProfile: async (): Promise<MockStudent> => {
|
|
|
|
|
+ await delay(200);
|
|
|
|
|
+ return initMockData()[0];
|
|
|
|
|
+ },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// ── Sync helpers (for components that can't use async easily) ─────────────────
|
|
|
|
|
+export const getMockStudentById = (id: string): MockStudent | undefined => {
|
|
|
|
|
+ return initMockData().find(s => s.id === id);
|
|
|
|
|
+};
|