Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
530e1a3d5d
commit
195069450f
480 files changed
+15507
-24650
No files matched your search
+145
-190
@@ -1,5 +1,5 @@
|
||||
@font-face {
|
||||
font-family: "Monaco";
|
||||
font-family: 'Monaco';
|
||||
src: url(/Monaco.ttf);
|
||||
}
|
||||
|
||||
@@ -12,132 +12,109 @@
|
||||
npm 上的 @fontsource/noto-color-emoji 是 SVG-in-OpenType,Chrome 不支持,别换成它。
|
||||
文件名里的 v47 是版本号,Caddy 对 /emoji/* 永久缓存 —— 换字体就换个版本号,别原名覆盖。 */
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-0.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-0.woff2) format('woff2');
|
||||
unicode-range: U+1f1e6-1f1ff;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-1.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-1.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2620, U+26a7, U+fe0f, U+1f308, U+1f38c, U+1f3c1, U+1f3f3-1f3f4, U+1f6a9,
|
||||
U+e0062-e0063, U+e0065, U+e0067, U+e006c, U+e006e, U+e0073-e0074, U+e0077, U+e007f;
|
||||
U+200d, U+2620, U+26a7, U+fe0f, U+1f308, U+1f38c, U+1f3c1, U+1f3f3-1f3f4, U+1f6a9, U+e0062-e0063, U+e0065, U+e0067, U+e006c, U+e006e, U+e0073-e0074,
|
||||
U+e0077, U+e007f;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-2.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-2.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+23, U+2a, U+30-39, U+a9, U+ae, U+200d, U+203c, U+2049, U+20e3, U+2122, U+2139, U+2194-2199,
|
||||
U+21a9-21aa, U+23cf, U+23e9-23ef, U+23f8-23fa, U+24c2, U+25aa-25ab, U+25b6, U+25c0, U+25fb-25fe,
|
||||
U+2611, U+2622-2623, U+2626, U+262a, U+262e-262f, U+2638, U+2640, U+2642, U+2648-2653, U+2660,
|
||||
U+2663, U+2665-2666, U+2668, U+267b, U+267e-267f, U+2695, U+269b-269c, U+26a0, U+26a7,
|
||||
U+26aa-26ab, U+26ce, U+26d4, U+2705, U+2714, U+2716, U+271d, U+2721, U+2733-2734, U+2747,
|
||||
U+274c, U+274e, U+2753-2755, U+2757, U+2764, U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935,
|
||||
U+2b05-2b07, U+2b1b-2b1c, U+2b55, U+3030, U+303d, U+3297, U+3299, U+fe0f, U+1f170-1f171,
|
||||
U+1f17e-1f17f, U+1f18e, U+1f191-1f19a, U+1f201-1f202, U+1f21a, U+1f22f, U+1f232-1f23a,
|
||||
U+1f250-1f251, U+1f310, U+1f3a6, U+1f3b5-1f3b6, U+1f3bc, U+1f3e7, U+1f441, U+1f499-1f49c,
|
||||
U+1f49f-1f4a0, U+1f4ac-1f4ad, U+1f4b1-1f4b2, U+1f4b9, U+1f4db, U+1f4f2-1f4f6, U+1f500-1f507,
|
||||
U+1f515, U+1f518-1f524, U+1f52f-1f53d, U+1f549, U+1f54e, U+1f5a4, U+1f5e8, U+1f5ef, U+1f6ab,
|
||||
U+1f6ad-1f6b1, U+1f6b3, U+1f6b7-1f6bc, U+1f6be, U+1f6c2-1f6c5, U+1f6d0-1f6d1, U+1f6d7, U+1f6dc,
|
||||
U+1f7e0-1f7eb, U+1f7f0, U+1f90d-1f90e, U+1f9d1-1f9d2, U+1f9e1, U+1fa75-1fa77, U+1faaf;
|
||||
U+23, U+2a, U+30-39, U+a9, U+ae, U+200d, U+203c, U+2049, U+20e3, U+2122, U+2139, U+2194-2199, U+21a9-21aa, U+23cf, U+23e9-23ef, U+23f8-23fa, U+24c2,
|
||||
U+25aa-25ab, U+25b6, U+25c0, U+25fb-25fe, U+2611, U+2622-2623, U+2626, U+262a, U+262e-262f, U+2638, U+2640, U+2642, U+2648-2653, U+2660, U+2663,
|
||||
U+2665-2666, U+2668, U+267b, U+267e-267f, U+2695, U+269b-269c, U+26a0, U+26a7, U+26aa-26ab, U+26ce, U+26d4, U+2705, U+2714, U+2716, U+271d, U+2721,
|
||||
U+2733-2734, U+2747, U+274c, U+274e, U+2753-2755, U+2757, U+2764, U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935, U+2b05-2b07, U+2b1b-2b1c, U+2b55,
|
||||
U+3030, U+303d, U+3297, U+3299, U+fe0f, U+1f170-1f171, U+1f17e-1f17f, U+1f18e, U+1f191-1f19a, U+1f201-1f202, U+1f21a, U+1f22f, U+1f232-1f23a, U+1f250-1f251,
|
||||
U+1f310, U+1f3a6, U+1f3b5-1f3b6, U+1f3bc, U+1f3e7, U+1f441, U+1f499-1f49c, U+1f49f-1f4a0, U+1f4ac-1f4ad, U+1f4b1-1f4b2, U+1f4b9, U+1f4db, U+1f4f2-1f4f6,
|
||||
U+1f500-1f507, U+1f515, U+1f518-1f524, U+1f52f-1f53d, U+1f549, U+1f54e, U+1f5a4, U+1f5e8, U+1f5ef, U+1f6ab, U+1f6ad-1f6b1, U+1f6b3, U+1f6b7-1f6bc, U+1f6be,
|
||||
U+1f6c2-1f6c5, U+1f6d0-1f6d1, U+1f6d7, U+1f6dc, U+1f7e0-1f7eb, U+1f7f0, U+1f90d-1f90e, U+1f9d1-1f9d2, U+1f9e1, U+1fa75-1fa77, U+1faaf;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-3.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-3.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+231a-231b, U+2328, U+23f0-23f3, U+2602, U+260e, U+2692, U+2694, U+2696-2697, U+2699,
|
||||
U+26b0-26b1, U+26cf, U+26d1, U+26d3, U+2702, U+2709, U+270f, U+2712, U+fe0f, U+1f302, U+1f321,
|
||||
U+1f392-1f393, U+1f3a9, U+1f3bd, U+1f3ee, U+1f3f7, U+1f3fa, U+1f451-1f462, U+1f484,
|
||||
U+1f489-1f48a, U+1f48c-1f48e, U+1f4a1, U+1f4a3, U+1f4a5, U+1f4b0, U+1f4b3-1f4b8, U+1f4bb-1f4da,
|
||||
U+1f4dc-1f4f1, U+1f4ff, U+1f508-1f514, U+1f516-1f517, U+1f526-1f529, U+1f52c-1f52e,
|
||||
U+1f550-1f567, U+1f56f-1f570, U+1f576, U+1f587, U+1f58a-1f58d, U+1f5a5, U+1f5a8, U+1f5b1-1f5b2,
|
||||
U+1f5c2-1f5c4, U+1f5d1-1f5d3, U+1f5dc-1f5de, U+1f5e1, U+1f5f3, U+1f6aa, U+1f6ac, U+1f6bd,
|
||||
U+1f6bf, U+1f6c1, U+1f6cb, U+1f6cd-1f6cf, U+1f6d2, U+1f6e0-1f6e1, U+1f6f0, U+1f97b-1f97f,
|
||||
U+1f9af, U+1f9ba, U+1f9e2-1f9e6, U+1f9ea-1f9ec, U+1f9ee-1f9f4, U+1f9f7-1f9ff, U+1fa71-1fa74,
|
||||
U+1fa79-1fa7b, U+1fa86, U+1fa8e-1fa8f, U+1fa91-1fa93, U+1fa96, U+1fa99-1faa0, U+1faa2-1faa7,
|
||||
U+1faaa-1faae;
|
||||
U+200d, U+231a-231b, U+2328, U+23f0-23f3, U+2602, U+260e, U+2692, U+2694, U+2696-2697, U+2699, U+26b0-26b1, U+26cf, U+26d1, U+26d3, U+2702, U+2709, U+270f,
|
||||
U+2712, U+fe0f, U+1f302, U+1f321, U+1f392-1f393, U+1f3a9, U+1f3bd, U+1f3ee, U+1f3f7, U+1f3fa, U+1f451-1f462, U+1f484, U+1f489-1f48a, U+1f48c-1f48e, U+1f4a1,
|
||||
U+1f4a3, U+1f4a5, U+1f4b0, U+1f4b3-1f4b8, U+1f4bb-1f4da, U+1f4dc-1f4f1, U+1f4ff, U+1f508-1f514, U+1f516-1f517, U+1f526-1f529, U+1f52c-1f52e, U+1f550-1f567,
|
||||
U+1f56f-1f570, U+1f576, U+1f587, U+1f58a-1f58d, U+1f5a5, U+1f5a8, U+1f5b1-1f5b2, U+1f5c2-1f5c4, U+1f5d1-1f5d3, U+1f5dc-1f5de, U+1f5e1, U+1f5f3, U+1f6aa,
|
||||
U+1f6ac, U+1f6bd, U+1f6bf, U+1f6c1, U+1f6cb, U+1f6cd-1f6cf, U+1f6d2, U+1f6e0-1f6e1, U+1f6f0, U+1f97b-1f97f, U+1f9af, U+1f9ba, U+1f9e2-1f9e6, U+1f9ea-1f9ec,
|
||||
U+1f9ee-1f9f4, U+1f9f7-1f9ff, U+1fa71-1fa74, U+1fa79-1fa7b, U+1fa86, U+1fa8e-1fa8f, U+1fa91-1fa93, U+1fa96, U+1fa99-1faa0, U+1faa2-1faa7, U+1faaa-1faae;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-4.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-4.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+265f, U+26bd-26be, U+26f3, U+26f8, U+fe0f, U+1f004, U+1f0cf, U+1f380-1f384, U+1f386-1f38b,
|
||||
U+1f38d-1f391, U+1f396-1f397, U+1f399-1f39b, U+1f39e-1f39f, U+1f3a3-1f3a5, U+1f3a7-1f3a9,
|
||||
U+1f3ab-1f3b4, U+1f3b7-1f3bb, U+1f3bd-1f3c0, U+1f3c5-1f3c6, U+1f3c8-1f3c9, U+1f3cf-1f3d3,
|
||||
U+1f3f8-1f3f9, U+1f47e, U+1f4e2, U+1f4f7-1f4fd, U+1f52b, U+1f579, U+1f58c-1f58d, U+1f5bc,
|
||||
U+1f6f7, U+1f6f9, U+1f6fc, U+1f93f, U+1f941, U+1f945, U+1f947-1f94f, U+1f9e7-1f9e9,
|
||||
U+1f9f5-1f9f6, U+1fa70-1fa71, U+1fa80-1fa81, U+1fa83-1fa85, U+1fa87-1fa8a, U+1fa94-1fa95,
|
||||
U+1fa97-1fa98, U+1faa1, U+1faa9, U+1fadf;
|
||||
U+265f, U+26bd-26be, U+26f3, U+26f8, U+fe0f, U+1f004, U+1f0cf, U+1f380-1f384, U+1f386-1f38b, U+1f38d-1f391, U+1f396-1f397, U+1f399-1f39b, U+1f39e-1f39f,
|
||||
U+1f3a3-1f3a5, U+1f3a7-1f3a9, U+1f3ab-1f3b4, U+1f3b7-1f3bb, U+1f3bd-1f3c0, U+1f3c5-1f3c6, U+1f3c8-1f3c9, U+1f3cf-1f3d3, U+1f3f8-1f3f9, U+1f47e, U+1f4e2,
|
||||
U+1f4f7-1f4fd, U+1f52b, U+1f579, U+1f58c-1f58d, U+1f5bc, U+1f6f7, U+1f6f9, U+1f6fc, U+1f93f, U+1f941, U+1f945, U+1f947-1f94f, U+1f9e7-1f9e9, U+1f9f5-1f9f6,
|
||||
U+1fa70-1fa71, U+1fa80-1fa81, U+1fa83-1fa85, U+1fa87-1fa8a, U+1fa94-1fa95, U+1fa97-1fa98, U+1faa1, U+1faa9, U+1fadf;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-5.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-5.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+2693, U+26e9-26ea, U+26f1-26f2, U+26f4-26f5, U+26fa, U+26fd, U+2708, U+fe0f, U+1f301, U+1f303,
|
||||
U+1f306-1f307, U+1f309, U+1f310, U+1f3a0-1f3a2, U+1f3aa, U+1f3cd-1f3ce, U+1f3d5, U+1f3d7-1f3db,
|
||||
U+1f3df-1f3e6, U+1f3e8-1f3ed, U+1f3ef-1f3f0, U+1f488, U+1f492, U+1f4ba, U+1f54b-1f54d,
|
||||
U+1f5fa-1f5ff, U+1f680-1f6a2, U+1f6a4-1f6a8, U+1f6b2, U+1f6d1, U+1f6d5-1f6d6, U+1f6dd-1f6df,
|
||||
U+1f6e2-1f6e5, U+1f6e9, U+1f6eb-1f6ec, U+1f6f3-1f6f6, U+1f6f8, U+1f6fa-1f6fb, U+1f9bc-1f9bd,
|
||||
U+2693, U+26e9-26ea, U+26f1-26f2, U+26f4-26f5, U+26fa, U+26fd, U+2708, U+fe0f, U+1f301, U+1f303, U+1f306-1f307, U+1f309, U+1f310, U+1f3a0-1f3a2, U+1f3aa,
|
||||
U+1f3cd-1f3ce, U+1f3d5, U+1f3d7-1f3db, U+1f3df-1f3e6, U+1f3e8-1f3ed, U+1f3ef-1f3f0, U+1f488, U+1f492, U+1f4ba, U+1f54b-1f54d, U+1f5fa-1f5ff, U+1f680-1f6a2,
|
||||
U+1f6a4-1f6a8, U+1f6b2, U+1f6d1, U+1f6d5-1f6d6, U+1f6dd-1f6df, U+1f6e2-1f6e5, U+1f6e9, U+1f6eb-1f6ec, U+1f6f3-1f6f6, U+1f6f8, U+1f6fa-1f6fb, U+1f9bc-1f9bd,
|
||||
U+1f9ed, U+1f9f3, U+1fa7c;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-6.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-6.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2615, U+fe0f, U+1f32d-1f330, U+1f336, U+1f33d, U+1f344-1f37f, U+1f382, U+1f52a,
|
||||
U+1f7e9, U+1f7eb, U+1f942-1f944, U+1f950-1f96f, U+1f99e, U+1f9aa, U+1f9c0-1f9cb, U+1fad0-1fadc;
|
||||
U+200d, U+2615, U+fe0f, U+1f32d-1f330, U+1f336, U+1f33d, U+1f344-1f37f, U+1f382, U+1f52a, U+1f7e9, U+1f7eb, U+1f942-1f944, U+1f950-1f96f, U+1f99e, U+1f9aa,
|
||||
U+1f9c0-1f9cb, U+1fad0-1fadc;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-7.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-7.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2600-2601, U+2603-2604, U+2614, U+2618, U+26a1, U+26c4-26c5, U+26c8, U+26f0, U+2728,
|
||||
U+2744, U+2b1b, U+2b50, U+fe0f, U+1f300, U+1f304-1f305, U+1f308, U+1f30a-1f30f, U+1f311-1f321,
|
||||
U+1f324-1f32c, U+1f331-1f335, U+1f337-1f33c, U+1f33e-1f344, U+1f3d4, U+1f3d6, U+1f3dc-1f3de,
|
||||
U+1f3f5, U+1f400-1f43f, U+1f490, U+1f4a7, U+1f4ae, U+1f525, U+1f54a, U+1f573, U+1f577-1f578,
|
||||
U+1f648-1f64a, U+1f6d8, U+1f940, U+1f980-1f9ae, U+1f9ba, U+1fa90, U+1faa8, U+1fab0-1fabf,
|
||||
U+1facd-1facf, U+1fae7;
|
||||
U+200d, U+2600-2601, U+2603-2604, U+2614, U+2618, U+26a1, U+26c4-26c5, U+26c8, U+26f0, U+2728, U+2744, U+2b1b, U+2b50, U+fe0f, U+1f300, U+1f304-1f305,
|
||||
U+1f308, U+1f30a-1f30f, U+1f311-1f321, U+1f324-1f32c, U+1f331-1f335, U+1f337-1f33c, U+1f33e-1f344, U+1f3d4, U+1f3d6, U+1f3dc-1f3de, U+1f3f5, U+1f400-1f43f,
|
||||
U+1f490, U+1f4a7, U+1f4ae, U+1f525, U+1f54a, U+1f573, U+1f577-1f578, U+1f648-1f64a, U+1f6d8, U+1f940, U+1f980-1f9ae, U+1f9ba, U+1fa90, U+1faa8,
|
||||
U+1fab0-1fabf, U+1facd-1facf, U+1fae7;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-8.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-8.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2640, U+2642, U+2695-2696, U+26f7, U+26f9, U+2708, U+2764, U+27a1, U+fe0f, U+1f33e,
|
||||
U+1f373, U+1f37c, U+1f384-1f385, U+1f393, U+1f3a4, U+1f3a8, U+1f3c2-1f3c4, U+1f3c7,
|
||||
U+1f3ca-1f3cc, U+1f3eb, U+1f3ed, U+1f3fb-1f3ff, U+1f430, U+1f466-1f469, U+1f46b-1f478, U+1f47c,
|
||||
U+1f481-1f483, U+1f486-1f487, U+1f48b, U+1f48f, U+1f491, U+1f4bb-1f4bc, U+1f527, U+1f52c,
|
||||
U+1f574-1f575, U+1f57a, U+1f645-1f647, U+1f64b, U+1f64d-1f64e, U+1f680, U+1f692, U+1f6a3,
|
||||
U+1f6b4-1f6b6, U+1f6c0, U+1f6cc, U+1f91d, U+1f926, U+1f930-1f931, U+1f934-1f93a, U+1f93c-1f93e,
|
||||
U+1f977, U+1f9af-1f9b3, U+1f9b8-1f9b9, U+1f9bc-1f9bd, U+1f9cc-1f9cf, U+1f9d1-1f9df, U+1fa70,
|
||||
U+1fa82, U+1fac3-1fac5, U+1fac8, U+1faef;
|
||||
U+200d, U+2640, U+2642, U+2695-2696, U+26f7, U+26f9, U+2708, U+2764, U+27a1, U+fe0f, U+1f33e, U+1f373, U+1f37c, U+1f384-1f385, U+1f393, U+1f3a4, U+1f3a8,
|
||||
U+1f3c2-1f3c4, U+1f3c7, U+1f3ca-1f3cc, U+1f3eb, U+1f3ed, U+1f3fb-1f3ff, U+1f430, U+1f466-1f469, U+1f46b-1f478, U+1f47c, U+1f481-1f483, U+1f486-1f487,
|
||||
U+1f48b, U+1f48f, U+1f491, U+1f4bb-1f4bc, U+1f527, U+1f52c, U+1f574-1f575, U+1f57a, U+1f645-1f647, U+1f64b, U+1f64d-1f64e, U+1f680, U+1f692, U+1f6a3,
|
||||
U+1f6b4-1f6b6, U+1f6c0, U+1f6cc, U+1f91d, U+1f926, U+1f930-1f931, U+1f934-1f93a, U+1f93c-1f93e, U+1f977, U+1f9af-1f9b3, U+1f9b8-1f9b9, U+1f9bc-1f9bd,
|
||||
U+1f9cc-1f9cf, U+1f9d1-1f9df, U+1fa70, U+1fa82, U+1fac3-1fac5, U+1fac8, U+1faef;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-9.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-9.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2194-2195, U+2603, U+261d, U+2620, U+2639-263a, U+2665, U+26a1, U+26c4, U+270a-270d,
|
||||
U+2728, U+2763-2764, U+2b50, U+fe0f, U+1f31a-1f31f, U+1f32b, U+1f389-1f38a, U+1f3fb-1f3ff,
|
||||
U+1f440-1f450, U+1f463-1f465, U+1f479-1f47b, U+1f47d-1f480, U+1f485, U+1f48b-1f48c,
|
||||
U+1f493-1f49f, U+1f4a2, U+1f4a4-1f4a6, U+1f4a8-1f4ab, U+1f4af, U+1f525, U+1f573, U+1f590,
|
||||
U+1f595-1f596, U+1f5a4, U+1f5e3, U+1f600-1f644, U+1f648-1f64a, U+1f64c, U+1f64f, U+1f90c-1f925,
|
||||
U+1f927-1f92f, U+1f932-1f933, U+1f970-1f976, U+1f978-1f97a, U+1f9a0, U+1f9b4-1f9b7, U+1f9bb,
|
||||
U+1f9be-1f9bf, U+1f9d0, U+1f9e0-1f9e1, U+1fa75-1fa79, U+1fac0-1fac2, U+1fac6, U+1fae0-1fae6,
|
||||
U+1fae8-1faea, U+1faef-1fafa;
|
||||
U+200d, U+2194-2195, U+2603, U+261d, U+2620, U+2639-263a, U+2665, U+26a1, U+26c4, U+270a-270d, U+2728, U+2763-2764, U+2b50, U+fe0f, U+1f31a-1f31f, U+1f32b,
|
||||
U+1f389-1f38a, U+1f3fb-1f3ff, U+1f440-1f450, U+1f463-1f465, U+1f479-1f47b, U+1f47d-1f480, U+1f485, U+1f48b-1f48c, U+1f493-1f49f, U+1f4a2, U+1f4a4-1f4a6,
|
||||
U+1f4a8-1f4ab, U+1f4af, U+1f525, U+1f573, U+1f590, U+1f595-1f596, U+1f5a4, U+1f5e3, U+1f600-1f644, U+1f648-1f64a, U+1f64c, U+1f64f, U+1f90c-1f925,
|
||||
U+1f927-1f92f, U+1f932-1f933, U+1f970-1f976, U+1f978-1f97a, U+1f9a0, U+1f9b4-1f9b7, U+1f9bb, U+1f9be-1f9bf, U+1f9d0, U+1f9e0-1f9e1, U+1fa75-1fa79,
|
||||
U+1fac0-1fac2, U+1fac6, U+1fae0-1fae6, U+1fae8-1faea, U+1faef-1fafa;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji";
|
||||
font-family: 'OJ Emoji';
|
||||
font-display: swap;
|
||||
src: url(/emoji/noto-v47-10.woff2) format("woff2");
|
||||
src: url(/emoji/noto-v47-10.woff2) format('woff2');
|
||||
unicode-range: U+1f6d9, U+1fa8b-1fa8d, U+1facc, U+1fadd, U+1faeb;
|
||||
}
|
||||
|
||||
@@ -149,163 +126,141 @@
|
||||
苹果、安卓先用系统自带的那套(local),不用下载。
|
||||
这一段是从上面 11 个 unicode-range 筛出来的,上面换了字体,这里要跟着重新筛。 */
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-0.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-0.woff2) format('woff2');
|
||||
unicode-range: U+1f1e6-1f1ff;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-1.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-1.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2620, U+26a7, U+fe0f, U+1f308, U+1f38c, U+1f3c1, U+1f3f3-1f3f4, U+1f6a9,
|
||||
U+e0062-e0063, U+e0065, U+e0067, U+e006c, U+e006e, U+e0073-e0074, U+e0077, U+e007f;
|
||||
U+200d, U+2620, U+26a7, U+fe0f, U+1f308, U+1f38c, U+1f3c1, U+1f3f3-1f3f4, U+1f6a9, U+e0062-e0063, U+e0065, U+e0067, U+e006c, U+e006e, U+e0073-e0074,
|
||||
U+e0077, U+e007f;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-2.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-2.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+23cf, U+23e9-23ef, U+23f8-23fa, U+2611, U+2622-2623, U+2626, U+262a, U+262e-262f,
|
||||
U+2638, U+2648-2653, U+2668, U+267b, U+267e-267f, U+2695, U+269b-269c, U+26a0, U+26a7,
|
||||
U+26aa-26ab, U+26ce, U+26d4, U+2705, U+2714, U+2716, U+271d, U+2721, U+2733-2734, U+2747,
|
||||
U+274c, U+274e, U+2753-2755, U+2757, U+2764, U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935,
|
||||
U+2b05-2b07, U+2b1b-2b1c, U+2b55, U+fe0f, U+1f170-1f171, U+1f17e-1f17f, U+1f18e, U+1f191-1f19a,
|
||||
U+1f201-1f202, U+1f21a, U+1f22f, U+1f232-1f23a, U+1f250-1f251, U+1f310, U+1f3a6, U+1f3b5-1f3b6,
|
||||
U+1f3bc, U+1f3e7, U+1f441, U+1f499-1f49c, U+1f49f-1f4a0, U+1f4ac-1f4ad, U+1f4b1-1f4b2, U+1f4b9,
|
||||
U+1f4db, U+1f4f2-1f4f6, U+1f500-1f507, U+1f515, U+1f518-1f524, U+1f52f-1f53d, U+1f549, U+1f54e,
|
||||
U+1f5a4, U+1f5e8, U+1f5ef, U+1f6ab, U+1f6ad-1f6b1, U+1f6b3, U+1f6b7-1f6bc, U+1f6be,
|
||||
U+1f6c2-1f6c5, U+1f6d0-1f6d1, U+1f6d7, U+1f6dc, U+1f7e0-1f7eb, U+1f7f0, U+1f90d-1f90e,
|
||||
U+200d, U+23cf, U+23e9-23ef, U+23f8-23fa, U+2611, U+2622-2623, U+2626, U+262a, U+262e-262f, U+2638, U+2648-2653, U+2668, U+267b, U+267e-267f, U+2695,
|
||||
U+269b-269c, U+26a0, U+26a7, U+26aa-26ab, U+26ce, U+26d4, U+2705, U+2714, U+2716, U+271d, U+2721, U+2733-2734, U+2747, U+274c, U+274e, U+2753-2755, U+2757,
|
||||
U+2764, U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935, U+2b05-2b07, U+2b1b-2b1c, U+2b55, U+fe0f, U+1f170-1f171, U+1f17e-1f17f, U+1f18e, U+1f191-1f19a,
|
||||
U+1f201-1f202, U+1f21a, U+1f22f, U+1f232-1f23a, U+1f250-1f251, U+1f310, U+1f3a6, U+1f3b5-1f3b6, U+1f3bc, U+1f3e7, U+1f441, U+1f499-1f49c, U+1f49f-1f4a0,
|
||||
U+1f4ac-1f4ad, U+1f4b1-1f4b2, U+1f4b9, U+1f4db, U+1f4f2-1f4f6, U+1f500-1f507, U+1f515, U+1f518-1f524, U+1f52f-1f53d, U+1f549, U+1f54e, U+1f5a4, U+1f5e8,
|
||||
U+1f5ef, U+1f6ab, U+1f6ad-1f6b1, U+1f6b3, U+1f6b7-1f6bc, U+1f6be, U+1f6c2-1f6c5, U+1f6d0-1f6d1, U+1f6d7, U+1f6dc, U+1f7e0-1f7eb, U+1f7f0, U+1f90d-1f90e,
|
||||
U+1f9d1-1f9d2, U+1f9e1, U+1fa75-1fa77, U+1faaf;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-3.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-3.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+231a-231b, U+2328, U+23f0-23f3, U+2602, U+260e, U+2692, U+2694, U+2696-2697, U+2699,
|
||||
U+26b0-26b1, U+26cf, U+26d1, U+26d3, U+2702, U+2709, U+270f, U+2712, U+fe0f, U+1f302, U+1f321,
|
||||
U+1f392-1f393, U+1f3a9, U+1f3bd, U+1f3ee, U+1f3f7, U+1f3fa, U+1f451-1f462, U+1f484,
|
||||
U+1f489-1f48a, U+1f48c-1f48e, U+1f4a1, U+1f4a3, U+1f4a5, U+1f4b0, U+1f4b3-1f4b8, U+1f4bb-1f4da,
|
||||
U+1f4dc-1f4f1, U+1f4ff, U+1f508-1f514, U+1f516-1f517, U+1f526-1f529, U+1f52c-1f52e,
|
||||
U+1f550-1f567, U+1f56f-1f570, U+1f576, U+1f587, U+1f58a-1f58d, U+1f5a5, U+1f5a8, U+1f5b1-1f5b2,
|
||||
U+1f5c2-1f5c4, U+1f5d1-1f5d3, U+1f5dc-1f5de, U+1f5e1, U+1f5f3, U+1f6aa, U+1f6ac, U+1f6bd,
|
||||
U+1f6bf, U+1f6c1, U+1f6cb, U+1f6cd-1f6cf, U+1f6d2, U+1f6e0-1f6e1, U+1f6f0, U+1f97b-1f97f,
|
||||
U+1f9af, U+1f9ba, U+1f9e2-1f9e6, U+1f9ea-1f9ec, U+1f9ee-1f9f4, U+1f9f7-1f9ff, U+1fa71-1fa74,
|
||||
U+1fa79-1fa7b, U+1fa86, U+1fa8e-1fa8f, U+1fa91-1fa93, U+1fa96, U+1fa99-1faa0, U+1faa2-1faa7,
|
||||
U+1faaa-1faae;
|
||||
U+200d, U+231a-231b, U+2328, U+23f0-23f3, U+2602, U+260e, U+2692, U+2694, U+2696-2697, U+2699, U+26b0-26b1, U+26cf, U+26d1, U+26d3, U+2702, U+2709, U+270f,
|
||||
U+2712, U+fe0f, U+1f302, U+1f321, U+1f392-1f393, U+1f3a9, U+1f3bd, U+1f3ee, U+1f3f7, U+1f3fa, U+1f451-1f462, U+1f484, U+1f489-1f48a, U+1f48c-1f48e, U+1f4a1,
|
||||
U+1f4a3, U+1f4a5, U+1f4b0, U+1f4b3-1f4b8, U+1f4bb-1f4da, U+1f4dc-1f4f1, U+1f4ff, U+1f508-1f514, U+1f516-1f517, U+1f526-1f529, U+1f52c-1f52e, U+1f550-1f567,
|
||||
U+1f56f-1f570, U+1f576, U+1f587, U+1f58a-1f58d, U+1f5a5, U+1f5a8, U+1f5b1-1f5b2, U+1f5c2-1f5c4, U+1f5d1-1f5d3, U+1f5dc-1f5de, U+1f5e1, U+1f5f3, U+1f6aa,
|
||||
U+1f6ac, U+1f6bd, U+1f6bf, U+1f6c1, U+1f6cb, U+1f6cd-1f6cf, U+1f6d2, U+1f6e0-1f6e1, U+1f6f0, U+1f97b-1f97f, U+1f9af, U+1f9ba, U+1f9e2-1f9e6, U+1f9ea-1f9ec,
|
||||
U+1f9ee-1f9f4, U+1f9f7-1f9ff, U+1fa71-1fa74, U+1fa79-1fa7b, U+1fa86, U+1fa8e-1fa8f, U+1fa91-1fa93, U+1fa96, U+1fa99-1faa0, U+1faa2-1faa7, U+1faaa-1faae;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-4.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-4.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+265f, U+26bd-26be, U+26f3, U+26f8, U+fe0f, U+1f004, U+1f0cf, U+1f380-1f384, U+1f386-1f38b,
|
||||
U+1f38d-1f391, U+1f396-1f397, U+1f399-1f39b, U+1f39e-1f39f, U+1f3a3-1f3a5, U+1f3a7-1f3a9,
|
||||
U+1f3ab-1f3b4, U+1f3b7-1f3bb, U+1f3bd-1f3c0, U+1f3c5-1f3c6, U+1f3c8-1f3c9, U+1f3cf-1f3d3,
|
||||
U+1f3f8-1f3f9, U+1f47e, U+1f4e2, U+1f4f7-1f4fd, U+1f52b, U+1f579, U+1f58c-1f58d, U+1f5bc,
|
||||
U+1f6f7, U+1f6f9, U+1f6fc, U+1f93f, U+1f941, U+1f945, U+1f947-1f94f, U+1f9e7-1f9e9,
|
||||
U+1f9f5-1f9f6, U+1fa70-1fa71, U+1fa80-1fa81, U+1fa83-1fa85, U+1fa87-1fa8a, U+1fa94-1fa95,
|
||||
U+1fa97-1fa98, U+1faa1, U+1faa9, U+1fadf;
|
||||
U+265f, U+26bd-26be, U+26f3, U+26f8, U+fe0f, U+1f004, U+1f0cf, U+1f380-1f384, U+1f386-1f38b, U+1f38d-1f391, U+1f396-1f397, U+1f399-1f39b, U+1f39e-1f39f,
|
||||
U+1f3a3-1f3a5, U+1f3a7-1f3a9, U+1f3ab-1f3b4, U+1f3b7-1f3bb, U+1f3bd-1f3c0, U+1f3c5-1f3c6, U+1f3c8-1f3c9, U+1f3cf-1f3d3, U+1f3f8-1f3f9, U+1f47e, U+1f4e2,
|
||||
U+1f4f7-1f4fd, U+1f52b, U+1f579, U+1f58c-1f58d, U+1f5bc, U+1f6f7, U+1f6f9, U+1f6fc, U+1f93f, U+1f941, U+1f945, U+1f947-1f94f, U+1f9e7-1f9e9, U+1f9f5-1f9f6,
|
||||
U+1fa70-1fa71, U+1fa80-1fa81, U+1fa83-1fa85, U+1fa87-1fa8a, U+1fa94-1fa95, U+1fa97-1fa98, U+1faa1, U+1faa9, U+1fadf;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-5.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-5.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+2693, U+26e9-26ea, U+26f1-26f2, U+26f4-26f5, U+26fa, U+26fd, U+2708, U+fe0f, U+1f301, U+1f303,
|
||||
U+1f306-1f307, U+1f309, U+1f310, U+1f3a0-1f3a2, U+1f3aa, U+1f3cd-1f3ce, U+1f3d5, U+1f3d7-1f3db,
|
||||
U+1f3df-1f3e6, U+1f3e8-1f3ed, U+1f3ef-1f3f0, U+1f488, U+1f492, U+1f4ba, U+1f54b-1f54d,
|
||||
U+1f5fa-1f5ff, U+1f680-1f6a2, U+1f6a4-1f6a8, U+1f6b2, U+1f6d1, U+1f6d5-1f6d6, U+1f6dd-1f6df,
|
||||
U+1f6e2-1f6e5, U+1f6e9, U+1f6eb-1f6ec, U+1f6f3-1f6f6, U+1f6f8, U+1f6fa-1f6fb, U+1f9bc-1f9bd,
|
||||
U+2693, U+26e9-26ea, U+26f1-26f2, U+26f4-26f5, U+26fa, U+26fd, U+2708, U+fe0f, U+1f301, U+1f303, U+1f306-1f307, U+1f309, U+1f310, U+1f3a0-1f3a2, U+1f3aa,
|
||||
U+1f3cd-1f3ce, U+1f3d5, U+1f3d7-1f3db, U+1f3df-1f3e6, U+1f3e8-1f3ed, U+1f3ef-1f3f0, U+1f488, U+1f492, U+1f4ba, U+1f54b-1f54d, U+1f5fa-1f5ff, U+1f680-1f6a2,
|
||||
U+1f6a4-1f6a8, U+1f6b2, U+1f6d1, U+1f6d5-1f6d6, U+1f6dd-1f6df, U+1f6e2-1f6e5, U+1f6e9, U+1f6eb-1f6ec, U+1f6f3-1f6f6, U+1f6f8, U+1f6fa-1f6fb, U+1f9bc-1f9bd,
|
||||
U+1f9ed, U+1f9f3, U+1fa7c;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-6.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-6.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2615, U+fe0f, U+1f32d-1f330, U+1f336, U+1f33d, U+1f344-1f37f, U+1f382, U+1f52a,
|
||||
U+1f7e9, U+1f7eb, U+1f942-1f944, U+1f950-1f96f, U+1f99e, U+1f9aa, U+1f9c0-1f9cb, U+1fad0-1fadc;
|
||||
U+200d, U+2615, U+fe0f, U+1f32d-1f330, U+1f336, U+1f33d, U+1f344-1f37f, U+1f382, U+1f52a, U+1f7e9, U+1f7eb, U+1f942-1f944, U+1f950-1f96f, U+1f99e, U+1f9aa,
|
||||
U+1f9c0-1f9cb, U+1fad0-1fadc;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-7.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-7.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2600-2601, U+2603-2604, U+2614, U+2618, U+26a1, U+26c4-26c5, U+26c8, U+26f0, U+2728,
|
||||
U+2744, U+2b1b, U+2b50, U+fe0f, U+1f300, U+1f304-1f305, U+1f308, U+1f30a-1f30f, U+1f311-1f321,
|
||||
U+1f324-1f32c, U+1f331-1f335, U+1f337-1f33c, U+1f33e-1f344, U+1f3d4, U+1f3d6, U+1f3dc-1f3de,
|
||||
U+1f3f5, U+1f400-1f43f, U+1f490, U+1f4a7, U+1f4ae, U+1f525, U+1f54a, U+1f573, U+1f577-1f578,
|
||||
U+1f648-1f64a, U+1f6d8, U+1f940, U+1f980-1f9ae, U+1f9ba, U+1fa90, U+1faa8, U+1fab0-1fabf,
|
||||
U+1facd-1facf, U+1fae7;
|
||||
U+200d, U+2600-2601, U+2603-2604, U+2614, U+2618, U+26a1, U+26c4-26c5, U+26c8, U+26f0, U+2728, U+2744, U+2b1b, U+2b50, U+fe0f, U+1f300, U+1f304-1f305,
|
||||
U+1f308, U+1f30a-1f30f, U+1f311-1f321, U+1f324-1f32c, U+1f331-1f335, U+1f337-1f33c, U+1f33e-1f344, U+1f3d4, U+1f3d6, U+1f3dc-1f3de, U+1f3f5, U+1f400-1f43f,
|
||||
U+1f490, U+1f4a7, U+1f4ae, U+1f525, U+1f54a, U+1f573, U+1f577-1f578, U+1f648-1f64a, U+1f6d8, U+1f940, U+1f980-1f9ae, U+1f9ba, U+1fa90, U+1faa8,
|
||||
U+1fab0-1fabf, U+1facd-1facf, U+1fae7;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-8.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-8.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2695-2696, U+26f7, U+26f9, U+2708, U+2764, U+27a1, U+fe0f, U+1f33e, U+1f373, U+1f37c,
|
||||
U+1f384-1f385, U+1f393, U+1f3a4, U+1f3a8, U+1f3c2-1f3c4, U+1f3c7, U+1f3ca-1f3cc, U+1f3eb,
|
||||
U+1f3ed, U+1f3fb-1f3ff, U+1f430, U+1f466-1f469, U+1f46b-1f478, U+1f47c, U+1f481-1f483,
|
||||
U+1f486-1f487, U+1f48b, U+1f48f, U+1f491, U+1f4bb-1f4bc, U+1f527, U+1f52c, U+1f574-1f575,
|
||||
U+1f57a, U+1f645-1f647, U+1f64b, U+1f64d-1f64e, U+1f680, U+1f692, U+1f6a3, U+1f6b4-1f6b6,
|
||||
U+1f6c0, U+1f6cc, U+1f91d, U+1f926, U+1f930-1f931, U+1f934-1f93a, U+1f93c-1f93e, U+1f977,
|
||||
U+1f9af-1f9b3, U+1f9b8-1f9b9, U+1f9bc-1f9bd, U+1f9cc-1f9cf, U+1f9d1-1f9df, U+1fa70, U+1fa82,
|
||||
U+1fac3-1fac5, U+1fac8, U+1faef;
|
||||
U+200d, U+2695-2696, U+26f7, U+26f9, U+2708, U+2764, U+27a1, U+fe0f, U+1f33e, U+1f373, U+1f37c, U+1f384-1f385, U+1f393, U+1f3a4, U+1f3a8, U+1f3c2-1f3c4,
|
||||
U+1f3c7, U+1f3ca-1f3cc, U+1f3eb, U+1f3ed, U+1f3fb-1f3ff, U+1f430, U+1f466-1f469, U+1f46b-1f478, U+1f47c, U+1f481-1f483, U+1f486-1f487, U+1f48b, U+1f48f,
|
||||
U+1f491, U+1f4bb-1f4bc, U+1f527, U+1f52c, U+1f574-1f575, U+1f57a, U+1f645-1f647, U+1f64b, U+1f64d-1f64e, U+1f680, U+1f692, U+1f6a3, U+1f6b4-1f6b6, U+1f6c0,
|
||||
U+1f6cc, U+1f91d, U+1f926, U+1f930-1f931, U+1f934-1f93a, U+1f93c-1f93e, U+1f977, U+1f9af-1f9b3, U+1f9b8-1f9b9, U+1f9bc-1f9bd, U+1f9cc-1f9cf, U+1f9d1-1f9df,
|
||||
U+1fa70, U+1fa82, U+1fac3-1fac5, U+1fac8, U+1faef;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-9.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-9.woff2) format('woff2');
|
||||
unicode-range:
|
||||
U+200d, U+2603, U+261d, U+2620, U+2639-263a, U+26a1, U+26c4, U+270a-270d, U+2728, U+2763-2764,
|
||||
U+2b50, U+fe0f, U+1f31a-1f31f, U+1f32b, U+1f389-1f38a, U+1f3fb-1f3ff, U+1f440-1f450,
|
||||
U+1f463-1f465, U+1f479-1f47b, U+1f47d-1f480, U+1f485, U+1f48b-1f48c, U+1f493-1f49f, U+1f4a2,
|
||||
U+1f4a4-1f4a6, U+1f4a8-1f4ab, U+1f4af, U+1f525, U+1f573, U+1f590, U+1f595-1f596, U+1f5a4,
|
||||
U+1f5e3, U+1f600-1f644, U+1f648-1f64a, U+1f64c, U+1f64f, U+1f90c-1f925, U+1f927-1f92f,
|
||||
U+1f932-1f933, U+1f970-1f976, U+1f978-1f97a, U+1f9a0, U+1f9b4-1f9b7, U+1f9bb, U+1f9be-1f9bf,
|
||||
U+1f9d0, U+1f9e0-1f9e1, U+1fa75-1fa79, U+1fac0-1fac2, U+1fac6, U+1fae0-1fae6, U+1fae8-1faea,
|
||||
U+1faef-1fafa;
|
||||
U+200d, U+2603, U+261d, U+2620, U+2639-263a, U+26a1, U+26c4, U+270a-270d, U+2728, U+2763-2764, U+2b50, U+fe0f, U+1f31a-1f31f, U+1f32b, U+1f389-1f38a,
|
||||
U+1f3fb-1f3ff, U+1f440-1f450, U+1f463-1f465, U+1f479-1f47b, U+1f47d-1f480, U+1f485, U+1f48b-1f48c, U+1f493-1f49f, U+1f4a2, U+1f4a4-1f4a6, U+1f4a8-1f4ab,
|
||||
U+1f4af, U+1f525, U+1f573, U+1f590, U+1f595-1f596, U+1f5a4, U+1f5e3, U+1f600-1f644, U+1f648-1f64a, U+1f64c, U+1f64f, U+1f90c-1f925, U+1f927-1f92f,
|
||||
U+1f932-1f933, U+1f970-1f976, U+1f978-1f97a, U+1f9a0, U+1f9b4-1f9b7, U+1f9bb, U+1f9be-1f9bf, U+1f9d0, U+1f9e0-1f9e1, U+1fa75-1fa79, U+1fac0-1fac2, U+1fac6,
|
||||
U+1fae0-1fae6, U+1fae8-1faea, U+1faef-1fafa;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OJ Emoji Lead";
|
||||
font-family: 'OJ Emoji Lead';
|
||||
font-display: swap;
|
||||
src:
|
||||
local("Apple Color Emoji"),
|
||||
local("Noto Color Emoji"),
|
||||
url(/emoji/noto-v47-10.woff2) format("woff2");
|
||||
local('Apple Color Emoji'),
|
||||
local('Noto Color Emoji'),
|
||||
url(/emoji/noto-v47-10.woff2) format('woff2');
|
||||
unicode-range: U+1f6d9, U+1fa8b-1fa8d, U+1facc, U+1fadd, U+1faeb;
|
||||
}
|
||||
|
||||
@@ -315,8 +270,8 @@
|
||||
Naive UI 的 body 规则是运行时插进来的,晚于这份文件,所以这里用 html body 提一级优先级 */
|
||||
html body {
|
||||
font-family:
|
||||
"OJ Emoji Lead", v-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif,
|
||||
"Apple Color Emoji", "Noto Color Emoji", "OJ Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
'OJ Emoji Lead', v-sans, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Apple Color Emoji', 'Noto Color Emoji', 'OJ Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
}
|
||||
|
||||
.md-editor-preview .md-editor-code .md-editor-code-head {
|
||||
|
||||
+13
-18
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||
import "normalize.css"
|
||||
import "./index.css"
|
||||
import "shared/highlight.css"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useConfigUpdate } from "shared/composables/configUpdate"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import CollabHost from "shared/components/CollabHost.vue"
|
||||
import { darkTheme, dateZhCN, zhCN } from 'naive-ui'
|
||||
import 'normalize.css'
|
||||
import './index.css'
|
||||
import 'shared/highlight.css'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useConfigUpdate } from 'shared/composables/configUpdate'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import CollabHost from 'shared/components/CollabHost.vue'
|
||||
|
||||
const isDark = useDark()
|
||||
const configStore = useConfigStore()
|
||||
@@ -43,23 +43,18 @@ watch(
|
||||
|
||||
// n-code(提交详情、看代码的弹框)的高亮,空闲时再加载,不挡首屏。
|
||||
// 它只认 highlight.js 的接口,shared/highlight 给了一个同名的壳,里面是和编辑器同一套解析器
|
||||
const hljsInstance = shallowRef<typeof import("shared/highlight").hljs>()
|
||||
const hljsInstance = shallowRef<typeof import('shared/highlight').hljs>()
|
||||
onMounted(() => {
|
||||
const load = async () => {
|
||||
hljsInstance.value = (await import("shared/highlight")).hljs
|
||||
hljsInstance.value = (await import('shared/highlight')).hljs
|
||||
}
|
||||
if ("requestIdleCallback" in window) requestIdleCallback(load)
|
||||
if ('requestIdleCallback' in window) requestIdleCallback(load)
|
||||
else setTimeout(load, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
:theme="isDark ? darkTheme : null"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:hljs="hljsInstance"
|
||||
>
|
||||
<n-config-provider :theme="isDark ? darkTheme : null" :locale="zhCN" :date-locale="dateZhCN" :hljs="hljsInstance">
|
||||
<n-dialog-provider>
|
||||
<n-message-provider>
|
||||
<router-view></router-view>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { AchievementRarity } from "utils/types"
|
||||
import {
|
||||
createAchievement,
|
||||
getMetricOptions,
|
||||
updateAchievement,
|
||||
type AdminAchievement,
|
||||
type MetricOption,
|
||||
deleteAchievement,
|
||||
} from "admin/api"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import type { AchievementRarity } from 'utils/types'
|
||||
import { createAchievement, getMetricOptions, updateAchievement, type AdminAchievement, type MetricOption, deleteAchievement } from 'admin/api'
|
||||
import AchievementIcon from 'shared/components/AchievementIcon.vue'
|
||||
import { RARITY_LABEL } from 'utils/constants'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
|
||||
label,
|
||||
@@ -21,7 +14,7 @@ const props = defineProps<{
|
||||
show: boolean
|
||||
editing: AdminAchievement | null
|
||||
}>()
|
||||
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; saved: [] }>()
|
||||
const dialog = useDialog()
|
||||
|
||||
/** 删除放在编辑框左下角(列表改成阶梯之后,每一档上只留「点了就编辑」) */
|
||||
@@ -30,16 +23,14 @@ function confirmDelete() {
|
||||
if (!target) return
|
||||
dialog.warning({
|
||||
title: `删除「${target.name}」?`,
|
||||
content: target.unlockCount
|
||||
? `${target.unlockCount} 人已经拿到了,删掉会连他们的记录一起删。只是不想再发,关掉「上架」就行。`
|
||||
: "删掉就找不回来了。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
content: target.unlockCount ? `${target.unlockCount} 人已经拿到了,删掉会连他们的记录一起删。只是不想再发,关掉「上架」就行。` : '删掉就找不回来了。',
|
||||
positiveText: '删除',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: async () => {
|
||||
await deleteAchievement(target.id)
|
||||
message.success("删掉了")
|
||||
emit("update:show", false)
|
||||
emit("saved")
|
||||
message.success('删掉了')
|
||||
emit('update:show', false)
|
||||
emit('saved')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -51,13 +42,13 @@ const saving = ref(false)
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "noto:trophy",
|
||||
rarity: "bronze" as AchievementRarity,
|
||||
name: '',
|
||||
description: '',
|
||||
icon: 'noto:trophy',
|
||||
rarity: 'bronze' as AchievementRarity,
|
||||
hidden: false,
|
||||
metric: "",
|
||||
operator: "gte" as "gte" | "lte",
|
||||
metric: '',
|
||||
operator: 'gte' as 'gte' | 'lte',
|
||||
threshold: 1,
|
||||
visible: true,
|
||||
order: 0,
|
||||
@@ -68,9 +59,7 @@ const form = ref(emptyForm())
|
||||
|
||||
const metricOptions = computed(() => metrics.value.map((m) => ({ label: m.name, value: m.key })))
|
||||
|
||||
const metricHelp = computed(
|
||||
() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? "",
|
||||
)
|
||||
const metricHelp = computed(() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? '')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
@@ -83,14 +72,14 @@ watch(
|
||||
if (props.editing) {
|
||||
form.value = { ...emptyForm(), ...props.editing }
|
||||
} else {
|
||||
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
|
||||
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? '' }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.metric) {
|
||||
message.error("名称和指标不能为空")
|
||||
message.error('名称和指标不能为空')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
@@ -100,9 +89,9 @@ async function save() {
|
||||
} else {
|
||||
await createAchievement(form.value)
|
||||
}
|
||||
message.success("保存成功")
|
||||
emit("update:show", false)
|
||||
emit("saved")
|
||||
message.success('保存成功')
|
||||
emit('update:show', false)
|
||||
emit('saved')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -110,24 +99,14 @@ async function save() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
style="width: 560px"
|
||||
:title="editing ? '编辑成就' : '新建成就'"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-modal :show="show" preset="card" style="width: 560px" :title="editing ? '编辑成就' : '新建成就'" @update:show="emit('update:show', $event)">
|
||||
<!-- 手机上弹框只有 350 宽,标签放到上面,输入框才有地方 -->
|
||||
<n-form :label-placement="isMobile ? 'top' : 'left'" :label-width="80">
|
||||
<n-form-item label="名称" required>
|
||||
<n-input v-model:value="form.name" placeholder="成就名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述" required>
|
||||
<n-input
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
placeholder="达成条件的描述,展示给学生看"
|
||||
/>
|
||||
<n-input v-model:value="form.description" type="textarea" placeholder="达成条件的描述,展示给学生看" />
|
||||
</n-form-item>
|
||||
<n-form-item label="图标">
|
||||
<n-flex vertical :size="4" style="flex: 1">
|
||||
@@ -138,8 +117,7 @@ async function save() {
|
||||
<n-input v-model:value="form.icon" placeholder="iconify 图标名,例如 noto:owl" />
|
||||
</n-flex>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
填 iconify 图标名(推荐 noto: 开头的彩色 emoji 图标),左侧是实时
|
||||
预览;预览不出来说明名字写错了。图标名可在 icon-sets.iconify.design 搜索。
|
||||
填 iconify 图标名(推荐 noto: 开头的彩色 emoji 图标),左侧是实时 预览;预览不出来说明名字写错了。图标名可在 icon-sets.iconify.design 搜索。
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
@@ -178,9 +156,7 @@ async function save() {
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button v-if="editing" quaternary type="error" class="delete" @click="confirmDelete">
|
||||
删除
|
||||
</n-button>
|
||||
<n-button v-if="editing" quaternary type="error" class="delete" @click="confirmDelete"> 删除 </n-button>
|
||||
<n-button @click="emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
|
||||
</n-flex>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import {
|
||||
getAdminAchievements,
|
||||
getBaseInfo,
|
||||
getMetricOptions,
|
||||
type AdminAchievement,
|
||||
type MetricOption,
|
||||
} from "admin/api"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import AchievementModal from "./components/AchievementModal.vue"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getAdminAchievements, getBaseInfo, getMetricOptions, type AdminAchievement, type MetricOption } from 'admin/api'
|
||||
import AchievementIcon from 'shared/components/AchievementIcon.vue'
|
||||
import AchievementModal from './components/AchievementModal.vue'
|
||||
import { RARITY_LABEL } from 'utils/constants'
|
||||
|
||||
/**
|
||||
* 后台成就(设计稿「我的成就重设计」AchAdmin):按指标分组,一组一行排成阶梯 ——
|
||||
@@ -37,11 +31,7 @@ const groups = computed(() => {
|
||||
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
|
||||
const order = metrics.value.map((m) => m.key)
|
||||
return [...byMetric.entries()]
|
||||
.sort(
|
||||
([a], [b]) =>
|
||||
(order.indexOf(a) === -1 ? 99 : order.indexOf(a)) -
|
||||
(order.indexOf(b) === -1 ? 99 : order.indexOf(b)),
|
||||
)
|
||||
.sort(([a], [b]) => (order.indexOf(a) === -1 ? 99 : order.indexOf(a)) - (order.indexOf(b) === -1 ? 99 : order.indexOf(b)))
|
||||
.map(([metric, items]) => ({
|
||||
metric,
|
||||
name: items[0]?.metricName ?? metric,
|
||||
@@ -50,9 +40,9 @@ const groups = computed(() => {
|
||||
})
|
||||
|
||||
function percent(a: AdminAchievement) {
|
||||
if (!students.value) return ""
|
||||
if (!students.value) return ''
|
||||
const value = (a.unlockCount / students.value) * 100
|
||||
return value > 0 && value < 1 ? "<1%" : `${Math.round(value)}%`
|
||||
return value > 0 && value < 1 ? '<1%' : `${Math.round(value)}%`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -103,20 +93,12 @@ onMounted(load)
|
||||
<div class="steps">
|
||||
<template v-for="(a, i) in group.items" :key="a.id">
|
||||
<span v-if="i" class="arrow" aria-hidden="true">→</span>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
:class="{ off: !a.visible }"
|
||||
:title="`${RARITY_LABEL[a.rarity] ?? a.rarity} · 点了编辑`"
|
||||
@click="edit(a)"
|
||||
>
|
||||
<button type="button" class="step" :class="{ off: !a.visible }" :title="`${RARITY_LABEL[a.rarity] ?? a.rarity} · 点了编辑`" @click="edit(a)">
|
||||
<AchievementIcon :icon="a.icon" :size="26" />
|
||||
<span class="step-text">
|
||||
<b>
|
||||
{{ a.name }}
|
||||
<span class="muted small"
|
||||
>{{ a.operator === "gte" ? "≥" : "≤" }} {{ a.threshold }}</span
|
||||
>
|
||||
<span class="muted small">{{ a.operator === 'gte' ? '≥' : '≤' }} {{ a.threshold }}</span>
|
||||
</b>
|
||||
<span class="small">
|
||||
{{ a.unlockCount }} 人
|
||||
@@ -143,7 +125,7 @@ onMounted(load)
|
||||
|
||||
/* 斑马底纹,和后台其它列表一样 */
|
||||
.group:nth-child(even) {
|
||||
background: v-bind("theme.tableColorStriped");
|
||||
background: v-bind('theme.tableColorStriped');
|
||||
}
|
||||
|
||||
.group {
|
||||
@@ -151,7 +133,7 @@ onMounted(load)
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.metric {
|
||||
@@ -168,7 +150,7 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.step {
|
||||
@@ -178,8 +160,8 @@ onMounted(load)
|
||||
min-width: 160px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.cardColor');
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
@@ -187,7 +169,7 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.step:hover {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
border-color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.step.off {
|
||||
@@ -210,11 +192,11 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.off-note {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
/* 手机上指标名放在一组上头,下面两档一行;箭头拿掉(折行之后箭头会单独落在行首) */
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "@iconify/vue"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { useBackClose } from "shared/composables/backClose"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminAiReport, AdminAiReportListItem } from "utils/types"
|
||||
import { getAIReportDetail, getAIReportList, pinAIReport } from "../api"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { AdminAiReport, AdminAiReportListItem } from 'utils/types'
|
||||
import { getAIReportDetail, getAIReportList, pinAIReport } from '../api'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
|
||||
const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue"))
|
||||
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
|
||||
|
||||
/**
|
||||
* 后台 AI 报告(设计稿「智能分析重设计」AiAdmin):左边一份一行(用户名、班级、时间、
|
||||
@@ -45,7 +45,7 @@ const route = useRoute()
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
username: typeof route.query.username === "string" ? route.query.username : "",
|
||||
username: typeof route.query.username === 'string' ? route.query.username : '',
|
||||
})
|
||||
|
||||
const selectedId = ref<number | null>(null)
|
||||
@@ -86,9 +86,9 @@ async function togglePin() {
|
||||
/** 摘要里去掉 Markdown 的记号:粗体星号、标题井号,还有表格的竖线和分隔线 */
|
||||
function excerpt(text: string) {
|
||||
return text
|
||||
.replace(/[*#>`|]+/g, " ")
|
||||
.replace(/-{2,}/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[*#>`|]+/g, ' ')
|
||||
.replace(/-{2,}/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -116,20 +116,13 @@ watchDebounced(
|
||||
<div v-else class="layout">
|
||||
<div class="list-col">
|
||||
<div class="list">
|
||||
<button
|
||||
v-for="r in reports"
|
||||
:key="r.id"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: r.id === selectedId }"
|
||||
@click="pick(r.id)"
|
||||
>
|
||||
<button v-for="r in reports" :key="r.id" type="button" class="item" :class="{ on: r.id === selectedId }" @click="pick(r.id)">
|
||||
<span class="item-head">
|
||||
<UserName :username="r.username" />
|
||||
<span class="muted small">{{ r.className ? classLabel(r.className) : "" }}</span>
|
||||
<span class="muted small">{{ r.className ? classLabel(r.className) : '' }}</span>
|
||||
<span v-if="r.isPinned" class="pinned">固定</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted small num">{{ parseTime(r.createTime, "MM-DD HH:mm") }}</span>
|
||||
<span class="muted small num">{{ parseTime(r.createTime, 'MM-DD HH:mm') }}</span>
|
||||
</span>
|
||||
<span class="item-text">{{ excerpt(r.analysisExcerpt) }}</span>
|
||||
</button>
|
||||
@@ -149,8 +142,7 @@ watchDebounced(
|
||||
<div class="detail-head">
|
||||
<UserName :username="selected.username" />
|
||||
<span class="muted">
|
||||
{{ selected.className ? classLabel(selected.className) + " · " : ""
|
||||
}}{{ parseTime(selected.createTime, "YYYY-MM-DD HH:mm") }}
|
||||
{{ selected.className ? classLabel(selected.className) + ' · ' : '' }}{{ parseTime(selected.createTime, 'YYYY-MM-DD HH:mm') }}
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<label class="pin">
|
||||
@@ -183,7 +175,7 @@ watchDebounced(
|
||||
}
|
||||
|
||||
.list {
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -195,7 +187,7 @@ watchDebounced(
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
@@ -209,16 +201,16 @@ watchDebounced(
|
||||
|
||||
/* 斑马底纹,和后台其它列表一样 */
|
||||
.item:nth-child(even) {
|
||||
background: v-bind("theme.tableColorStriped");
|
||||
background: v-bind('theme.tableColorStriped');
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.item.on {
|
||||
background: v-bind("theme.hoverColor");
|
||||
box-shadow: inset 3px 0 0 v-bind("theme.primaryColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
box-shadow: inset 3px 0 0 v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.item-head {
|
||||
@@ -232,7 +224,7 @@ watchDebounced(
|
||||
.item-text {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -241,7 +233,7 @@ watchDebounced(
|
||||
|
||||
.pinned {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.warningColor");
|
||||
color: v-bind('theme.warningColor');
|
||||
}
|
||||
|
||||
.detail {
|
||||
@@ -255,7 +247,7 @@ watchDebounced(
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.pin {
|
||||
@@ -277,7 +269,7 @@ watchDebounced(
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -316,7 +308,7 @@ watchDebounced(
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 32px;
|
||||
background: v-bind("theme.bodyColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
}
|
||||
|
||||
.detail-bar {
|
||||
@@ -330,8 +322,8 @@ watchDebounced(
|
||||
margin: 0 -16px 12px;
|
||||
padding: 0 8px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.bodyColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.bodyColor');
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteAnnouncement } from "admin/api"
|
||||
import { deleteAnnouncement } from 'admin/api'
|
||||
|
||||
/**
|
||||
* 公告一行的操作:编辑、置顶、删除直接摆出来(用户说的:不要收进 ⋯)
|
||||
@@ -17,21 +17,21 @@ const dialog = useDialog()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin announcement edit",
|
||||
name: 'admin announcement edit',
|
||||
params: { announcementID: props.announcementID },
|
||||
})
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
dialog.warning({
|
||||
title: "删除这条公告?",
|
||||
content: "删掉就找不回来了。只是不想让学生看到的话,关掉「学生看得到」就行。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
title: '删除这条公告?',
|
||||
content: '删掉就找不回来了。只是不想让学生看到的话,关掉「学生看得到」就行。',
|
||||
positiveText: '删除',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: async () => {
|
||||
await deleteAnnouncement(props.announcementID)
|
||||
message.success("删掉了")
|
||||
emit("deleted")
|
||||
message.success('删掉了')
|
||||
emit('deleted')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function confirmDelete() {
|
||||
<n-flex :size="6" :wrap="false">
|
||||
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||
<n-button size="small" @click="emit('toggleTop')">
|
||||
{{ props.top ? "取消置顶" : "置顶" }}
|
||||
{{ props.top ? '取消置顶' : '置顶' }}
|
||||
</n-button>
|
||||
<n-button size="small" quaternary type="error" @click="confirmDelete">删除</n-button>
|
||||
</n-flex>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import type { AnnouncementEdit } from "utils/types"
|
||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import TextEditor from 'shared/components/TextEditor.vue'
|
||||
import type { AnnouncementEdit } from 'utils/types'
|
||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from '../api'
|
||||
|
||||
interface Props {
|
||||
announcementID?: string
|
||||
@@ -17,16 +17,16 @@ const [ready, toggleReady] = useToggle()
|
||||
|
||||
const announcement = reactive<AnnouncementEdit>({
|
||||
id: 0,
|
||||
title: "",
|
||||
tag: "公告",
|
||||
content: "",
|
||||
title: '',
|
||||
tag: '公告',
|
||||
content: '',
|
||||
visible: false,
|
||||
top: false,
|
||||
})
|
||||
|
||||
const tags: SelectOption[] = [
|
||||
{ label: "公告", value: "公告" },
|
||||
{ label: "更新", value: "更新" },
|
||||
{ label: '公告', value: '公告' },
|
||||
{ label: '更新', value: '更新' },
|
||||
]
|
||||
|
||||
async function init() {
|
||||
@@ -46,25 +46,25 @@ async function init() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (announcement.content === "<p><br></p>") {
|
||||
announcement.content = ""
|
||||
if (announcement.content === '<p><br></p>') {
|
||||
announcement.content = ''
|
||||
}
|
||||
if (!announcement.title || !announcement.content) {
|
||||
message.error("标题和正文必填")
|
||||
message.error('标题和正文必填')
|
||||
return
|
||||
}
|
||||
const api = {
|
||||
"admin announcement create": createAnnouncement,
|
||||
"admin announcement edit": editAnnouncement,
|
||||
'admin announcement create': createAnnouncement,
|
||||
'admin announcement edit': editAnnouncement,
|
||||
}[route.name as string]
|
||||
try {
|
||||
await api!(announcement)
|
||||
if (route.name === "admin announcement create") {
|
||||
message.success("发好了")
|
||||
if (route.name === 'admin announcement create') {
|
||||
message.success('发好了')
|
||||
} else {
|
||||
message.success("改好了")
|
||||
message.success('改好了')
|
||||
}
|
||||
router.push({ name: "admin announcement list" })
|
||||
router.push({ name: 'admin announcement list' })
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
@@ -73,23 +73,14 @@ async function submit() {
|
||||
onMounted(init)
|
||||
</script>
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="route.name === 'admin announcement create' ? '写一条公告' : '改公告'"
|
||||
:back="{ name: 'admin announcement list' }"
|
||||
>
|
||||
<PageHeader :title="route.name === 'admin announcement create' ? '写一条公告' : '改公告'" :back="{ name: 'admin announcement list' }">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<!-- 标题、类型、置顶、学生看得到排一行;保存在右上角,不用滚到正文底下去点 -->
|
||||
<div class="meta">
|
||||
<n-input
|
||||
v-model:value="announcement.title"
|
||||
placeholder="标题"
|
||||
size="large"
|
||||
class="title"
|
||||
aria-label="标题"
|
||||
/>
|
||||
<n-input v-model:value="announcement.title" placeholder="标题" size="large" class="title" aria-label="标题" />
|
||||
<n-radio-group v-model:value="announcement.tag" aria-label="类型">
|
||||
<n-radio-button v-for="t in tags" :key="String(t.value)" :value="t.value">
|
||||
{{ t.label }}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { NSwitch, useThemeVars } from "naive-ui"
|
||||
import { RouterLink } from "vue-router"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminAnnouncementListItem } from "utils/types"
|
||||
import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { NSwitch, useThemeVars } from 'naive-ui'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { AdminAnnouncementListItem } from 'utils/types'
|
||||
import { editAnnouncement, getAnnouncement, getAnnouncementList } from '../api'
|
||||
import Actions from './components/Actions.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
const message = useMessage()
|
||||
const total = ref(0)
|
||||
@@ -27,26 +27,26 @@ const theme = useThemeVars()
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
function renderTitle(row: AdminAnnouncementListItem) {
|
||||
return h("span", { class: "title-cell" }, [
|
||||
return h('span', { class: 'title-cell' }, [
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
class: "title-link",
|
||||
to: { name: "admin announcement edit", params: { announcementID: row.id } },
|
||||
class: 'title-link',
|
||||
to: { name: 'admin announcement edit', params: { announcementID: row.id } },
|
||||
},
|
||||
() => row.title,
|
||||
),
|
||||
row.top ? h("span", { class: "top" }, "置顶") : null,
|
||||
h("span", { class: "muted small" }, row.tag),
|
||||
row.top ? h('span', { class: 'top' }, '置顶') : null,
|
||||
h('span', { class: 'muted small' }, row.tag),
|
||||
])
|
||||
}
|
||||
|
||||
function renderVisible(row: AdminAnnouncementListItem) {
|
||||
return h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: isMobile.value ? "medium" : "small",
|
||||
size: isMobile.value ? 'medium' : 'small',
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggle(row, "visible"),
|
||||
onUpdateValue: () => toggle(row, 'visible'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,61 +55,61 @@ function renderActions(row: AdminAnnouncementListItem) {
|
||||
announcementID: row.id,
|
||||
top: row.top,
|
||||
onDeleted: listAnnouncements,
|
||||
onToggleTop: () => toggle(row, "top"),
|
||||
onToggleTop: () => toggle(row, 'top'),
|
||||
})
|
||||
}
|
||||
|
||||
/** 手机上一条一张卡:标题一行,发布 · 改过一行小字,操作一行;「学生看得到」单独一列靠右 */
|
||||
function renderMobileCard(row: AdminAnnouncementListItem) {
|
||||
const created = parseTime(row.createTime, "YYYY-MM-DD")
|
||||
const updated = parseTime(row.lastUpdateTime, "YYYY-MM-DD")
|
||||
return h("div", { class: "m-card" }, [
|
||||
const created = parseTime(row.createTime, 'YYYY-MM-DD')
|
||||
const updated = parseTime(row.lastUpdateTime, 'YYYY-MM-DD')
|
||||
return h('div', { class: 'm-card' }, [
|
||||
renderTitle(row),
|
||||
h("div", { class: "m-meta muted num" }, [
|
||||
h("span", { class: "m-item" }, `${created} 发布`),
|
||||
updated === created ? null : h("span", { class: "m-item" }, ` · ${updated} 改过`),
|
||||
h('div', { class: 'm-meta muted num' }, [
|
||||
h('span', { class: 'm-item' }, `${created} 发布`),
|
||||
updated === created ? null : h('span', { class: 'm-item' }, ` · ${updated} 改过`),
|
||||
]),
|
||||
renderActions(row),
|
||||
])
|
||||
}
|
||||
|
||||
const mobileColumns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
||||
{ title: "标题", key: "title", render: renderMobileCard },
|
||||
{ title: "看得到", key: "visible", width: 72, align: "center", render: renderVisible },
|
||||
{ title: '标题', key: 'title', render: renderMobileCard },
|
||||
{ title: '看得到', key: 'visible', width: 72, align: 'center', render: renderVisible },
|
||||
]
|
||||
|
||||
const desktopColumns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
width: 360,
|
||||
render: renderTitle,
|
||||
},
|
||||
{
|
||||
title: "发布",
|
||||
key: "createTime",
|
||||
title: '发布',
|
||||
key: 'createTime',
|
||||
width: 120,
|
||||
render: (row) => h("span", { class: "num" }, parseTime(row.createTime, "YYYY-MM-DD")),
|
||||
render: (row) => h('span', { class: 'num' }, parseTime(row.createTime, 'YYYY-MM-DD')),
|
||||
},
|
||||
{
|
||||
title: "改过",
|
||||
key: "lastUpdateTime",
|
||||
title: '改过',
|
||||
key: 'lastUpdateTime',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
const created = parseTime(row.createTime, "YYYY-MM-DD")
|
||||
const updated = parseTime(row.lastUpdateTime, "YYYY-MM-DD")
|
||||
return h("span", { class: "num muted" }, updated === created ? "—" : updated)
|
||||
const created = parseTime(row.createTime, 'YYYY-MM-DD')
|
||||
const updated = parseTime(row.lastUpdateTime, 'YYYY-MM-DD')
|
||||
return h('span', { class: 'num muted' }, updated === created ? '—' : updated)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "学生看得到",
|
||||
key: "visible",
|
||||
title: '学生看得到',
|
||||
key: 'visible',
|
||||
width: 100,
|
||||
render: renderVisible,
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
title: '',
|
||||
key: 'actions',
|
||||
minWidth: 240,
|
||||
render: renderActions,
|
||||
},
|
||||
@@ -119,7 +119,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
|
||||
|
||||
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
|
||||
// 拿列表里那行直接回传会 400。所以先取回整条再改。
|
||||
async function toggle(announcement: AdminAnnouncementListItem, field: "visible" | "top") {
|
||||
async function toggle(announcement: AdminAnnouncementListItem, field: 'visible' | 'top') {
|
||||
try {
|
||||
const full = await getAnnouncement(announcement.id)
|
||||
await editAnnouncement({
|
||||
@@ -127,13 +127,13 @@ async function toggle(announcement: AdminAnnouncementListItem, field: "visible"
|
||||
title: full.title,
|
||||
tag: full.tag,
|
||||
content: full.content,
|
||||
visible: field === "visible" ? !full.visible : full.visible,
|
||||
top: field === "top" ? !full.top : full.top,
|
||||
visible: field === 'visible' ? !full.visible : full.visible,
|
||||
top: field === 'top' ? !full.top : full.top,
|
||||
})
|
||||
if (field === "top") listAnnouncements()
|
||||
if (field === 'top') listAnnouncements()
|
||||
else announcement.visible = !full.visible
|
||||
} catch {
|
||||
message.error("没改成,刷新一下再试")
|
||||
message.error('没改成,刷新一下再试')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,18 +153,10 @@ watch(query, listAnnouncements, { deep: true })
|
||||
<template>
|
||||
<PageHeader title="公告">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin announcement create' })">
|
||||
写一条公告
|
||||
</n-button>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin announcement create' })"> 写一条公告 </n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
striped
|
||||
class="ann-table"
|
||||
:columns="columns"
|
||||
:data="announcements"
|
||||
/>
|
||||
<n-data-table :loading="loading" striped class="ann-table" :columns="columns" :data="announcements" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
@@ -197,7 +189,7 @@ watch(query, listAnnouncements, { deep: true })
|
||||
}
|
||||
|
||||
.ann-table :deep(.title-link) {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -210,12 +202,12 @@ watch(query, listAnnouncements, { deep: true })
|
||||
font-size: 12px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
color: v-bind("theme.warningColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind('theme.warningColor');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.ann-table :deep(.muted) {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.ann-table :deep(.small) {
|
||||
|
||||
+100
-175
@@ -1,4 +1,4 @@
|
||||
import { toAdminType, toProblemPermission } from "@oj2/contract"
|
||||
import { toAdminType, toProblemPermission } from '@oj2/contract'
|
||||
import type {
|
||||
AddContestProblemRequest,
|
||||
ChallengeSuggestionsResponse,
|
||||
@@ -39,9 +39,9 @@ import type {
|
||||
UpdateTutorialRequest,
|
||||
UpdateUserRequest,
|
||||
UpdateWebsiteConfigRequest,
|
||||
} from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import { toAdminProblemRow } from "admin/transforms"
|
||||
} from '@oj2/contract'
|
||||
import api from 'utils/api'
|
||||
import { toAdminProblemRow } from 'admin/transforms'
|
||||
import type {
|
||||
BatchProblemTagResponse,
|
||||
GenerateSqlTestCaseResponse,
|
||||
@@ -87,7 +87,7 @@ import type {
|
||||
TutorialListItem,
|
||||
TutorialEdit,
|
||||
TagCategory,
|
||||
} from "utils/types"
|
||||
} from 'utils/types'
|
||||
import type {
|
||||
TutorialProblemCandidate,
|
||||
AstCheckRequest,
|
||||
@@ -96,14 +96,14 @@ import type {
|
||||
GenerateTestInputsResponse,
|
||||
TestCaseFiles,
|
||||
CheckUsernamesResponse,
|
||||
} from "@oj2/contract"
|
||||
} from '@oj2/contract'
|
||||
|
||||
export function getBaseInfo() {
|
||||
return api.get<DashboardInfo>("admin/dashboard")
|
||||
return api.get<DashboardInfo>('admin/dashboard')
|
||||
}
|
||||
|
||||
export function randomUser10(classroom: string) {
|
||||
return api.get<string[]>("admin/random-usernames", {
|
||||
return api.get<string[]>('admin/random-usernames', {
|
||||
params: { classroom },
|
||||
})
|
||||
}
|
||||
@@ -116,11 +116,11 @@ export async function getProblemList(
|
||||
contestID?: string,
|
||||
tagId?: number,
|
||||
/** 题型:sql = SQL 题,code = 编程题;不传是全部(比赛题列表不认) */
|
||||
kind?: "sql" | "code" | "",
|
||||
kind?: 'sql' | 'code' | '',
|
||||
/** 1 = 学生看得到的,0 = 藏着的;不传是全部(比赛题列表不认) */
|
||||
visible?: "1" | "0" | "",
|
||||
visible?: '1' | '0' | '',
|
||||
) {
|
||||
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
|
||||
const endpoint = contestID ? `admin/contests/${contestID}/problems` : 'admin/problems'
|
||||
const res = await api.get<AdminProblemList>(endpoint, {
|
||||
params: {
|
||||
offset,
|
||||
@@ -141,7 +141,7 @@ export async function getProblemList(
|
||||
|
||||
/** 勾了几道,一次让学生看得到 / 藏起来 */
|
||||
export function batchSetProblemVisible(problemIds: number[], visible: boolean) {
|
||||
return api.post<{ problemCount: number }>("admin/problems/batch-visible", {
|
||||
return api.post<{ problemCount: number }>('admin/problems/batch-visible', {
|
||||
problemIds,
|
||||
visible,
|
||||
} satisfies BatchProblemVisibleRequest)
|
||||
@@ -149,7 +149,7 @@ export function batchSetProblemVisible(problemIds: number[], visible: boolean) {
|
||||
|
||||
// 比赛「从题库中选择」用:列所有可见的公开题,不只是自己创建的
|
||||
export async function getLibraryProblemList(offset = 0, limit = 10, keyword: string) {
|
||||
const res = await api.get<AdminProblemList>("admin/problems", {
|
||||
const res = await api.get<AdminProblemList>('admin/problems', {
|
||||
params: { offset, limit, keyword, library: 1 },
|
||||
})
|
||||
return {
|
||||
@@ -170,10 +170,7 @@ export function deleteContestProblem(id: number) {
|
||||
// 比赛题也走这条:后端是同一条 PUT admin/problems/:id,比赛由题目自己推导,
|
||||
// 所以不像新建那样需要分两个函数
|
||||
export function editProblem(problem: AdminProblem | BlankProblem) {
|
||||
return api.put<AdminProblem>(
|
||||
`admin/problems/${(problem as AdminProblem).id}`,
|
||||
toProblemBody(problem) satisfies Partial<UpdateProblemRequest>,
|
||||
)
|
||||
return api.put<AdminProblem>(`admin/problems/${(problem as AdminProblem).id}`, toProblemBody(problem) satisfies Partial<UpdateProblemRequest>)
|
||||
}
|
||||
|
||||
export function toggleProblemVisible(problemID: number) {
|
||||
@@ -181,7 +178,7 @@ export function toggleProblemVisible(problemID: number) {
|
||||
}
|
||||
|
||||
export function generateFlowchartFromPythonCode(python: string) {
|
||||
return api.post<{ doc: Record<string, unknown> }>("admin/problems/flowchart", {
|
||||
return api.post<{ doc: Record<string, unknown> }>('admin/problems/flowchart', {
|
||||
python,
|
||||
} satisfies GenerateFlowchartRequest)
|
||||
}
|
||||
@@ -191,8 +188,8 @@ export function getProblem(id: string | number) {
|
||||
}
|
||||
|
||||
// 标签管理
|
||||
export function getTagAdminList(keyword = "") {
|
||||
return api.get<AdminTag[]>("admin/problem-tags", { params: { keyword } })
|
||||
export function getTagAdminList(keyword = '') {
|
||||
return api.get<AdminTag[]>('admin/problem-tags', { params: { keyword } })
|
||||
}
|
||||
|
||||
export function renameTag(id: number, name: string) {
|
||||
@@ -209,12 +206,8 @@ export function deleteTag(id: number) {
|
||||
return api.delete(`admin/problem-tags/${id}`)
|
||||
}
|
||||
|
||||
export function batchTagProblems(
|
||||
problemIds: number[],
|
||||
tagNames: string[],
|
||||
action: "add" | "remove",
|
||||
) {
|
||||
return api.post<BatchProblemTagResponse>("admin/problems/batch-tag", {
|
||||
export function batchTagProblems(problemIds: number[], tagNames: string[], action: 'add' | 'remove') {
|
||||
return api.post<BatchProblemTagResponse>('admin/problems/batch-tag', {
|
||||
problemIds,
|
||||
tagNames,
|
||||
action,
|
||||
@@ -223,7 +216,7 @@ export function batchTagProblems(
|
||||
|
||||
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank)
|
||||
export function getAdminUserRank(offset: number, limit: number, keyword: string) {
|
||||
return api.get<AdminUserRank>("admin/rankings/users", {
|
||||
return api.get<AdminUserRank>('admin/rankings/users', {
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
}
|
||||
@@ -232,13 +225,13 @@ export function getAdminUserRank(offset: number, limit: number, keyword: string)
|
||||
export function getUserList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
type = "",
|
||||
type = '',
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
orderBy = '',
|
||||
/** 班级,NO_CLASS 是「没填班级」 */
|
||||
className = "",
|
||||
className = '',
|
||||
) {
|
||||
return api.get<AdminUserList>("admin/users", {
|
||||
return api.get<AdminUserList>('admin/users', {
|
||||
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
|
||||
// "-online" 是新增的,原样透传
|
||||
params: {
|
||||
@@ -246,7 +239,7 @@ export function getUserList(
|
||||
limit,
|
||||
keyword,
|
||||
type,
|
||||
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
|
||||
orderBy: orderBy === '-last_login' ? '-lastLogin' : orderBy,
|
||||
className: className || undefined,
|
||||
},
|
||||
})
|
||||
@@ -254,7 +247,7 @@ export function getUserList(
|
||||
|
||||
/** 批量建号之前查重:这些用户名哪些已经有了 */
|
||||
export function checkUsernames(usernames: string[]) {
|
||||
return api.post<CheckUsernamesResponse>("admin/users/check", {
|
||||
return api.post<CheckUsernamesResponse>('admin/users/check', {
|
||||
usernames,
|
||||
} satisfies CheckUsernamesRequest)
|
||||
}
|
||||
@@ -264,12 +257,12 @@ export function editUser(user: AdminUserForm) {
|
||||
return api.put<AdminUser>(`admin/users/${user.id}`, {
|
||||
username: user.username,
|
||||
// 库里 email 没有空的(2026-10 核过),响应类型是 nullable 只因为列可空
|
||||
email: user.email ?? "",
|
||||
email: user.email ?? '',
|
||||
adminType: toAdminType(user.adminType),
|
||||
problemPermission: toProblemPermission(user.problemPermission),
|
||||
realName: user.realName ?? null,
|
||||
isDisabled: user.isDisabled,
|
||||
password: user.password ?? "",
|
||||
password: user.password ?? '',
|
||||
} satisfies UpdateUserRequest)
|
||||
}
|
||||
|
||||
@@ -281,19 +274,19 @@ export async function resetPassword(userID: number) {
|
||||
|
||||
// 导入用户
|
||||
/** 批量建号。addedClasses:这批里的新班级,后端已经顺手加进了班级列表 */
|
||||
export function importUsers(users: ImportUsersRequest["users"]) {
|
||||
return api.post<{ imported: number; addedClasses: string[] }>("admin/users", {
|
||||
export function importUsers(users: ImportUsersRequest['users']) {
|
||||
return api.post<{ imported: number; addedClasses: string[] }>('admin/users', {
|
||||
users,
|
||||
} satisfies ImportUsersRequest)
|
||||
}
|
||||
|
||||
// 批量删除用户
|
||||
export function deleteUsers(userIDs: number[]) {
|
||||
return api.delete("admin/users", { data: { ids: userIDs } })
|
||||
return api.delete('admin/users', { data: { ids: userIDs } })
|
||||
}
|
||||
|
||||
export function getContestList(offset = 0, limit = 10, keyword: string) {
|
||||
return api.get<AdminContestList>("admin/contests", {
|
||||
return api.get<AdminContestList>('admin/contests', {
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
}
|
||||
@@ -301,34 +294,34 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
|
||||
// 上传图片
|
||||
export async function uploadImage(file: File): Promise<string> {
|
||||
const form = new window.FormData()
|
||||
form.append("image", file)
|
||||
form.append('image', file)
|
||||
const res = await api.post<{
|
||||
success: boolean
|
||||
filePath: string
|
||||
msg: string
|
||||
}>("admin/upload-image", form)
|
||||
return res.success ? res.filePath : ""
|
||||
}>('admin/upload-image', form)
|
||||
return res.success ? res.filePath : ''
|
||||
}
|
||||
|
||||
// 上传测试用例;SQL 题的压缩包是 1.sql..N.sql(每个文件一个测试点的建表+数据脚本)
|
||||
export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
|
||||
const form = new window.FormData()
|
||||
form.append("file", file)
|
||||
form.append('file', file)
|
||||
if (options.sql) {
|
||||
form.append("sql", "1")
|
||||
form.append('sql', '1')
|
||||
}
|
||||
return api.post<UploadTestCaseResponse>("admin/test-cases", form)
|
||||
return api.post<UploadTestCaseResponse>('admin/test-cases', form)
|
||||
}
|
||||
|
||||
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
|
||||
export function previewSQLTestcase(data: {
|
||||
initSql: string
|
||||
refSql: string
|
||||
mode: "query" | "modify"
|
||||
mode: 'query' | 'modify'
|
||||
/** 是不是题面上展示的那组(第 1 组):增删改题只有它必须改到行 */
|
||||
shown: boolean
|
||||
}) {
|
||||
return api.post<SqlDisplay>("admin/sql-test-cases/preview", data satisfies SqlPreviewRequest)
|
||||
return api.post<SqlDisplay>('admin/sql-test-cases/preview', data satisfies SqlPreviewRequest)
|
||||
}
|
||||
|
||||
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
|
||||
@@ -337,11 +330,8 @@ export function getSQLTestcaseScripts(problemId: number) {
|
||||
}
|
||||
|
||||
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
|
||||
export function generateSQLTestcase(data: { refSql: string; mode: "query" | "modify" }) {
|
||||
return api.post<GenerateSqlTestCaseResponse>(
|
||||
"admin/sql-test-cases/generate",
|
||||
data satisfies GenerateSqlTestCaseRequest,
|
||||
)
|
||||
export function generateSQLTestcase(data: { refSql: string; mode: 'query' | 'modify' }) {
|
||||
return api.post<GenerateSqlTestCaseResponse>('admin/sql-test-cases/generate', data satisfies GenerateSqlTestCaseRequest)
|
||||
}
|
||||
|
||||
// 编程题出题页:读回已有测试点的原文(太大的只给数量和大小)
|
||||
@@ -351,38 +341,32 @@ export function getTestCaseFiles(problemId: number) {
|
||||
|
||||
// 拿标准答案自测语法要求
|
||||
export function checkAstRules(data: AstCheckRequest) {
|
||||
return api.post<AstCheckResponse>("admin/ast-check", data satisfies AstCheckRequest)
|
||||
return api.post<AstCheckResponse>('admin/ast-check', data satisfies AstCheckRequest)
|
||||
}
|
||||
|
||||
// 出题页「挑战」的候选:拿这道题历史上做对的代码挖
|
||||
export function getChallengeSuggestions(problemId: number, language: string) {
|
||||
return api.post<ChallengeSuggestionsResponse>(
|
||||
`admin/problems/${problemId}/challenge-suggestions`,
|
||||
{
|
||||
language,
|
||||
},
|
||||
)
|
||||
return api.post<ChallengeSuggestionsResponse>(`admin/problems/${problemId}/challenge-suggestions`, {
|
||||
language,
|
||||
})
|
||||
}
|
||||
|
||||
// AI 按题面和标准答案想几组测试输入(输出由标准答案跑)
|
||||
export function generateTestInputs(data: GenerateTestInputsRequest) {
|
||||
return api.post<GenerateTestInputsResponse>(
|
||||
"admin/test-inputs/generate",
|
||||
data satisfies GenerateTestInputsRequest,
|
||||
)
|
||||
return api.post<GenerateTestInputsResponse>('admin/test-inputs/generate', data satisfies GenerateTestInputsRequest)
|
||||
}
|
||||
|
||||
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
|
||||
function toProblemBody(problem: AdminProblem | BlankProblem) {
|
||||
const p = problem as Partial<Omit<AdminProblem, "flowchartData">> & {
|
||||
const p = problem as Partial<Omit<AdminProblem, 'flowchartData'>> & {
|
||||
flowchartData?: Record<string, unknown> | null
|
||||
}
|
||||
return {
|
||||
_id: p._id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
inputDescription: p.inputDescription ?? "",
|
||||
outputDescription: p.outputDescription ?? "",
|
||||
inputDescription: p.inputDescription ?? '',
|
||||
outputDescription: p.outputDescription ?? '',
|
||||
samples: p.samples ?? [],
|
||||
testCaseId: p.testCaseId,
|
||||
testCaseScore: p.testCaseScore ?? [],
|
||||
@@ -408,18 +392,12 @@ function toProblemBody(problem: AdminProblem | BlankProblem) {
|
||||
}
|
||||
|
||||
export function createProblem(problem: BlankProblem) {
|
||||
return api.post<AdminProblem>(
|
||||
"admin/problems",
|
||||
toProblemBody(problem) satisfies Partial<CreateProblemRequest>,
|
||||
)
|
||||
return api.post<AdminProblem>('admin/problems', toProblemBody(problem) satisfies Partial<CreateProblemRequest>)
|
||||
}
|
||||
|
||||
export function createContestProblem(problem: BlankProblem) {
|
||||
// contestId 由 detail.vue 在提交前写进 problem 对象
|
||||
return api.post<AdminProblem>(
|
||||
`admin/contests/${problem.contestId}/problems`,
|
||||
toProblemBody(problem) satisfies Partial<CreateProblemRequest>,
|
||||
)
|
||||
return api.post<AdminProblem>(`admin/contests/${problem.contestId}/problems`, toProblemBody(problem) satisfies Partial<CreateProblemRequest>)
|
||||
}
|
||||
|
||||
/** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */
|
||||
@@ -436,17 +414,11 @@ function toContestBody(contest: AdminContest | BlankContest) {
|
||||
}
|
||||
|
||||
export function createContest(contest: BlankContest) {
|
||||
return api.post<AdminContest>(
|
||||
"admin/contests",
|
||||
toContestBody(contest) satisfies CreateContestRequest,
|
||||
)
|
||||
return api.post<AdminContest>('admin/contests', toContestBody(contest) satisfies CreateContestRequest)
|
||||
}
|
||||
|
||||
export function editContest(contest: AdminContest | BlankContest) {
|
||||
return api.put<AdminContest>(
|
||||
`admin/contests/${(contest as AdminContest).id}`,
|
||||
toContestBody(contest) satisfies UpdateContestRequest,
|
||||
)
|
||||
return api.put<AdminContest>(`admin/contests/${(contest as AdminContest).id}`, toContestBody(contest) satisfies UpdateContestRequest)
|
||||
}
|
||||
|
||||
export function cloneContest(contestId: number) {
|
||||
@@ -472,32 +444,32 @@ export function reorderContestProblems(contestID: string, problemIds: number[])
|
||||
}
|
||||
|
||||
export function getWebsite() {
|
||||
return api.get<WebsiteConfig>("admin/website")
|
||||
return api.get<WebsiteConfig>('admin/website')
|
||||
}
|
||||
|
||||
export function editWebsite(data: WebsiteConfig) {
|
||||
return api.post<WebsiteConfig>("admin/website", data satisfies UpdateWebsiteConfigRequest)
|
||||
return api.post<WebsiteConfig>('admin/website', data satisfies UpdateWebsiteConfigRequest)
|
||||
}
|
||||
|
||||
export function listInvalidTestcases() {
|
||||
return api.get<OrphanTestCase[]>("admin/orphan-test-cases")
|
||||
return api.get<OrphanTestCase[]>('admin/orphan-test-cases')
|
||||
}
|
||||
|
||||
export function pruneInvalidTestcases(id?: string) {
|
||||
return api.delete("admin/orphan-test-cases", { params: { id } })
|
||||
return api.delete('admin/orphan-test-cases', { params: { id } })
|
||||
}
|
||||
|
||||
export function listOrphanImages() {
|
||||
return api.get<OrphanImage[]>("admin/orphan-images")
|
||||
return api.get<OrphanImage[]>('admin/orphan-images')
|
||||
}
|
||||
|
||||
/** 不带参数是全删;带 folder 只删那个目录的,再带 name 只删一张 */
|
||||
export function pruneOrphanImages(target?: Pick<OrphanImage, "folder"> & { name?: string }) {
|
||||
return api.delete<{ deleted: number }>("admin/orphan-images", { params: target })
|
||||
export function pruneOrphanImages(target?: Pick<OrphanImage, 'folder'> & { name?: string }) {
|
||||
return api.delete<{ deleted: number }>('admin/orphan-images', { params: target })
|
||||
}
|
||||
|
||||
export function getJudgeServer() {
|
||||
return api.get<JudgeServerList>("admin/judge-servers")
|
||||
return api.get<JudgeServerList>('admin/judge-servers')
|
||||
}
|
||||
|
||||
export function deleteJudgeServer(hostname: string) {
|
||||
@@ -505,7 +477,7 @@ export function deleteJudgeServer(hostname: string) {
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AdminAnnouncementListItem[]; total: number }>("admin/announcements", {
|
||||
return api.get<{ results: AdminAnnouncementListItem[]; total: number }>('admin/announcements', {
|
||||
params: { offset, limit },
|
||||
})
|
||||
}
|
||||
@@ -520,22 +492,16 @@ export function deleteAnnouncement(id: number) {
|
||||
|
||||
export function editAnnouncement(announcement: AnnouncementEdit) {
|
||||
const { id, ...body } = announcement
|
||||
return api.put<AdminAnnouncement>(
|
||||
`admin/announcements/${id}`,
|
||||
body satisfies UpdateAnnouncementRequest,
|
||||
)
|
||||
return api.put<AdminAnnouncement>(`admin/announcements/${id}`, body satisfies UpdateAnnouncementRequest)
|
||||
}
|
||||
|
||||
export function createAnnouncement(announcement: AnnouncementEdit) {
|
||||
const { id: _id, ...body } = announcement
|
||||
return api.post<AdminAnnouncement>(
|
||||
"admin/announcements",
|
||||
body satisfies CreateAnnouncementRequest,
|
||||
)
|
||||
return api.post<AdminAnnouncement>('admin/announcements', body satisfies CreateAnnouncementRequest)
|
||||
}
|
||||
|
||||
export function getTutorialList() {
|
||||
return api.get<{ [key: string]: TutorialListItem[] }>("admin/tutorials")
|
||||
return api.get<{ [key: string]: TutorialListItem[] }>('admin/tutorials')
|
||||
}
|
||||
|
||||
export function getTutorial(id: number) {
|
||||
@@ -555,14 +521,11 @@ function toTutorialBody(data: TutorialEdit) {
|
||||
}
|
||||
|
||||
export function createTutorial(data: TutorialEdit) {
|
||||
return api.post<Tutorial>("admin/tutorials", toTutorialBody(data) satisfies CreateTutorialRequest)
|
||||
return api.post<Tutorial>('admin/tutorials', toTutorialBody(data) satisfies CreateTutorialRequest)
|
||||
}
|
||||
|
||||
export function updateTutorial(data: TutorialEdit) {
|
||||
return api.put<Tutorial>(
|
||||
`admin/tutorials/${data.id}`,
|
||||
toTutorialBody(data) satisfies UpdateTutorialRequest,
|
||||
)
|
||||
return api.put<Tutorial>(`admin/tutorials/${data.id}`, toTutorialBody(data) satisfies UpdateTutorialRequest)
|
||||
}
|
||||
|
||||
export function setTutorialProblems(id: number, problemIds: number[]) {
|
||||
@@ -572,17 +535,17 @@ export function setTutorialProblems(id: number, problemIds: number[]) {
|
||||
}
|
||||
|
||||
export function getTutorialProblemCandidates(keyword: string) {
|
||||
return api.get<TutorialProblemCandidate[]>("admin/tutorial-problem-candidates", {
|
||||
return api.get<TutorialProblemCandidate[]>('admin/tutorial-problem-candidates', {
|
||||
params: keyword ? { keyword } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function reorderTutorials(ids: number[]) {
|
||||
return api.put<null>("admin/tutorial-order", { ids } satisfies ReorderTutorialsRequest)
|
||||
return api.put<null>('admin/tutorial-order', { ids } satisfies ReorderTutorialsRequest)
|
||||
}
|
||||
|
||||
export function renameTutorialProject(type: "python" | "c", from: string, to: string) {
|
||||
return api.put<null>("admin/tutorial-projects", {
|
||||
export function renameTutorialProject(type: 'python' | 'c', from: string, to: string) {
|
||||
return api.put<null>('admin/tutorial-projects', {
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
@@ -603,22 +566,11 @@ export function getAdminExercises(tutorialId: number) {
|
||||
return api.get<Exercise[]>(`admin/tutorials/${tutorialId}/exercises`)
|
||||
}
|
||||
|
||||
export async function createExercise(data: {
|
||||
tutorialId: number
|
||||
type: ExerciseType
|
||||
data: Record<string, unknown>
|
||||
order: number
|
||||
}) {
|
||||
return api.post<Exercise>("admin/exercises", data satisfies CreateExerciseRequest)
|
||||
export async function createExercise(data: { tutorialId: number; type: ExerciseType; data: Record<string, unknown>; order: number }) {
|
||||
return api.post<Exercise>('admin/exercises', data satisfies CreateExerciseRequest)
|
||||
}
|
||||
|
||||
export async function updateExercise(data: {
|
||||
id: number
|
||||
type: ExerciseType
|
||||
data: Record<string, unknown>
|
||||
order: number
|
||||
resetAttempts: boolean
|
||||
}) {
|
||||
export async function updateExercise(data: { id: number; type: ExerciseType; data: Record<string, unknown>; order: number; resetAttempts: boolean }) {
|
||||
return api.put<Exercise>(`admin/exercises/${data.id}`, {
|
||||
type: data.type,
|
||||
data: data.data,
|
||||
@@ -639,12 +591,7 @@ export function makeProblemPublic(id: number, displayId: string) {
|
||||
}
|
||||
|
||||
// 比赛辅助检查
|
||||
export function updateACMHelperChecked(
|
||||
contest_id: number,
|
||||
rank_id: number,
|
||||
problem_id: string,
|
||||
checked: boolean,
|
||||
) {
|
||||
export function updateACMHelperChecked(contest_id: number, rank_id: number, problem_id: string, checked: boolean) {
|
||||
return api.put(`admin/contests/${contest_id}/acm-helper`, {
|
||||
rankId: rank_id,
|
||||
problemId: problem_id,
|
||||
@@ -653,8 +600,8 @@ export function updateACMHelperChecked(
|
||||
}
|
||||
|
||||
// 题单管理 API
|
||||
export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
|
||||
return api.get<AdminProblemSetList>("admin/problem-sets", {
|
||||
export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
|
||||
return api.get<AdminProblemSetList>('admin/problem-sets', {
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
}
|
||||
@@ -672,14 +619,11 @@ export interface ProblemSetBody {
|
||||
}
|
||||
|
||||
export function createProblemSet(data: ProblemSetBody) {
|
||||
return api.post<AdminProblemSet>("admin/problem-sets", data satisfies CreateProblemSetRequest)
|
||||
return api.post<AdminProblemSet>('admin/problem-sets', data satisfies CreateProblemSetRequest)
|
||||
}
|
||||
|
||||
export function editProblemSet(id: number, data: ProblemSetBody) {
|
||||
return api.put<AdminProblemSet>(
|
||||
`admin/problem-sets/${id}`,
|
||||
data satisfies UpdateProblemSetRequest,
|
||||
)
|
||||
return api.put<AdminProblemSet>(`admin/problem-sets/${id}`, data satisfies UpdateProblemSetRequest)
|
||||
}
|
||||
|
||||
export function deleteProblemSet(id: number) {
|
||||
@@ -727,22 +671,16 @@ export interface BadgeBody {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
conditionType: AdminProblemSetBadge["conditionType"]
|
||||
conditionType: AdminProblemSetBadge['conditionType']
|
||||
conditionValue: number
|
||||
}
|
||||
|
||||
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
|
||||
return api.post<AdminProblemSetBadge>(
|
||||
`admin/problem-sets/${problemSetId}/badges`,
|
||||
data satisfies CreateProblemSetBadgeRequest,
|
||||
)
|
||||
return api.post<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges`, data satisfies CreateProblemSetBadgeRequest)
|
||||
}
|
||||
|
||||
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
|
||||
return api.put<AdminProblemSetBadge>(
|
||||
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
|
||||
data satisfies UpdateProblemSetBadgeRequest,
|
||||
)
|
||||
return api.put<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges/${badgeId}`, data satisfies UpdateProblemSetBadgeRequest)
|
||||
}
|
||||
|
||||
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
@@ -751,41 +689,38 @@ export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
|
||||
// 卡点分析:start 是时间段的起点(ISO),不给就是全部时间
|
||||
export function getStuckProblems(params: { className?: string; start?: string }) {
|
||||
return api.get<StuckProblem[]>("admin/problem-analytics/stuck", { params })
|
||||
return api.get<StuckProblem[]>('admin/problem-analytics/stuck', { params })
|
||||
}
|
||||
|
||||
export function getStuckProblemDetail(id: number, params: { className?: string; start?: string }) {
|
||||
return api.get<StuckProblemDetail>(`admin/problem-analytics/stuck/${id}`, { params })
|
||||
}
|
||||
|
||||
export function getLearnBoard(params: { type: "python" | "c"; className?: string }) {
|
||||
return api.get<LearnBoard>("admin/learn-analytics/board", { params })
|
||||
export function getLearnBoard(params: { type: 'python' | 'c'; className?: string }) {
|
||||
return api.get<LearnBoard>('admin/learn-analytics/board', { params })
|
||||
}
|
||||
|
||||
export function getLearnExercises(params: { type: "python" | "c"; className?: string }) {
|
||||
return api.get<LearnExerciseProgressList>("admin/learn-analytics/exercises", {
|
||||
export function getLearnExercises(params: { type: 'python' | 'c'; className?: string }) {
|
||||
return api.get<LearnExerciseProgressList>('admin/learn-analytics/exercises', {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) {
|
||||
return api.get<LearnExerciseAttemptList>(
|
||||
`admin/learn-analytics/exercises/${exerciseId}/attempts`,
|
||||
{ params },
|
||||
)
|
||||
return api.get<LearnExerciseAttemptList>(`admin/learn-analytics/exercises/${exerciseId}/attempts`, { params })
|
||||
}
|
||||
|
||||
export function getLearnStudent(userId: number, params: { type: "python" | "c" }) {
|
||||
export function getLearnStudent(userId: number, params: { type: 'python' | 'c' }) {
|
||||
return api.get<LearnStudentDetail>(`admin/learn-analytics/students/${userId}`, { params })
|
||||
}
|
||||
|
||||
export function getGradeTrend(params: { grade?: string; compare?: string }) {
|
||||
return api.get<GradeTrend>("admin/problem-analytics/grades", { params })
|
||||
return api.get<GradeTrend>('admin/problem-analytics/grades', { params })
|
||||
}
|
||||
|
||||
// AI 学习分析报告
|
||||
export function getAIReportList(offset = 0, limit = 10, username = "") {
|
||||
return api.get<AdminAiReportList>("admin/ai/reports", {
|
||||
export function getAIReportList(offset = 0, limit = 10, username = '') {
|
||||
return api.get<AdminAiReportList>('admin/ai/reports', {
|
||||
params: { offset, limit, username: username || undefined },
|
||||
})
|
||||
}
|
||||
@@ -799,18 +734,14 @@ export function pinAIReport(id: number) {
|
||||
}
|
||||
|
||||
export function getPinnedAIReports() {
|
||||
return api.get<AdminAiReportList>("admin/ai/reports", {
|
||||
params: { pinnedOnly: "true" },
|
||||
return api.get<AdminAiReportList>('admin/ai/reports', {
|
||||
params: { pinnedOnly: 'true' },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 成就 ====================
|
||||
|
||||
import type {
|
||||
AdminAchievement,
|
||||
AchievementMetric as MetricOption,
|
||||
AdminUserRank,
|
||||
} from "@oj2/contract"
|
||||
import type { AdminAchievement, AchievementMetric as MetricOption, AdminUserRank } from '@oj2/contract'
|
||||
export type { AdminAchievement, MetricOption }
|
||||
|
||||
function toAchievementBody(data: Partial<AdminAchievement>) {
|
||||
@@ -829,25 +760,19 @@ function toAchievementBody(data: Partial<AdminAchievement>) {
|
||||
}
|
||||
|
||||
export function getAdminAchievements() {
|
||||
return api.get<AdminAchievement[]>("admin/achievements")
|
||||
return api.get<AdminAchievement[]>('admin/achievements')
|
||||
}
|
||||
|
||||
export function getMetricOptions() {
|
||||
return api.get<MetricOption[]>("admin/achievement-metrics")
|
||||
return api.get<MetricOption[]>('admin/achievement-metrics')
|
||||
}
|
||||
|
||||
export function createAchievement(data: Partial<AdminAchievement>) {
|
||||
return api.post<AdminAchievement>(
|
||||
"admin/achievements",
|
||||
toAchievementBody(data) satisfies Partial<CreateAchievementRequest>,
|
||||
)
|
||||
return api.post<AdminAchievement>('admin/achievements', toAchievementBody(data) satisfies Partial<CreateAchievementRequest>)
|
||||
}
|
||||
|
||||
export function updateAchievement(data: Partial<AdminAchievement>) {
|
||||
return api.put<AdminAchievement>(
|
||||
`admin/achievements/${data.id}`,
|
||||
toAchievementBody(data) satisfies Partial<UpdateAchievementRequest>,
|
||||
)
|
||||
return api.put<AdminAchievement>(`admin/achievements/${data.id}`, toAchievementBody(data) satisfies Partial<UpdateAchievementRequest>)
|
||||
}
|
||||
|
||||
export function deleteAchievement(id: number) {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
v-model:value="author"
|
||||
remote
|
||||
@update:show="getAuthorOptions"
|
||||
:options="authorOptions"
|
||||
/>
|
||||
<n-select style="width: 140px" v-model:value="author" remote @update:show="getAuthorOptions" :options="authorOptions" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAuthors } from "shared/api"
|
||||
import { getAuthors } from 'shared/api'
|
||||
|
||||
interface Props {
|
||||
all?: boolean
|
||||
@@ -17,12 +11,12 @@ interface Props {
|
||||
|
||||
const { all = false } = defineProps<Props>()
|
||||
|
||||
const author = defineModel<string>("value")
|
||||
const author = defineModel<string>('value')
|
||||
|
||||
const authorOptions = ref([{ label: "全部", value: "" }])
|
||||
const authorOptions = ref([{ label: '全部', value: '' }])
|
||||
|
||||
async function getAuthorOptions() {
|
||||
authorOptions.value = [{ label: "全部", value: "" }]
|
||||
authorOptions.value = [{ label: '全部', value: '' }]
|
||||
const res = await getAuthors(all)
|
||||
const remotes = res.map((item) => ({
|
||||
label: `${item.username} (${item.problemCount})`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import type { RouteLocationRaw } from "vue-router"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 后台每一页顶上的那一块:标题行(返回 · 标题 · 标签 ········ 操作按钮),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AdminContest } from "utils/types"
|
||||
import { cloneContest } from "../../api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import type { AdminContest } from 'utils/types'
|
||||
import { cloneContest } from '../../api'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
interface Props {
|
||||
contest: AdminContest
|
||||
@@ -14,30 +14,30 @@ const { isMobile } = useBreakpoints()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin contest edit",
|
||||
name: 'admin contest edit',
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
// 原来的「审核」页并进了前台比赛页老师的「全班情况 / 成绩」:点格子看代码、标看过了
|
||||
function goClass() {
|
||||
window.open(`/contest/${props.contest.id}/class`, "_blank")
|
||||
window.open(`/contest/${props.contest.id}/class`, '_blank')
|
||||
}
|
||||
|
||||
function openFront() {
|
||||
window.open(`/contest/${props.contest.id}`, "_blank")
|
||||
window.open(`/contest/${props.contest.id}`, '_blank')
|
||||
}
|
||||
|
||||
async function clone() {
|
||||
try {
|
||||
const res = await cloneContest(props.contest.id)
|
||||
message.success("复制成功")
|
||||
message.success('复制成功')
|
||||
router.push({
|
||||
name: "admin contest edit",
|
||||
name: 'admin contest edit',
|
||||
params: { contestID: res.id },
|
||||
})
|
||||
} catch {
|
||||
message.error("复制失败")
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -46,7 +46,7 @@ async function clone() {
|
||||
<n-flex :size="4" :wrap="isMobile">
|
||||
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||
<n-button size="small" @click="goClass">
|
||||
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
|
||||
{{ contest.status === '-1' ? '成绩' : '全班情况' }}
|
||||
</n-button>
|
||||
<n-button size="small" @click="clone">复制一份</n-button>
|
||||
<n-button size="small" title="在前台打开这场比赛" @click="openFront">前台看</n-button>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
|
||||
import type { AdminProblemRow, BlankContest } from "utils/types"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { errorCode, errorMessage } from 'utils/api'
|
||||
import TextEditor from 'shared/components/TextEditor.vue'
|
||||
import { DIFFICULTY } from 'utils/constants'
|
||||
import { fromPickerValue, parseTime, toPickerValue } from 'utils/functions'
|
||||
import type { AdminProblemRow, BlankContest } from 'utils/types'
|
||||
import {
|
||||
addProblemForContest,
|
||||
createContest,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getLibraryProblemList,
|
||||
getProblemList,
|
||||
reorderContestProblems,
|
||||
} from "../api"
|
||||
} from '../api'
|
||||
|
||||
/**
|
||||
* 新建 / 编辑比赛,一屏做完(设计稿「比赛重设计」后台那块):左边名字、类型、时间、密码,
|
||||
@@ -38,12 +38,12 @@ const saving = ref(false)
|
||||
|
||||
const contest = reactive<BlankContest & { id: number }>({
|
||||
id: 0,
|
||||
title: "",
|
||||
description: "",
|
||||
tag: "练习",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
password: "",
|
||||
title: '',
|
||||
description: '',
|
||||
tag: '练习',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
password: '',
|
||||
// 不列出来学生就进不去(隐藏比赛对学生是 404),新建默认就列出来
|
||||
visible: true,
|
||||
})
|
||||
@@ -68,21 +68,17 @@ function roundedNow() {
|
||||
return ms - (ms % 60_000)
|
||||
}
|
||||
|
||||
const startMs = computed(() =>
|
||||
waitMins.value !== null
|
||||
? roundedNow() + waitMins.value * 60_000
|
||||
: fromPickerValue(pickedStart.value),
|
||||
)
|
||||
const startMs = computed(() => (waitMins.value !== null ? roundedNow() + waitMins.value * 60_000 : fromPickerValue(pickedStart.value)))
|
||||
const endMs = computed(() => startMs.value + durationMins.value * 60_000)
|
||||
|
||||
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
|
||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const timeHint = computed(() => {
|
||||
const start = new Date(startMs.value).toISOString()
|
||||
const end = new Date(endMs.value).toISOString()
|
||||
// 东八区的星期几:平移 8 小时后读 UTC
|
||||
const weekday = WEEKDAYS[new Date(startMs.value + 8 * 3600_000).getUTCDay()]
|
||||
const sameDay = parseTime(start, "YYYY-MM-DD") === parseTime(end, "YYYY-MM-DD")
|
||||
return `${parseTime(start, "M月D日")} 周${weekday} ${parseTime(start, "HH:mm")} 开始,${parseTime(end, sameDay ? "HH:mm" : "M月D日 HH:mm")} 结束`
|
||||
const sameDay = parseTime(start, 'YYYY-MM-DD') === parseTime(end, 'YYYY-MM-DD')
|
||||
return `${parseTime(start, 'M月D日')} 周${weekday} ${parseTime(start, 'HH:mm')} 开始,${parseTime(end, sameDay ? 'HH:mm' : 'M月D日 HH:mm')} 结束`
|
||||
})
|
||||
|
||||
function pickWait(minutes: number | null) {
|
||||
@@ -104,7 +100,7 @@ const items = ref<Item[]>([])
|
||||
const pickedPublic = computed(() => new Set(items.value.map((item) => item.id)))
|
||||
|
||||
async function loadContestProblems() {
|
||||
const res = await getProblemList(0, 250, "", undefined, props.contestID)
|
||||
const res = await getProblemList(0, 250, '', undefined, props.contestID)
|
||||
items.value = res.results
|
||||
.map((row: AdminProblemRow) => ({
|
||||
id: row.id,
|
||||
@@ -112,12 +108,10 @@ async function loadContestProblems() {
|
||||
title: row.title,
|
||||
difficulty: row.difficulty,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) => a.displayId.length - b.displayId.length || a.displayId.localeCompare(b.displayId),
|
||||
)
|
||||
.sort((a, b) => a.displayId.length - b.displayId.length || a.displayId.localeCompare(b.displayId))
|
||||
}
|
||||
|
||||
const keyword = ref("")
|
||||
const keyword = ref('')
|
||||
const results = ref<AdminProblemRow[]>([])
|
||||
const searching = ref(false)
|
||||
watchDebounced(
|
||||
@@ -139,9 +133,7 @@ watchDebounced(
|
||||
|
||||
/** 编辑时比赛题是复制品,没法知道它从哪道公开题来,按题名比一下防重复 */
|
||||
function added(row: AdminProblemRow) {
|
||||
return editing.value
|
||||
? items.value.some((item) => item.title === row.title)
|
||||
: pickedPublic.value.has(row.id)
|
||||
return editing.value ? items.value.some((item) => item.title === row.title) : pickedPublic.value.has(row.id)
|
||||
}
|
||||
|
||||
async function add(row: AdminProblemRow) {
|
||||
@@ -160,13 +152,7 @@ async function add(row: AdminProblemRow) {
|
||||
await loadContestProblems()
|
||||
} catch (err) {
|
||||
const code = errorCode(err)
|
||||
message.error(
|
||||
code === "contest-ended"
|
||||
? "比赛已经结束,不能再加题"
|
||||
: code === "display-id-exists"
|
||||
? "题号重了,刷新一下再加"
|
||||
: errorMessage(err),
|
||||
)
|
||||
message.error(code === 'contest-ended' ? '比赛已经结束,不能再加题' : code === 'display-id-exists' ? '题号重了,刷新一下再加' : errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,21 +163,17 @@ function remove(item: Item) {
|
||||
return
|
||||
}
|
||||
dialog.warning({
|
||||
title: "从比赛里删掉这道题?",
|
||||
title: '从比赛里删掉这道题?',
|
||||
content: `第 ${item.displayId} 题「${item.title}」。已经有人交过的题删不掉。`,
|
||||
positiveText: "删掉",
|
||||
negativeText: "不删",
|
||||
positiveText: '删掉',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteContestProblem(item.id)
|
||||
await loadContestProblems()
|
||||
if (items.value.length) await saveOrder()
|
||||
} catch (err) {
|
||||
message.error(
|
||||
errorCode(err) === "problem-has-submissions"
|
||||
? "已经有人交过这道题,删不掉;不想让学生看到可以在题目里关掉「可见」"
|
||||
: errorMessage(err),
|
||||
)
|
||||
message.error(errorCode(err) === 'problem-has-submissions' ? '已经有人交过这道题,删不掉;不想让学生看到可以在题目里关掉「可见」' : errorMessage(err))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -237,7 +219,7 @@ function onDragEnd() {
|
||||
|
||||
function editProblem(item: Item) {
|
||||
router.push({
|
||||
name: "admin contest problem edit",
|
||||
name: 'admin contest problem edit',
|
||||
params: { contestID: props.contestID, problemID: item.id },
|
||||
})
|
||||
}
|
||||
@@ -245,7 +227,7 @@ function editProblem(item: Item) {
|
||||
// ---------------------------------------------------------------- 载入 / 保存
|
||||
|
||||
async function load() {
|
||||
keyword.value = ""
|
||||
keyword.value = ''
|
||||
if (!props.contestID) {
|
||||
ready.value = true
|
||||
return
|
||||
@@ -272,33 +254,31 @@ async function load() {
|
||||
|
||||
async function submit() {
|
||||
if (!contest.title.trim()) {
|
||||
message.error("比赛还没起名字")
|
||||
message.error('比赛还没起名字')
|
||||
return
|
||||
}
|
||||
if (durationMins.value <= 0) {
|
||||
message.error("时长要大于 0")
|
||||
message.error('时长要大于 0')
|
||||
return
|
||||
}
|
||||
contest.startTime = new Date(startMs.value).toISOString()
|
||||
contest.endTime = new Date(endMs.value).toISOString()
|
||||
if (!contest.description || contest.description === "<p><br></p>") {
|
||||
if (!contest.description || contest.description === '<p><br></p>') {
|
||||
contest.description = contest.title
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await editContest(contest)
|
||||
message.success("改好了")
|
||||
message.success('改好了')
|
||||
return
|
||||
}
|
||||
const created = await createContest(contest)
|
||||
for (const [index, item] of items.value.entries()) {
|
||||
await addProblemForContest(String(created.id), item.id, String(index + 1))
|
||||
}
|
||||
message.success(
|
||||
items.value.length ? `比赛建好了,${items.value.length} 道题都加进去了` : "比赛建好了",
|
||||
)
|
||||
router.replace({ name: "admin contest edit", params: { contestID: created.id } })
|
||||
message.success(items.value.length ? `比赛建好了,${items.value.length} 道题都加进去了` : '比赛建好了')
|
||||
router.replace({ name: 'admin contest edit', params: { contestID: created.id } })
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
} finally {
|
||||
@@ -313,9 +293,7 @@ onMounted(load)
|
||||
<template>
|
||||
<PageHeader :title="editing ? '编辑比赛' : '新建比赛'" :back="{ name: 'admin contest list' }">
|
||||
<template v-if="editing" #actions>
|
||||
<n-button tag="a" :href="`/contest/${contestID}/class`" target="_blank">
|
||||
到前台看这场比赛
|
||||
</n-button>
|
||||
<n-button tag="a" :href="`/contest/${contestID}/class`" target="_blank"> 到前台看这场比赛 </n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -334,9 +312,7 @@ onMounted(load)
|
||||
<n-radio-button value="期中">期中</n-radio-button>
|
||||
<n-radio-button value="期末">期末</n-radio-button>
|
||||
</n-radio-group>
|
||||
<span class="hint">
|
||||
练习:学生边做边看排名。期中、期末:考试中学生看不到排名和每题做对人数,考完一起公布。
|
||||
</span>
|
||||
<span class="hint"> 练习:学生边做边看排名。期中、期末:考试中学生看不到排名和每题做对人数,考完一起公布。 </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -345,26 +321,10 @@ onMounted(load)
|
||||
<div class="stack">
|
||||
<n-flex :size="6">
|
||||
<template v-if="!editing">
|
||||
<button
|
||||
v-for="m in WAIT_CHOICES"
|
||||
:key="m"
|
||||
class="chip"
|
||||
:class="{ on: waitMins === m }"
|
||||
@click="pickWait(m)"
|
||||
>
|
||||
{{ m }} 分钟后
|
||||
</button>
|
||||
<button class="chip" :class="{ on: waitMins === null }" @click="pickWait(null)">
|
||||
选时间…
|
||||
</button>
|
||||
<button v-for="m in WAIT_CHOICES" :key="m" class="chip" :class="{ on: waitMins === m }" @click="pickWait(m)">{{ m }} 分钟后</button>
|
||||
<button class="chip" :class="{ on: waitMins === null }" @click="pickWait(null)">选时间…</button>
|
||||
</template>
|
||||
<n-date-picker
|
||||
v-if="waitMins === null"
|
||||
v-model:value="pickedStart"
|
||||
type="datetime"
|
||||
format="yyyy-MM-dd HH:mm"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<n-date-picker v-if="waitMins === null" v-model:value="pickedStart" type="datetime" format="yyyy-MM-dd HH:mm" style="width: 200px" />
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,25 +333,11 @@ onMounted(load)
|
||||
<span class="label">时长</span>
|
||||
<div class="stack">
|
||||
<n-flex :size="6" align="center">
|
||||
<button
|
||||
v-for="m in DURATION_CHOICES"
|
||||
:key="m"
|
||||
class="chip"
|
||||
:class="{ on: !customDuration && durationMins === m }"
|
||||
@click="pickDuration(m)"
|
||||
>
|
||||
{{ m === 60 ? "1 小时" : `${m} 分钟` }}
|
||||
<button v-for="m in DURATION_CHOICES" :key="m" class="chip" :class="{ on: !customDuration && durationMins === m }" @click="pickDuration(m)">
|
||||
{{ m === 60 ? '1 小时' : `${m} 分钟` }}
|
||||
</button>
|
||||
<button class="chip" :class="{ on: customDuration }" @click="pickDuration(null)">
|
||||
自己填
|
||||
</button>
|
||||
<n-input-number
|
||||
v-if="customDuration"
|
||||
v-model:value="durationMins"
|
||||
:min="1"
|
||||
:step="5"
|
||||
style="width: 130px"
|
||||
>
|
||||
<button class="chip" :class="{ on: customDuration }" @click="pickDuration(null)">自己填</button>
|
||||
<n-input-number v-if="customDuration" v-model:value="durationMins" :min="1" :step="5" style="width: 130px">
|
||||
<template #suffix>分钟</template>
|
||||
</n-input-number>
|
||||
</n-flex>
|
||||
@@ -407,9 +353,7 @@ onMounted(load)
|
||||
<div class="field">
|
||||
<span class="label">列出来</span>
|
||||
<n-switch v-model:value="contest.visible" />
|
||||
<span class="hint">{{
|
||||
contest.visible ? "学生在比赛列表里看得到" : "藏起来,学生进不去"
|
||||
}}</span>
|
||||
<span class="hint">{{ contest.visible ? '学生在比赛列表里看得到' : '藏起来,学生进不去' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field top">
|
||||
@@ -432,18 +376,8 @@ onMounted(load)
|
||||
<span class="hint">{{ items.length }} 道 · 拖动排顺序,题号自动按 1、2、3 排</span>
|
||||
</div>
|
||||
<div class="search">
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
clearable
|
||||
:loading="searching"
|
||||
placeholder="输入题号或题目名,从题库里加"
|
||||
/>
|
||||
<n-button
|
||||
v-if="editing"
|
||||
@click="router.push({ name: 'admin contest problem create', params: { contestID } })"
|
||||
>
|
||||
+ 新出一道
|
||||
</n-button>
|
||||
<n-input v-model:value="keyword" clearable :loading="searching" placeholder="输入题号或题目名,从题库里加" />
|
||||
<n-button v-if="editing" @click="router.push({ name: 'admin contest problem create', params: { contestID } })"> + 新出一道 </n-button>
|
||||
<n-tooltip v-else>
|
||||
<template #trigger>
|
||||
<n-button disabled>+ 新出一道</n-button>
|
||||
@@ -476,25 +410,13 @@ onMounted(load)
|
||||
<span class="grip" aria-hidden="true">⋮⋮</span>
|
||||
<b class="num">{{ item.displayId }}</b>
|
||||
<span class="ell grow">{{ item.title }}</span>
|
||||
<span class="muted tiny">{{
|
||||
DIFFICULTY[item.difficulty as keyof typeof DIFFICULTY]
|
||||
}}</span>
|
||||
<n-button v-if="editing" size="small" text type="primary" @click="editProblem(item)"
|
||||
>改</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
text
|
||||
:aria-label="`删掉第 ${item.displayId} 题`"
|
||||
@click="remove(item)"
|
||||
>✕</n-button
|
||||
>
|
||||
<span class="muted tiny">{{ DIFFICULTY[item.difficulty as keyof typeof DIFFICULTY] }}</span>
|
||||
<n-button v-if="editing" size="small" text type="primary" @click="editProblem(item)">改</n-button>
|
||||
<n-button size="small" text :aria-label="`删掉第 ${item.displayId} 题`" @click="remove(item)">✕</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty hint">还没加题。在上面输入题号,比如 2033</div>
|
||||
<span class="hint">
|
||||
从题库加的题会复制一份进比赛:比赛里改它不影响原题,学生在题库里也搜不到比赛题。
|
||||
</span>
|
||||
<span class="hint"> 从题库加的题会复制一份进比赛:比赛里改它不影响原题,学生在题库里也搜不到比赛题。 </span>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -513,9 +435,9 @@ onMounted(load)
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.form {
|
||||
@@ -547,7 +469,7 @@ onMounted(load)
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.stack {
|
||||
@@ -565,11 +487,11 @@ onMounted(load)
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.tiny {
|
||||
@@ -592,17 +514,17 @@ onMounted(load)
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
color: v-bind("theme.primaryColor");
|
||||
border-color: v-bind('theme.primaryColor');
|
||||
color: v-bind('theme.primaryColor');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -611,7 +533,7 @@ onMounted(load)
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
@@ -627,7 +549,7 @@ onMounted(load)
|
||||
|
||||
.results,
|
||||
.list {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -649,12 +571,12 @@ onMounted(load)
|
||||
|
||||
.result + .result,
|
||||
.item + .item {
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.item {
|
||||
cursor: grab;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.item.dragging {
|
||||
@@ -662,7 +584,7 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.grip {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
letter-spacing: -3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminContestListItem } from "@oj2/contract"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { NSwitch, useThemeVars } from "naive-ui"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { editContest, getContestList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import type { AdminContestListItem } from '@oj2/contract'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { NSwitch, useThemeVars } from 'naive-ui'
|
||||
import ContestTitle from 'shared/components/ContestTitle.vue'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { CONTEST_STATUS } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import { editContest, getContestList } from '../api'
|
||||
import Actions from './components/Actions.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
/**
|
||||
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
|
||||
@@ -24,7 +24,7 @@ const total = ref(0)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
keyword: "",
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
function toggleVisible(contest: AdminContestListItem) {
|
||||
@@ -44,31 +44,24 @@ function duration(row: AdminContestListItem) {
|
||||
return `${Math.round(minutes / 1440)} 天`
|
||||
}
|
||||
|
||||
const EXAM_TAGS = ["期中", "期末"]
|
||||
const EXAM_TAGS = ['期中', '期末']
|
||||
|
||||
function renderTitle(row: AdminContestListItem) {
|
||||
return h("span", { class: "title-cell" }, [
|
||||
h(ContestTitle, { contest: row }),
|
||||
EXAM_TAGS.includes(row.tag) ? h("span", { class: "exam" }, row.tag) : null,
|
||||
])
|
||||
return h('span', { class: 'title-cell' }, [h(ContestTitle, { contest: row }), EXAM_TAGS.includes(row.tag) ? h('span', { class: 'exam' }, row.tag) : null])
|
||||
}
|
||||
|
||||
function renderStatus(row: AdminContestListItem) {
|
||||
return h(
|
||||
"span",
|
||||
{ class: row.status === "0" ? "live" : row.status === "-1" ? "muted" : "" },
|
||||
row.status === "-1"
|
||||
? "结束了"
|
||||
: row.status === "0"
|
||||
? "进行中"
|
||||
: CONTEST_STATUS[row.status].name,
|
||||
'span',
|
||||
{ class: row.status === '0' ? 'live' : row.status === '-1' ? 'muted' : '' },
|
||||
row.status === '-1' ? '结束了' : row.status === '0' ? '进行中' : CONTEST_STATUS[row.status].name,
|
||||
)
|
||||
}
|
||||
|
||||
function renderVisible(row: AdminContestListItem) {
|
||||
return h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: isMobile.value ? "medium" : "small",
|
||||
size: isMobile.value ? 'medium' : 'small',
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row),
|
||||
})
|
||||
@@ -80,75 +73,71 @@ function renderVisible(row: AdminContestListItem) {
|
||||
*/
|
||||
function renderMobileCard(row: AdminContestListItem) {
|
||||
const items = [
|
||||
h("span", { class: "num" }, parseTime(row.startTime, "YYYY-MM-DD HH:mm")),
|
||||
h('span', { class: 'num' }, parseTime(row.startTime, 'YYYY-MM-DD HH:mm')),
|
||||
duration(row),
|
||||
renderStatus(row),
|
||||
`${row.problemCount} 题`,
|
||||
row.participantCount ? `${row.participantCount} 人参加` : null,
|
||||
row.createdBy.username,
|
||||
].filter((it) => it !== null)
|
||||
return h("div", { class: "m-card" }, [
|
||||
return h('div', { class: 'm-card' }, [
|
||||
renderTitle(row),
|
||||
h(
|
||||
"div",
|
||||
{ class: "m-meta muted" },
|
||||
items.map((it, i) => h("span", { class: "m-item", key: i }, i ? [" · ", it] : [it])),
|
||||
'div',
|
||||
{ class: 'm-meta muted' },
|
||||
items.map((it, i) => h('span', { class: 'm-item', key: i }, i ? [' · ', it] : [it])),
|
||||
),
|
||||
h(Actions, { contest: row }),
|
||||
])
|
||||
}
|
||||
|
||||
const mobileColumns: DataTableColumn<AdminContestListItem>[] = [
|
||||
{ title: "比赛", key: "title", render: renderMobileCard },
|
||||
{ title: "看得到", key: "visible", width: 72, align: "center", render: renderVisible },
|
||||
{ title: '比赛', key: 'title', render: renderMobileCard },
|
||||
{ title: '看得到', key: 'visible', width: 72, align: 'center', render: renderVisible },
|
||||
]
|
||||
|
||||
const desktopColumns: DataTableColumn<AdminContestListItem>[] = [
|
||||
{
|
||||
title: "比赛",
|
||||
key: "title",
|
||||
title: '比赛',
|
||||
key: 'title',
|
||||
width: 260,
|
||||
render: renderTitle,
|
||||
},
|
||||
{
|
||||
title: "开始 · 多长",
|
||||
key: "start_time",
|
||||
title: '开始 · 多长',
|
||||
key: 'start_time',
|
||||
width: 200,
|
||||
render: (row) =>
|
||||
h("span", { class: "num" }, [
|
||||
parseTime(row.startTime, "YYYY-MM-DD HH:mm"),
|
||||
h("span", { class: "muted" }, ` · ${duration(row)}`),
|
||||
]),
|
||||
render: (row) => h('span', { class: 'num' }, [parseTime(row.startTime, 'YYYY-MM-DD HH:mm'), h('span', { class: 'muted' }, ` · ${duration(row)}`)]),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 72,
|
||||
render: renderStatus,
|
||||
},
|
||||
{ title: "题", key: "problemCount", width: 56 },
|
||||
{ title: '题', key: 'problemCount', width: 56 },
|
||||
{
|
||||
title: "参加",
|
||||
key: "participantCount",
|
||||
title: '参加',
|
||||
key: 'participantCount',
|
||||
width: 64,
|
||||
render: (row) => (row.participantCount ? String(row.participantCount) : "—"),
|
||||
render: (row) => (row.participantCount ? String(row.participantCount) : '—'),
|
||||
},
|
||||
{
|
||||
title: "出卷",
|
||||
key: "created_by",
|
||||
title: '出卷',
|
||||
key: 'created_by',
|
||||
width: 84,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.createdBy.username,
|
||||
},
|
||||
{
|
||||
title: "学生看得到",
|
||||
key: "visible",
|
||||
title: '学生看得到',
|
||||
key: 'visible',
|
||||
width: 96,
|
||||
render: renderVisible,
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
title: '',
|
||||
key: 'actions',
|
||||
minWidth: 270,
|
||||
render: (row) => h(Actions, { contest: row }),
|
||||
},
|
||||
@@ -175,26 +164,13 @@ watchDebounced(() => query.keyword, listContests, {
|
||||
<template>
|
||||
<PageHeader title="比赛">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin contest create' })">
|
||||
新建比赛
|
||||
</n-button>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin contest create' })"> 新建比赛 </n-button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="比赛名"
|
||||
clearable
|
||||
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||
/>
|
||||
<n-input v-model:value="query.keyword" placeholder="比赛名" clearable :style="{ width: isMobile ? '100%' : '220px' }" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
striped
|
||||
class="contest-table"
|
||||
:columns="columns"
|
||||
:data="contests"
|
||||
/>
|
||||
<n-data-table :loading="loading" striped class="contest-table" :columns="columns" :data="contests" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
@@ -236,8 +212,8 @@ watchDebounced(() => query.keyword, listContests, {
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.errorColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.contest-table :deep(.num) {
|
||||
@@ -246,11 +222,11 @@ watchDebounced(() => query.keyword, listContests, {
|
||||
}
|
||||
|
||||
.contest-table :deep(.muted) {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.contest-table :deep(.live) {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NText } from "naive-ui"
|
||||
import { getLearnExerciseAttempts } from "admin/api"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { LearnExerciseAttempt } from "utils/types"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { NText } from 'naive-ui'
|
||||
import { getLearnExerciseAttempts } from 'admin/api'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { LearnExerciseAttempt } from 'utils/types'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
const props = defineProps<{ exerciseId: number; className: string }>()
|
||||
const { isMobile } = useBreakpoints()
|
||||
@@ -14,57 +14,53 @@ const rows = ref<LearnExerciseAttempt[]>([])
|
||||
const untried = ref<{ userId: number; username: string }[]>([])
|
||||
|
||||
function renderResult(row: LearnExerciseAttempt) {
|
||||
return row.solved
|
||||
? h(NText, { type: "success" }, () => `做对了(第 ${row.attemptsToSolve} 次)`)
|
||||
: h(NText, { type: "error" }, () => "还没做对")
|
||||
return row.solved ? h(NText, { type: 'success' }, () => `做对了(第 ${row.attemptsToSolve} 次)`) : h(NText, { type: 'error' }, () => '还没做对')
|
||||
}
|
||||
|
||||
const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
||||
// 一个弹框只看一个班,班级列多余;用户名里本来就带名字,不再单列真名
|
||||
{
|
||||
title: "学生",
|
||||
key: "username",
|
||||
title: '学生',
|
||||
key: 'username',
|
||||
width: 140,
|
||||
render: (row) => h(UserName, { username: row.username }),
|
||||
},
|
||||
{
|
||||
title: "结果",
|
||||
key: "solved",
|
||||
title: '结果',
|
||||
key: 'solved',
|
||||
width: 130,
|
||||
render: renderResult,
|
||||
},
|
||||
{ title: "提交次数", key: "attempts", width: 110, sorter: "default" },
|
||||
{ title: '提交次数', key: 'attempts', width: 110, sorter: 'default' },
|
||||
{
|
||||
// 学生最后一次做错时提交的内容,前端拼好的一句人话。选择题看这一列
|
||||
// 就知道全班是不是都掉进同一个干扰项
|
||||
title: "最后一次错在",
|
||||
key: "lastWrongAnswer",
|
||||
title: '最后一次错在',
|
||||
key: 'lastWrongAnswer',
|
||||
minWidth: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.lastWrongAnswer || "-",
|
||||
render: (row) => row.lastWrongAnswer || '-',
|
||||
},
|
||||
{
|
||||
title: "最后作答",
|
||||
key: "lastAttemptAt",
|
||||
title: '最后作答',
|
||||
key: 'lastAttemptAt',
|
||||
width: 120,
|
||||
render: (row) => parseTime(row.lastAttemptAt, "M月D日 HH:mm"),
|
||||
render: (row) => parseTime(row.lastAttemptAt, 'M月D日 HH:mm'),
|
||||
},
|
||||
]
|
||||
|
||||
/** 手机上五列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */
|
||||
const mobileColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
||||
{
|
||||
title: "学生",
|
||||
key: "username",
|
||||
title: '学生',
|
||||
key: 'username',
|
||||
render: (row) =>
|
||||
h("div", { class: "m-row" }, [
|
||||
h("div", { class: "m-line" }, [h(UserName, { username: row.username }), renderResult(row)]),
|
||||
h('div', { class: 'm-row' }, [
|
||||
h('div', { class: 'm-line' }, [h(UserName, { username: row.username }), renderResult(row)]),
|
||||
h(
|
||||
NText,
|
||||
{ depth: 3, class: "m-sub" },
|
||||
() =>
|
||||
`交了 ${row.attempts} 次 · ${parseTime(row.lastAttemptAt, "M月D日 HH:mm")}` +
|
||||
(row.lastWrongAnswer ? ` · 最后错在 ${row.lastWrongAnswer}` : ""),
|
||||
{ depth: 3, class: 'm-sub' },
|
||||
() => `交了 ${row.attempts} 次 · ${parseTime(row.lastAttemptAt, 'M月D日 HH:mm')}` + (row.lastWrongAnswer ? ` · 最后错在 ${row.lastWrongAnswer}` : ''),
|
||||
),
|
||||
]),
|
||||
},
|
||||
@@ -86,29 +82,15 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-data-table
|
||||
striped
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
size="small"
|
||||
:pagination="rows.length > 10 ? { pageSize: 10 } : false"
|
||||
/>
|
||||
<n-empty
|
||||
v-if="!loading && rows.length === 0"
|
||||
description="还没有人做过这道练习"
|
||||
size="small"
|
||||
style="padding: 16px 0"
|
||||
/>
|
||||
<n-data-table striped :loading="loading" :columns="columns" :data="rows" size="small" :pagination="rows.length > 10 ? { pageSize: 10 } : false" />
|
||||
<n-empty v-if="!loading && rows.length === 0" description="还没有人做过这道练习" size="small" style="padding: 16px 0" />
|
||||
<div v-if="untried.length" class="untried">
|
||||
<div class="untried-head">
|
||||
<span class="strong">读了这课还没做</span>
|
||||
<n-text depth="3">{{ untried.length }} 人</n-text>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span v-for="row in untried" :key="row.userId" class="chip"
|
||||
><UserName :username="row.username"
|
||||
/></span>
|
||||
<span v-for="row in untried" :key="row.userId" class="chip"><UserName :username="row.username" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { LearnBoardStudent } from "utils/types"
|
||||
import type { LearnBoardStudent } from 'utils/types'
|
||||
|
||||
/** 读过这课的一个人,和他在这道配套题上的情况(一次没交是 null) */
|
||||
export interface ProblemReader {
|
||||
userId: number
|
||||
username: string
|
||||
state: LearnBoardStudent["problems"][number] | null
|
||||
state: LearnBoardStudent['problems'][number] | null
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NText } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { NText } from 'naive-ui'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
/**
|
||||
* 一道配套题,读过这课的人各自做没做对。数据就是方块格那份,不另外请求。
|
||||
@@ -31,49 +31,43 @@ function rank(row: ProblemReader) {
|
||||
const rows = computed(() => [...props.people].sort((a, b) => rank(a) - rank(b)))
|
||||
|
||||
function renderResult(row: ProblemReader) {
|
||||
if (!row.state) return h(NText, { depth: 3 }, () => "没交")
|
||||
return row.state.solved
|
||||
? h(NText, { type: "success" }, () => "做对了")
|
||||
: h(NText, { type: "error" }, () => `交了 ${row.state!.submissions} 次还没对`)
|
||||
if (!row.state) return h(NText, { depth: 3 }, () => '没交')
|
||||
return row.state.solved ? h(NText, { type: 'success' }, () => '做对了') : h(NText, { type: 'error' }, () => `交了 ${row.state!.submissions} 次还没对`)
|
||||
}
|
||||
|
||||
/** 提交列表按这个人、这道题筛好,新标签页打开,后台这页不动 */
|
||||
function renderLink(row: ProblemReader) {
|
||||
if (!row.state) return null
|
||||
const { href } = router.resolve({
|
||||
path: "/submission",
|
||||
query: { problem: props.displayId, username: row.username, exactUsername: "1" },
|
||||
path: '/submission',
|
||||
query: { problem: props.displayId, username: row.username, exactUsername: '1' },
|
||||
})
|
||||
return h("a", { href, target: "_blank", class: "link" }, "看提交")
|
||||
return h('a', { href, target: '_blank', class: 'link' }, '看提交')
|
||||
}
|
||||
|
||||
const desktopColumns: DataTableColumn<ProblemReader>[] = [
|
||||
{
|
||||
title: "学生",
|
||||
key: "username",
|
||||
title: '学生',
|
||||
key: 'username',
|
||||
width: 150,
|
||||
render: (row) => h(UserName, { username: row.username }),
|
||||
},
|
||||
{ title: "结果", key: "result", width: 150, render: renderResult },
|
||||
{ title: '结果', key: 'result', width: 150, render: renderResult },
|
||||
{
|
||||
title: "最后提交",
|
||||
key: "lastSubmitAt",
|
||||
title: '最后提交',
|
||||
key: 'lastSubmitAt',
|
||||
width: 150,
|
||||
render: (row) =>
|
||||
row.state ? parseTime(row.state.lastSubmitAt, "YYYY年M月D日") : h(NText, { depth: 3 }, "-"),
|
||||
render: (row) => (row.state ? parseTime(row.state.lastSubmitAt, 'YYYY年M月D日') : h(NText, { depth: 3 }, '-')),
|
||||
},
|
||||
{ title: "", key: "link", render: renderLink },
|
||||
{ title: '', key: 'link', render: renderLink },
|
||||
]
|
||||
|
||||
const mobileColumns: DataTableColumn<ProblemReader>[] = [
|
||||
{
|
||||
title: "学生",
|
||||
key: "username",
|
||||
title: '学生',
|
||||
key: 'username',
|
||||
render: (row) =>
|
||||
h("div", { class: "m-line" }, [
|
||||
h(UserName, { username: row.username }),
|
||||
h("span", { class: "m-right" }, [renderResult(row), renderLink(row)]),
|
||||
]),
|
||||
h('div', { class: 'm-line' }, [h(UserName, { username: row.username }), h('span', { class: 'm-right' }, [renderResult(row), renderLink(row)])]),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getLearnStudent } from "admin/api"
|
||||
import { parseTime, readableDuration } from "utils/functions"
|
||||
import type { LearnStudentDetail } from "utils/types"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useBackClose } from "shared/composables/backClose"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { splitTitle, type TutorialType } from "oj/learn/lesson"
|
||||
import { EXERCISE_KIND } from "./kinds"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getLearnStudent } from 'admin/api'
|
||||
import { parseTime, readableDuration } from 'utils/functions'
|
||||
import type { LearnStudentDetail } from 'utils/types'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import { EXERCISE_KIND } from './kinds'
|
||||
|
||||
/**
|
||||
* 一个学生的自学明细:点方块格里的名字从右边滑出来。
|
||||
@@ -24,15 +24,15 @@ const props = defineProps<{
|
||||
/** 课 id → 第几课,跟方块格表头的编号一致 */
|
||||
steps: Map<number, number>
|
||||
}>()
|
||||
const show = defineModel<boolean>("show", { required: true })
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
|
||||
const theme = useThemeVars()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
useBackClose(show, () => !isDesktop.value)
|
||||
|
||||
type Lesson = LearnStudentDetail["lessons"][number]
|
||||
type Exercise = Lesson["exercises"][number]
|
||||
type Problem = Lesson["problems"][number]
|
||||
type Lesson = LearnStudentDetail['lessons'][number]
|
||||
type Exercise = Lesson['exercises'][number]
|
||||
type Problem = Lesson['problems'][number]
|
||||
|
||||
const detail = ref<LearnStudentDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -58,20 +58,20 @@ function solvedOf(lesson: Lesson) {
|
||||
}
|
||||
|
||||
function stateOf(ex: Exercise) {
|
||||
if (ex.solved) return "solved"
|
||||
return ex.attempts > 0 ? "wrong" : "todo"
|
||||
if (ex.solved) return 'solved'
|
||||
return ex.attempts > 0 ? 'wrong' : 'todo'
|
||||
}
|
||||
|
||||
function problemStateOf(problem: Problem) {
|
||||
if (problem.solved) return "solved"
|
||||
return problem.submissions > 0 ? "wrong" : "todo"
|
||||
if (problem.solved) return 'solved'
|
||||
return problem.submissions > 0 ? 'wrong' : 'todo'
|
||||
}
|
||||
|
||||
/** 提交列表按这个人、这道题筛好,新标签页打开 */
|
||||
function submissionsHref(problem: Problem) {
|
||||
return router.resolve({
|
||||
path: "/submission",
|
||||
query: { problem: problem.displayId, username: detail.value?.username, exactUsername: "1" },
|
||||
path: '/submission',
|
||||
query: { problem: problem.displayId, username: detail.value?.username, exactUsername: '1' },
|
||||
}).href
|
||||
}
|
||||
|
||||
@@ -95,8 +95,7 @@ watch(
|
||||
data.lessons
|
||||
.filter(
|
||||
(lesson) =>
|
||||
(lesson.exercises.some((ex) => ex.attempts > 0) &&
|
||||
lesson.exercises.some((ex) => !ex.solved)) ||
|
||||
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) ||
|
||||
lesson.problems.some((p) => p.submissions > 0 && !p.solved),
|
||||
)
|
||||
.map((lesson) => lesson.tutorialId),
|
||||
@@ -118,15 +117,9 @@ watch(
|
||||
<UserName :username="detail.username" class="head-name" />
|
||||
<span class="muted small num">
|
||||
打开过 {{ summary.opened }} 课
|
||||
<template v-if="summary.seconds">
|
||||
· 停留 {{ readableDuration(summary.seconds) }}</template
|
||||
>
|
||||
<template v-if="summary.total">
|
||||
· 练一练 {{ summary.solved }}/{{ summary.total }}</template
|
||||
>
|
||||
<template v-if="summary.problemTotal">
|
||||
· 配套题 {{ summary.problemSolved }}/{{ summary.problemTotal }}</template
|
||||
>
|
||||
<template v-if="summary.seconds"> · 停留 {{ readableDuration(summary.seconds) }}</template>
|
||||
<template v-if="summary.total"> · 练一练 {{ summary.solved }}/{{ summary.total }}</template>
|
||||
<template v-if="summary.problemTotal"> · 配套题 {{ summary.problemSolved }}/{{ summary.problemTotal }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>自学明细</span>
|
||||
@@ -149,22 +142,13 @@ watch(
|
||||
{{ splitTitle(lesson.title).kp }}
|
||||
</span>
|
||||
<span class="grow" />
|
||||
<span v-if="lesson.exercises.length" class="small num">
|
||||
{{ solvedOf(lesson) }}/{{ lesson.exercises.length }}
|
||||
</span>
|
||||
<span v-if="lesson.exercises.length" class="small num"> {{ solvedOf(lesson) }}/{{ lesson.exercises.length }} </span>
|
||||
<span class="squares">
|
||||
<span
|
||||
v-for="ex in lesson.exercises"
|
||||
:key="ex.exerciseId"
|
||||
class="square"
|
||||
:class="stateOf(ex)"
|
||||
/>
|
||||
<span v-for="ex in lesson.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
|
||||
</span>
|
||||
<Icon
|
||||
v-if="lesson.exercises.length || lesson.problems.length"
|
||||
:icon="
|
||||
expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'
|
||||
"
|
||||
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
||||
:width="12"
|
||||
class="muted"
|
||||
/>
|
||||
@@ -172,35 +156,22 @@ watch(
|
||||
<span class="lesson-line small muted num">
|
||||
<template v-if="lesson.viewCount">
|
||||
打开 {{ lesson.viewCount }} 次
|
||||
<template v-if="lesson.totalSeconds">
|
||||
· 停留 {{ readableDuration(lesson.totalSeconds) }}</template
|
||||
>
|
||||
<template v-if="lesson.lastViewedAt">
|
||||
· {{ parseTime(lesson.lastViewedAt, "M月D日 HH:mm") }}</template
|
||||
>
|
||||
<template v-if="lesson.totalSeconds"> · 停留 {{ readableDuration(lesson.totalSeconds) }}</template>
|
||||
<template v-if="lesson.lastViewedAt"> · {{ parseTime(lesson.lastViewedAt, 'M月D日 HH:mm') }}</template>
|
||||
</template>
|
||||
<template v-else>没打开过,直接做的练一练</template>
|
||||
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template>
|
||||
<template v-if="lesson.problems.length">
|
||||
· 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{
|
||||
lesson.problems.length
|
||||
}}</template
|
||||
>
|
||||
<template v-if="lesson.problems.length"> · 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{ lesson.problems.length }}</template>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)
|
||||
"
|
||||
class="ex-list"
|
||||
>
|
||||
<div v-if="expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)" class="ex-list">
|
||||
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex">
|
||||
<span class="square" :class="stateOf(ex)" />
|
||||
<div class="ex-body">
|
||||
<div class="ex-question">
|
||||
<span class="kind">{{ EXERCISE_KIND[ex.type] ?? ex.type }}</span>
|
||||
{{ ex.question || "(没有题干)" }}
|
||||
{{ ex.question || '(没有题干)' }}
|
||||
</div>
|
||||
<div class="small num">
|
||||
<span v-if="ex.solved" class="ok">做对了(第 {{ ex.attemptsToSolve }} 次)</span>
|
||||
@@ -208,9 +179,7 @@ watch(
|
||||
<span class="bad">还没做对,交了 {{ ex.attempts }} 次</span>
|
||||
</template>
|
||||
<span v-else class="muted">没做</span>
|
||||
<span v-if="ex.lastWrongAnswer" class="muted">
|
||||
· 最后错在 {{ ex.lastWrongAnswer }}</span
|
||||
>
|
||||
<span v-if="ex.lastWrongAnswer" class="muted"> · 最后错在 {{ ex.lastWrongAnswer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,14 +193,10 @@ watch(
|
||||
</div>
|
||||
<div class="small num">
|
||||
<span v-if="problem.solved" class="ok">做对了</span>
|
||||
<span v-else-if="problem.submissions" class="bad"
|
||||
>交了 {{ problem.submissions }} 次还没对</span
|
||||
>
|
||||
<span v-else-if="problem.submissions" class="bad">交了 {{ problem.submissions }} 次还没对</span>
|
||||
<span v-else class="muted">没交</span>
|
||||
<template v-if="problem.lastSubmitAt">
|
||||
<span class="muted">
|
||||
· {{ parseTime(problem.lastSubmitAt, "YYYY年M月D日") }} ·
|
||||
</span>
|
||||
<span class="muted"> · {{ parseTime(problem.lastSubmitAt, 'YYYY年M月D日') }} · </span>
|
||||
<a :href="submissionsHref(problem)" target="_blank" class="link">看提交</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { LearnBoard, LearnBoardStudent, LearnExerciseProgress } from "utils/types"
|
||||
import { getLearnBoard, getLearnExercises } from "admin/api"
|
||||
import { parseTime, readableDuration } from "utils/functions"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { groupClassOptions } from "utils/classes"
|
||||
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
|
||||
import "oj/learn/learn.css"
|
||||
import ExerciseAttempts from "./ExerciseAttempts.vue"
|
||||
import ProblemReaders, { type ProblemReader } from "./ProblemReaders.vue"
|
||||
import StudentDrawer from "./StudentDrawer.vue"
|
||||
import { EXERCISE_KIND } from "./kinds"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { LearnBoard, LearnBoardStudent, LearnExerciseProgress } from 'utils/types'
|
||||
import { getLearnBoard, getLearnExercises } from 'admin/api'
|
||||
import { parseTime, readableDuration } from 'utils/functions'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import 'oj/learn/learn.css'
|
||||
import ExerciseAttempts from './ExerciseAttempts.vue'
|
||||
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
|
||||
import StudentDrawer from './StudentDrawer.vue'
|
||||
import { EXERCISE_KIND } from './kinds'
|
||||
|
||||
/**
|
||||
* 自学情况(设计稿「后台 · 自学情况」):一个班学到哪了。
|
||||
@@ -26,43 +26,30 @@ import { EXERCISE_KIND } from "./kinds"
|
||||
const configStore = useConfigStore()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const type = ref<TutorialType>("python")
|
||||
const type = ref<TutorialType>('python')
|
||||
const className = ref<string | null>(null)
|
||||
const board = ref<LearnBoard | null>(null)
|
||||
const exercises = ref<LearnExerciseProgress[]>([])
|
||||
const lessonId = ref<number | null>(null)
|
||||
const expanded = ref<number | null>(null)
|
||||
const side = ref<"exercise" | "problem">("exercise")
|
||||
const side = ref<'exercise' | 'problem'>('exercise')
|
||||
const problemOpen = ref<number | null>(null)
|
||||
const studentId = ref<number | null>(null)
|
||||
const showStudent = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||
const groups = computed(() =>
|
||||
groupByProject(
|
||||
(board.value?.tutorials ?? []).map(({ id, title, project }) => ({ id, title, project })),
|
||||
),
|
||||
)
|
||||
const groups = computed(() => groupByProject((board.value?.tutorials ?? []).map(({ id, title, project }) => ({ id, title, project }))))
|
||||
const started = computed(() =>
|
||||
(board.value?.students ?? [])
|
||||
.filter((row) => row.opened.length || row.exerciseSolved)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.done.length - a.done.length ||
|
||||
b.opened.length - a.opened.length ||
|
||||
(b.lastViewedAt ?? "").localeCompare(a.lastViewedAt ?? ""),
|
||||
),
|
||||
)
|
||||
const idle = computed(() =>
|
||||
(board.value?.students ?? []).filter((row) => !row.opened.length && !row.exerciseSolved),
|
||||
.sort((a, b) => b.done.length - a.done.length || b.opened.length - a.opened.length || (b.lastViewedAt ?? '').localeCompare(a.lastViewedAt ?? '')),
|
||||
)
|
||||
const idle = computed(() => (board.value?.students ?? []).filter((row) => !row.opened.length && !row.exerciseSolved))
|
||||
const finishedOne = computed(() => started.value.filter((row) => row.done.length > 0).length)
|
||||
const avgSeconds = computed(() => {
|
||||
const rows = started.value
|
||||
return rows.length
|
||||
? Math.round(rows.reduce((n, row) => n + row.totalSeconds, 0) / rows.length)
|
||||
: 0
|
||||
return rows.length ? Math.round(rows.reduce((n, row) => n + row.totalSeconds, 0) / rows.length) : 0
|
||||
})
|
||||
const openedCount = computed(() => {
|
||||
const counts = new Map<number, number>()
|
||||
@@ -71,28 +58,13 @@ const openedCount = computed(() => {
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const lessonProblems = computed(
|
||||
() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])),
|
||||
)
|
||||
const lessonProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
|
||||
/** 学生 → 题 → 交过的情况 */
|
||||
const problemStates = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(board.value?.students ?? []).map((row) => [
|
||||
row.userId,
|
||||
new Map(row.problems.map((problem) => [problem.problemId, problem])),
|
||||
]),
|
||||
),
|
||||
)
|
||||
const exerciseTotal = computed(
|
||||
() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])),
|
||||
)
|
||||
const steps = computed(
|
||||
() =>
|
||||
new Map(
|
||||
groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step])),
|
||||
),
|
||||
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
|
||||
)
|
||||
const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])))
|
||||
const steps = computed(() => new Map(groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step]))))
|
||||
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
||||
const lessonOptions = computed(() =>
|
||||
groups.value.flatMap((group) =>
|
||||
@@ -102,14 +74,10 @@ const lessonOptions = computed(() =>
|
||||
})),
|
||||
),
|
||||
)
|
||||
const lessonExercises = computed(() =>
|
||||
exercises.value.filter((row) => row.tutorialId === lessonId.value),
|
||||
)
|
||||
const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value))
|
||||
/** 读过(或做过练一练)这一课的人 */
|
||||
function readersOf(id: number) {
|
||||
return started.value.filter(
|
||||
(row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id),
|
||||
)
|
||||
return started.value.filter((row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id))
|
||||
}
|
||||
const sideProblems = computed(() => {
|
||||
const id = lessonId.value
|
||||
@@ -135,11 +103,11 @@ function lessonOf(row: LearnBoardStudent, id: number) {
|
||||
}
|
||||
|
||||
function cellOf(row: LearnBoardStudent, id: number) {
|
||||
if (row.done.includes(id)) return "done"
|
||||
if (row.done.includes(id)) return 'done'
|
||||
const lesson = lessonOf(row, id)
|
||||
if (lesson) return lesson.solved ? "partial" : "partial stuck"
|
||||
if (row.opened.includes(id)) return "opened"
|
||||
return ""
|
||||
if (lesson) return lesson.solved ? 'partial' : 'partial stuck'
|
||||
if (row.opened.includes(id)) return 'opened'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
||||
@@ -147,22 +115,20 @@ function cellStyle(row: LearnBoardStudent, id: number) {
|
||||
const lesson = lessonOf(row, id)
|
||||
const total = exerciseTotal.value.get(id) ?? 0
|
||||
if (!lesson || !total || row.done.includes(id)) return undefined
|
||||
return { "--fill": `${Math.round((lesson.solved / total) * 100)}%` }
|
||||
return { '--fill': `${Math.round((lesson.solved / total) * 100)}%` }
|
||||
}
|
||||
|
||||
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
||||
const head = `第 ${step} 课 ${splitTitle(title).kp}`
|
||||
const total = exerciseTotal.value.get(id) ?? 0
|
||||
const lesson = lessonOf(row, id)
|
||||
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ""
|
||||
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ''
|
||||
const problems = lessonProblems.value.get(id) ?? []
|
||||
const states = problemStates.value.get(row.userId)
|
||||
const tail = problems.length
|
||||
? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}`
|
||||
: ""
|
||||
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
|
||||
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
|
||||
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}`
|
||||
if (row.opened.includes(id)) return `${head}:${total ? "打开过,练一练没做" : "打开过"}${tail}`
|
||||
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
|
||||
return `${head}:没打开`
|
||||
}
|
||||
|
||||
@@ -196,10 +162,10 @@ function openStudent(row: LearnBoardStudent) {
|
||||
}
|
||||
|
||||
function flag(row: LearnExerciseProgress) {
|
||||
if (row.triedUsers < 3) return ""
|
||||
if (row.firstTryUsers === 0 && row.solvedUsers > 0) return "没人一次对"
|
||||
if (row.solvedUsers / row.triedUsers < 0.5) return "多数人卡住"
|
||||
return ""
|
||||
if (row.triedUsers < 3) return ''
|
||||
if (row.firstTryUsers === 0 && row.solvedUsers > 0) return '没人一次对'
|
||||
if (row.solvedUsers / row.triedUsers < 0.5) return '多数人卡住'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -210,9 +176,7 @@ async function load() {
|
||||
const data = await getLearnBoard(params)
|
||||
board.value = data
|
||||
className.value = data.className
|
||||
const ex = data.className
|
||||
? await getLearnExercises({ type: type.value, className: data.className })
|
||||
: { results: [] }
|
||||
const ex = data.className ? await getLearnExercises({ type: type.value, className: data.className }) : { results: [] }
|
||||
exercises.value = ex.results
|
||||
// 练一练默认看大家打开得最多的那课
|
||||
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
@@ -239,18 +203,10 @@ onMounted(load)
|
||||
<span class="title">自学情况</span>
|
||||
<span class="grow" />
|
||||
<div class="seg">
|
||||
<button type="button" :class="{ on: type === 'python' }" @click="pickType('python')">
|
||||
Python
|
||||
</button>
|
||||
<button type="button" :class="{ on: type === 'python' }" @click="pickType('python')">Python</button>
|
||||
<button type="button" :class="{ on: type === 'c' }" @click="pickType('c')">C 语言</button>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="className"
|
||||
:options="classOptions"
|
||||
filterable
|
||||
placeholder="选班级"
|
||||
class="class-select"
|
||||
/>
|
||||
<n-select v-model:value="className" :options="classOptions" filterable placeholder="选班级" class="class-select" />
|
||||
</div>
|
||||
|
||||
<n-spin :show="loading">
|
||||
@@ -280,16 +236,9 @@ onMounted(load)
|
||||
<span class="c-name strong">学到哪了</span>
|
||||
<div class="cells-head">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head">
|
||||
<span class="group-no" :title="group.project">{{
|
||||
group.no || group.name
|
||||
}}</span>
|
||||
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
|
||||
<span class="group-steps">
|
||||
<span
|
||||
v-for="lesson in group.lessons"
|
||||
:key="lesson.id"
|
||||
class="step"
|
||||
:title="splitTitle(lesson.title).kp"
|
||||
>
|
||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp">
|
||||
{{ lesson.step }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -318,22 +267,18 @@ onMounted(load)
|
||||
</div>
|
||||
<span class="c-num num">
|
||||
<template v-if="exerciseDue(row)"
|
||||
>{{ row.exerciseSolved
|
||||
}}<span class="muted">/{{ exerciseDue(row) }}</span></template
|
||||
>{{ row.exerciseSolved }}<span class="muted">/{{ exerciseDue(row) }}</span></template
|
||||
>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
<span class="c-num num">
|
||||
<template v-if="problemCount(row).total"
|
||||
>{{ problemCount(row).solved
|
||||
}}<span class="muted">/{{ problemCount(row).total }}</span></template
|
||||
>{{ problemCount(row).solved }}<span class="muted">/{{ problemCount(row).total }}</span></template
|
||||
>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
<span class="c-stay num">{{ readableDuration(row.totalSeconds) }}</span>
|
||||
<span class="c-last num muted">{{
|
||||
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日") : "-"
|
||||
}}</span>
|
||||
<span class="c-last num muted">{{ row.lastViewedAt ? parseTime(row.lastViewedAt, 'M月D日') : '-' }}</span>
|
||||
</div>
|
||||
<div v-if="!started.length" class="none muted">这个班还没人打开过</div>
|
||||
<div v-else class="line foot">
|
||||
@@ -341,7 +286,7 @@ onMounted(load)
|
||||
<div class="cells bars">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col">
|
||||
<span class="bar-num num">{{ openedCount.get(lesson.id) || "" }}</span>
|
||||
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span>
|
||||
<span
|
||||
class="bar"
|
||||
:class="{ zero: !openedCount.get(lesson.id) }"
|
||||
@@ -360,9 +305,7 @@ onMounted(load)
|
||||
<span class="muted num">{{ idle.length }} 人</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span v-for="row in idle" :key="row.userId" class="chip"
|
||||
><UserName :username="row.username"
|
||||
/></span>
|
||||
<span v-for="row in idle" :key="row.userId" class="chip"><UserName :username="row.username" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -370,23 +313,10 @@ onMounted(load)
|
||||
<section class="card side">
|
||||
<div class="side-head">
|
||||
<div class="seg small">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: side === 'exercise' }"
|
||||
@click="side = 'exercise'"
|
||||
>
|
||||
练一练
|
||||
</button>
|
||||
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">
|
||||
配套题
|
||||
</button>
|
||||
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
|
||||
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="lessonId"
|
||||
:options="lessonOptions"
|
||||
size="small"
|
||||
class="lesson-select"
|
||||
/>
|
||||
<n-select v-model:value="lessonId" :options="lessonOptions" size="small" class="lesson-select" />
|
||||
</div>
|
||||
<template v-if="side === 'problem'">
|
||||
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
|
||||
@@ -404,15 +334,11 @@ onMounted(load)
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
|
||||
<div
|
||||
v-for="row in side === 'exercise' ? lessonExercises : []"
|
||||
:key="row.exerciseId"
|
||||
class="ex"
|
||||
>
|
||||
<div v-for="row in side === 'exercise' ? lessonExercises : []" :key="row.exerciseId" class="ex">
|
||||
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
||||
<span class="ex-line">
|
||||
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
||||
<span class="question">{{ row.question || "(没有题干)" }}</span>
|
||||
<span class="question">{{ row.question || '(没有题干)' }}</span>
|
||||
<Icon icon="ph:caret-right-bold" :width="12" class="muted" />
|
||||
</span>
|
||||
<span class="ex-line small num">
|
||||
@@ -435,11 +361,7 @@ onMounted(load)
|
||||
style="width: 820px; max-width: 95vw"
|
||||
@update:show="(show: boolean) => !show && (expanded = null)"
|
||||
>
|
||||
<ExerciseAttempts
|
||||
v-if="expanded !== null"
|
||||
:exercise-id="expanded"
|
||||
:class-name="board?.className ?? ''"
|
||||
/>
|
||||
<ExerciseAttempts v-if="expanded !== null" :exercise-id="expanded" :class-name="board?.className ?? ''" />
|
||||
</n-modal>
|
||||
|
||||
<!-- 点一道配套题:读过这课的人各自做没做对 -->
|
||||
@@ -450,11 +372,7 @@ onMounted(load)
|
||||
style="width: 640px; max-width: 95vw"
|
||||
@update:show="(show: boolean) => !show && (problemOpen = null)"
|
||||
>
|
||||
<ProblemReaders
|
||||
v-if="openProblem"
|
||||
:display-id="openProblem.displayId"
|
||||
:people="openProblem.people"
|
||||
/>
|
||||
<ProblemReaders v-if="openProblem" :display-id="openProblem.displayId" :people="openProblem.people" />
|
||||
</n-modal>
|
||||
|
||||
<StudentDrawer v-model:show="showStudent" :user-id="studentId" :type="type" :steps="steps" />
|
||||
@@ -639,7 +557,7 @@ onMounted(load)
|
||||
background: linear-gradient(to top, #18a058 var(--fill), rgba(24, 160, 88, 0.3) var(--fill));
|
||||
}
|
||||
.cell.stuck {
|
||||
box-shadow: inset 0 -4px 0 v-bind("theme.warningColor");
|
||||
box-shadow: inset 0 -4px 0 v-bind('theme.warningColor');
|
||||
}
|
||||
.name-btn {
|
||||
border: 0;
|
||||
@@ -651,7 +569,7 @@ onMounted(load)
|
||||
cursor: pointer;
|
||||
}
|
||||
.name-btn :deep(.name) {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
}
|
||||
.name-btn:hover :deep(.name) {
|
||||
text-decoration: underline;
|
||||
@@ -841,7 +759,7 @@ onMounted(load)
|
||||
width: 96px;
|
||||
margin-left: -16px;
|
||||
padding-left: 16px;
|
||||
background: v-bind("theme.bodyColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
}
|
||||
.side {
|
||||
width: auto;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** 练一练的题型,后台显示用的短名 */
|
||||
export const EXERCISE_KIND: Record<string, string> = {
|
||||
mcq: "选择",
|
||||
sort: "排序",
|
||||
fill: "填空",
|
||||
match: "连线",
|
||||
predict: "预测输出",
|
||||
debug: "找错",
|
||||
group: "分组",
|
||||
mcq: '选择',
|
||||
sort: '排序',
|
||||
fill: '填空',
|
||||
match: '连线',
|
||||
predict: '预测输出',
|
||||
debug: '找错',
|
||||
group: '分组',
|
||||
}
|
||||
@@ -1,45 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { TEST_CASE_MAX_CASES } from "@oj2/contract"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
|
||||
import SqlProblemData from "oj/problem/components/SqlProblemData.vue"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { errorCode, errorMessage, isApiError } from "utils/api"
|
||||
import { DIFFICULTY, STORAGE_KEY } from "utils/constants"
|
||||
import { createZipBlob, getTagColor, unique } from "utils/functions"
|
||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from "utils/types"
|
||||
import PreviewStrip from "./components/PreviewStrip.vue"
|
||||
import { usePreviewCollapsed } from "./components/previewCollapse"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
editProblem,
|
||||
generateSQLTestcase,
|
||||
getSQLTestcaseScripts,
|
||||
previewSQLTestcase,
|
||||
uploadTestcases,
|
||||
} from "../api"
|
||||
import {
|
||||
cleanTags,
|
||||
difficultyOptions,
|
||||
isBlankHtml,
|
||||
problemListRoute,
|
||||
saveErrorText,
|
||||
tagTooLongText,
|
||||
useCreateDraft,
|
||||
} from "./components/problemForm"
|
||||
import {
|
||||
explainSqlError,
|
||||
groupSummary,
|
||||
type GroupStatus,
|
||||
type ScriptGroup,
|
||||
} from "./components/sqlScript"
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { TEST_CASE_MAX_CASES } from '@oj2/contract'
|
||||
import Markdown from 'shared/components/Markdown.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
import SqlProblemData from 'oj/problem/components/SqlProblemData.vue'
|
||||
import { getProblemTagList } from 'shared/api'
|
||||
import TextEditor from 'shared/components/TextEditor.vue'
|
||||
import { errorCode, errorMessage, isApiError } from 'utils/api'
|
||||
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
||||
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
||||
import PreviewStrip from './components/PreviewStrip.vue'
|
||||
import { usePreviewCollapsed } from './components/previewCollapse'
|
||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api'
|
||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm'
|
||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript'
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
||||
const wrapLines = [EditorView.lineWrapping]
|
||||
|
||||
@@ -71,7 +50,7 @@ const isCreate = computed(() => !props.problemID)
|
||||
interface SqlForm {
|
||||
_id: string
|
||||
title: string
|
||||
difficulty: BlankProblem["difficulty"]
|
||||
difficulty: BlankProblem['difficulty']
|
||||
visible: boolean
|
||||
tags: string[]
|
||||
description: string
|
||||
@@ -85,35 +64,35 @@ interface SqlForm {
|
||||
}
|
||||
|
||||
const blankForm = (): SqlForm => ({
|
||||
_id: "",
|
||||
title: "",
|
||||
difficulty: "Low",
|
||||
_id: '',
|
||||
title: '',
|
||||
difficulty: 'Low',
|
||||
visible: false,
|
||||
tags: ["SQL"],
|
||||
description: "",
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
tags: ['SQL'],
|
||||
description: '',
|
||||
hint: '',
|
||||
source: '',
|
||||
prompt: '',
|
||||
memoryLimit: 64,
|
||||
timeLimit: 1000,
|
||||
sqlConfig: { mode: "query", order_sensitive: false },
|
||||
answer: "",
|
||||
sqlConfig: { mode: 'query', order_sensitive: false },
|
||||
answer: '',
|
||||
})
|
||||
|
||||
const form = reactive<SqlForm>(blankForm())
|
||||
|
||||
const testCase = reactive({ id: "", score: [] as Testcase[] })
|
||||
const testCase = reactive({ id: '', score: [] as Testcase[] })
|
||||
|
||||
let nextKey = 0
|
||||
function newGroup(sql = ""): ScriptGroup {
|
||||
return { key: nextKey++, sql, ranFor: "", display: null, error: "", errorRaw: "", running: "" }
|
||||
function newGroup(sql = ''): ScriptGroup {
|
||||
return { key: nextKey++, sql, ranFor: '', display: null, error: '', errorRaw: '', running: '' }
|
||||
}
|
||||
const groups = ref<ScriptGroup[]>([])
|
||||
const selected = ref(0)
|
||||
/** 打开时库里那几份脚本:没改过就不用重新上传 */
|
||||
let savedScripts: string[] = []
|
||||
/** 已有的测试数据没读出来。空白编辑器和「本来就没有」长得一样,必须说出来 */
|
||||
const loadError = ref("")
|
||||
const loadError = ref('')
|
||||
|
||||
const showMore = ref(false)
|
||||
const ready = ref(false)
|
||||
@@ -122,12 +101,7 @@ const ready = ref(false)
|
||||
const draft = useCreateDraft<{ form: SqlForm; scripts: string[] }>(STORAGE_KEY.ADMIN_SQL_PROBLEM, {
|
||||
source: () => [form, groups.value.map((g) => g.sql)] as const,
|
||||
enabled: () => ready.value && isCreate.value,
|
||||
empty: () =>
|
||||
!form._id &&
|
||||
!form.title &&
|
||||
isBlankHtml(form.description) &&
|
||||
!form.answer &&
|
||||
!groups.value.some((g) => g.sql),
|
||||
empty: () => !form._id && !form.title && isBlankHtml(form.description) && !form.answer && !groups.value.some((g) => g.sql),
|
||||
take: () => ({ form: { ...form }, scripts: groups.value.map((g) => g.sql) }),
|
||||
})
|
||||
|
||||
@@ -142,7 +116,7 @@ async function load() {
|
||||
if (isCreate.value) {
|
||||
// 草稿是本机存的,形状不对(旧版本、被别的页面写坏)就当没有,别让整页起不来
|
||||
const saved = draft.value
|
||||
if (saved && typeof saved.form === "object" && Array.isArray(saved.scripts)) {
|
||||
if (saved && typeof saved.form === 'object' && Array.isArray(saved.scripts)) {
|
||||
Object.assign(form, blankForm(), saved.form)
|
||||
groups.value = saved.scripts.map((sql) => newGroup(String(sql)))
|
||||
} else {
|
||||
@@ -161,13 +135,13 @@ async function load() {
|
||||
visible: data.visible,
|
||||
tags: data.tags,
|
||||
description: data.description,
|
||||
hint: data.hint ?? "",
|
||||
source: data.source ?? "",
|
||||
prompt: data.prompt ?? "",
|
||||
hint: data.hint ?? '',
|
||||
source: data.source ?? '',
|
||||
prompt: data.prompt ?? '',
|
||||
memoryLimit: data.memoryLimit,
|
||||
timeLimit: data.timeLimit,
|
||||
sqlConfig: data.sqlConfig ?? { mode: "query", order_sensitive: false },
|
||||
answer: data.answers.find((a) => a.language === "SQL")?.code ?? "",
|
||||
sqlConfig: data.sqlConfig ?? { mode: 'query', order_sensitive: false },
|
||||
answer: data.answers.find((a) => a.language === 'SQL')?.code ?? '',
|
||||
} satisfies SqlForm)
|
||||
testCase.id = data.testCaseId
|
||||
testCase.score = data.testCaseScore
|
||||
@@ -178,11 +152,9 @@ async function load() {
|
||||
} catch (err) {
|
||||
const code = errorCode(err)
|
||||
// 测试点是旧格式(不是 SQL 脚本)时后端回 not-sql-test-case,当作「还没有」就对了
|
||||
if (code !== "problem-not-found" && code !== "not-sql-test-case") {
|
||||
const detail = isApiError(err) && typeof err.data === "string" ? `(${err.data})` : ""
|
||||
loadError.value =
|
||||
`已有的测试数据没读出来${detail}。下面是空白的,不是这道题真实的测试数据 —— ` +
|
||||
`直接保存会把它换掉,先排查测试点文件再改。`
|
||||
if (code !== 'problem-not-found' && code !== 'not-sql-test-case') {
|
||||
const detail = isApiError(err) && typeof err.data === 'string' ? `(${err.data})` : ''
|
||||
loadError.value = `已有的测试数据没读出来${detail}。下面是空白的,不是这道题真实的测试数据 —— ` + `直接保存会把它换掉,先排查测试点文件再改。`
|
||||
}
|
||||
}
|
||||
if (groups.value.length < 2) {
|
||||
@@ -203,7 +175,7 @@ const tagOptions = computed(() =>
|
||||
|
||||
function onTagsChange(tags: string[]) {
|
||||
const cleaned = cleanTags(tags)
|
||||
if ("tooLong" in cleaned) message.error(tagTooLongText(cleaned.tooLong))
|
||||
if ('tooLong' in cleaned) message.error(tagTooLongText(cleaned.tooLong))
|
||||
else form.tags = cleaned.tags
|
||||
}
|
||||
|
||||
@@ -213,14 +185,13 @@ function onTagsChange(tags: string[]) {
|
||||
* 一组数据跑出来的结果取决于这几样,任何一样变了,原来的结果就作废。
|
||||
* 排在第一也算:删掉第 1 组之后,原来的第 2 组成了题面展示的那组,增删改题得重查它改没改到行
|
||||
*/
|
||||
const signatureOf = (g: ScriptGroup) =>
|
||||
JSON.stringify([form.answer, form.sqlConfig.mode, g.sql, groups.value[0] === g])
|
||||
const signatureOf = (g: ScriptGroup) => JSON.stringify([form.answer, form.sqlConfig.mode, g.sql, groups.value[0] === g])
|
||||
|
||||
function statusOf(g: ScriptGroup): GroupStatus {
|
||||
if (!g.sql.trim()) return "empty"
|
||||
if (!form.answer.trim()) return "no-answer"
|
||||
if (g.ranFor !== signatureOf(g)) return "running"
|
||||
return g.error ? "error" : "ok"
|
||||
if (!g.sql.trim()) return 'empty'
|
||||
if (!form.answer.trim()) return 'no-answer'
|
||||
if (g.ranFor !== signatureOf(g)) return 'running'
|
||||
return g.error ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
async function run(g: ScriptGroup) {
|
||||
@@ -228,8 +199,8 @@ async function run(g: ScriptGroup) {
|
||||
if (!g.sql.trim() || !form.answer.trim()) return
|
||||
if (g.ranFor === signature || g.running === signature) return
|
||||
g.running = signature
|
||||
let display: ScriptGroup["display"] = null
|
||||
let errorRaw = ""
|
||||
let display: ScriptGroup['display'] = null
|
||||
let errorRaw = ''
|
||||
try {
|
||||
display = await previewSQLTestcase({
|
||||
initSql: g.sql,
|
||||
@@ -238,11 +209,10 @@ async function run(g: ScriptGroup) {
|
||||
shown: groups.value[0] === g,
|
||||
})
|
||||
} catch (err) {
|
||||
errorRaw =
|
||||
isApiError(err) && typeof err.data === "string" ? err.data : errorMessage(err, "跑不起来")
|
||||
errorRaw = isApiError(err) && typeof err.data === 'string' ? err.data : errorMessage(err, '跑不起来')
|
||||
}
|
||||
// 跑的过程中又改了:这次的结果照样记下(ranFor 说明它对应哪一版),新的那版另有一次在跑
|
||||
if (g.running === signature) g.running = ""
|
||||
if (g.running === signature) g.running = ''
|
||||
g.ranFor = signature
|
||||
g.display = display
|
||||
g.error = errorRaw && explainSqlError(errorRaw)
|
||||
@@ -291,7 +261,7 @@ async function generateGroup() {
|
||||
selected.value = groups.value.length - 1
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "AI 没写出来,稍后再试"))
|
||||
message.error(errorMessage(err, 'AI 没写出来,稍后再试'))
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
@@ -303,13 +273,12 @@ const current = computed(() => groups.value[selected.value])
|
||||
|
||||
/** 为什么还不能保存。按出题的顺序查,只说第一条 */
|
||||
const blocker = computed(() => {
|
||||
if (!form._id.trim() || !form.title.trim()) return "编号和题目还没填"
|
||||
if (isBlankHtml(form.description)) return "题目描述还没写"
|
||||
if (!form.tags.length) return "至少要有一个标签"
|
||||
if (!form.answer.trim()) return "标准答案还没写"
|
||||
if (groups.value.length < 2) return "测试数据至少要 2 组"
|
||||
if (groups.value.length > TEST_CASE_MAX_CASES)
|
||||
return `测试数据最多 ${TEST_CASE_MAX_CASES} 组,删掉 ${groups.value.length - TEST_CASE_MAX_CASES} 组`
|
||||
if (!form._id.trim() || !form.title.trim()) return '编号和题目还没填'
|
||||
if (isBlankHtml(form.description)) return '题目描述还没写'
|
||||
if (!form.tags.length) return '至少要有一个标签'
|
||||
if (!form.answer.trim()) return '标准答案还没写'
|
||||
if (groups.value.length < 2) return '测试数据至少要 2 组'
|
||||
if (groups.value.length > TEST_CASE_MAX_CASES) return `测试数据最多 ${TEST_CASE_MAX_CASES} 组,删掉 ${groups.value.length - TEST_CASE_MAX_CASES} 组`
|
||||
for (const [i, g] of groups.value.entries()) {
|
||||
if (!g.sql.trim()) return `第 ${i + 1} 组还是空的,写上或删掉`
|
||||
}
|
||||
@@ -321,17 +290,17 @@ const blocker = computed(() => {
|
||||
}
|
||||
for (const [i, g] of groups.value.entries()) {
|
||||
const status = statusOf(g)
|
||||
if (status === "running") return "测试数据还在跑……"
|
||||
if (status === "error") return `第 ${i + 1} 组还没跑通,改好或删掉才能保存`
|
||||
if (status === 'running') return '测试数据还在跑……'
|
||||
if (status === 'error') return `第 ${i + 1} 组还没跑通,改好或删掉才能保存`
|
||||
}
|
||||
return ""
|
||||
return ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
async function uploadScripts(scripts: string[]) {
|
||||
const blob = createZipBlob(scripts.map((content, i) => ({ name: `${i + 1}.sql`, content })))
|
||||
const res = await uploadTestcases(new File([blob], "testcase.zip", { type: "application/zip" }), {
|
||||
const res = await uploadTestcases(new File([blob], 'testcase.zip', { type: 'application/zip' }), {
|
||||
sql: true,
|
||||
})
|
||||
// 分值平分,余数给最后一组;只留落库的三个键(见契约 problemTestCaseScoreSchema)
|
||||
@@ -353,18 +322,15 @@ async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const scripts = groups.value.map((g) => g.sql)
|
||||
const changed =
|
||||
!testCase.id ||
|
||||
scripts.length !== savedScripts.length ||
|
||||
scripts.some((sql, i) => sql !== savedScripts[i])
|
||||
const changed = !testCase.id || scripts.length !== savedScripts.length || scripts.some((sql, i) => sql !== savedScripts[i])
|
||||
if (changed) await uploadScripts(scripts)
|
||||
|
||||
const body: BlankProblem = {
|
||||
_id: form._id.trim(),
|
||||
title: form.title.trim(),
|
||||
description: form.description,
|
||||
inputDescription: "",
|
||||
outputDescription: "",
|
||||
inputDescription: '',
|
||||
outputDescription: '',
|
||||
samples: [],
|
||||
testCaseId: testCase.id,
|
||||
testCaseScore: testCase.score,
|
||||
@@ -373,16 +339,16 @@ async function save() {
|
||||
difficulty: form.difficulty,
|
||||
visible: form.visible,
|
||||
tags: form.tags,
|
||||
languages: ["SQL"],
|
||||
languages: ['SQL'],
|
||||
template: {},
|
||||
hint: isBlankHtml(form.hint) ? "" : form.hint,
|
||||
hint: isBlankHtml(form.hint) ? '' : form.hint,
|
||||
source: form.source,
|
||||
prompt: form.prompt,
|
||||
answers: [{ language: "SQL", code: form.answer }],
|
||||
answers: [{ language: 'SQL', code: form.answer }],
|
||||
allowFlowchart: false,
|
||||
showFlowchart: false,
|
||||
mermaidCode: "",
|
||||
flowchartHint: "",
|
||||
mermaidCode: '',
|
||||
flowchartHint: '',
|
||||
astRules: null,
|
||||
sqlConfig: form.sqlConfig,
|
||||
sqlDisplay: null,
|
||||
@@ -398,9 +364,9 @@ async function save() {
|
||||
savedScripts = scripts
|
||||
if (isCreate.value) {
|
||||
draft.value = null
|
||||
message.success("SQL 题建好了")
|
||||
message.success('SQL 题建好了')
|
||||
} else {
|
||||
message.success("保存好了")
|
||||
message.success('保存好了')
|
||||
}
|
||||
router.push(problemListRoute(props.contestID))
|
||||
} catch (err) {
|
||||
@@ -436,43 +402,20 @@ onMounted(async () => {
|
||||
<section class="card">
|
||||
<div class="headRow">
|
||||
<n-input v-model:value="form._id" placeholder="编号" aria-label="显示编号" class="pid" />
|
||||
<n-input
|
||||
v-model:value="form.title"
|
||||
placeholder="题目,比如:统计平均工资超过6000元的部门"
|
||||
aria-label="题目"
|
||||
class="grow"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="form.difficulty"
|
||||
:options="difficultyOptions"
|
||||
aria-label="难度"
|
||||
class="difficulty"
|
||||
/>
|
||||
<n-input v-model:value="form.title" placeholder="题目,比如:统计平均工资超过6000元的部门" aria-label="题目" class="grow" />
|
||||
<n-select v-model:value="form.difficulty" :options="difficultyOptions" aria-label="难度" class="difficulty" />
|
||||
<label class="switch">
|
||||
<n-switch v-model:value="form.visible" size="small" />
|
||||
<span>可见</span>
|
||||
</label>
|
||||
</div>
|
||||
<TextEditor v-model:value="form.description" title="" simple :min-height="140" />
|
||||
<n-text depth="3" class="note">
|
||||
数据表和期望结果会自动接在描述后面给学生看(用第 1
|
||||
组测试数据),描述里不用再抄一遍表结构。
|
||||
</n-text>
|
||||
<n-text depth="3" class="note"> 数据表和期望结果会自动接在描述后面给学生看(用第 1 组测试数据),描述里不用再抄一遍表结构。 </n-text>
|
||||
<div class="tagRow">
|
||||
<span class="label">标签</span>
|
||||
<n-select
|
||||
:value="form.tags"
|
||||
:options="tagOptions"
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
placeholder="选一个,或者打字建新的"
|
||||
@update:value="onTagsChange"
|
||||
/>
|
||||
<n-select :value="form.tags" :options="tagOptions" multiple filterable tag placeholder="选一个,或者打字建新的" @update:value="onTagsChange" />
|
||||
</div>
|
||||
<n-button text type="primary" class="moreToggle" @click="showMore = !showMore">
|
||||
提示 · 来源 · 内存限制 {{ showMore ? "▴" : "▾" }}
|
||||
</n-button>
|
||||
<n-button text type="primary" class="moreToggle" @click="showMore = !showMore"> 提示 · 来源 · 内存限制 {{ showMore ? '▴' : '▾' }} </n-button>
|
||||
<div v-if="showMore" class="more">
|
||||
<div class="label">提示(学生卡住时点开看)</div>
|
||||
<TextEditor v-model:value="form.hint" title="" simple :min-height="60" />
|
||||
@@ -483,13 +426,7 @@ onMounted(async () => {
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">内存限制</span>
|
||||
<n-input-number
|
||||
v-model:value="form.memoryLimit"
|
||||
:min="1"
|
||||
:max="1024"
|
||||
:show-button="false"
|
||||
style="width: 110px"
|
||||
>
|
||||
<n-input-number v-model:value="form.memoryLimit" :min="1" :max="1024" :show-button="false" style="width: 110px">
|
||||
<template #suffix>MB</template>
|
||||
</n-input-number>
|
||||
</label>
|
||||
@@ -508,32 +445,15 @@ onMounted(async () => {
|
||||
<n-radio-button value="query">查询 · 比结果</n-radio-button>
|
||||
<n-radio-button value="modify">增删改 · 比执行完的表</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-checkbox
|
||||
v-if="form.sqlConfig.mode === 'query'"
|
||||
v-model:checked="form.sqlConfig.order_sensitive"
|
||||
size="small"
|
||||
>
|
||||
按顺序比
|
||||
</n-checkbox>
|
||||
<n-checkbox v-if="form.sqlConfig.mode === 'query'" v-model:checked="form.sqlConfig.order_sensitive" size="small"> 按顺序比 </n-checkbox>
|
||||
</div>
|
||||
<div class="subHead">
|
||||
<span class="label">标准答案</span>
|
||||
<n-text depth="3" class="note">
|
||||
{{
|
||||
form.sqlConfig.mode === "query"
|
||||
? "每组数据都拿它跑出期望结果,学生看不到"
|
||||
: "执行完之后,被改过的表就是期望结果,学生看不到"
|
||||
}}
|
||||
{{ form.sqlConfig.mode === 'query' ? '每组数据都拿它跑出期望结果,学生看不到' : '执行完之后,被改过的表就是期望结果,学生看不到' }}
|
||||
</n-text>
|
||||
</div>
|
||||
<CodeEditor
|
||||
v-model:value="form.answer"
|
||||
language="SQL"
|
||||
:extra-extensions="wrapLines"
|
||||
:font-size="14"
|
||||
height="120px"
|
||||
placeholder="SELECT ... FROM ..."
|
||||
/>
|
||||
<CodeEditor v-model:value="form.answer" language="SQL" :extra-extensions="wrapLines" :font-size="14" height="120px" placeholder="SELECT ... FROM ..." />
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -556,11 +476,7 @@ onMounted(async () => {
|
||||
>
|
||||
AI 再写一组
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:disabled="groupsFull"
|
||||
:title="groupsFull ? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了` : undefined"
|
||||
@click="addGroup"
|
||||
<n-button size="tiny" :disabled="groupsFull" :title="groupsFull ? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了` : undefined" @click="addGroup"
|
||||
>+ 加一组</n-button
|
||||
>
|
||||
</div>
|
||||
@@ -587,17 +503,8 @@ onMounted(async () => {
|
||||
<template v-else-if="g.display">{{ groupSummary(g.display) }}</template>
|
||||
</span>
|
||||
<span v-if="statusOf(g) === 'ok'" class="mark ok" aria-label="跑通了">✓</span>
|
||||
<span v-else-if="statusOf(g) === 'error'" class="mark error" aria-label="没跑通"
|
||||
>✗</span
|
||||
>
|
||||
<n-button
|
||||
v-if="groups.length > 1"
|
||||
size="tiny"
|
||||
quaternary
|
||||
class="remove"
|
||||
:aria-label="`删掉第 ${i + 1} 组`"
|
||||
@click.stop="removeGroup(i)"
|
||||
>
|
||||
<span v-else-if="statusOf(g) === 'error'" class="mark error" aria-label="没跑通">✗</span>
|
||||
<n-button v-if="groups.length > 1" size="tiny" quaternary class="remove" :aria-label="`删掉第 ${i + 1} 组`" @click.stop="removeGroup(i)">
|
||||
删掉
|
||||
</n-button>
|
||||
</div>
|
||||
@@ -620,9 +527,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<div class="saveBar">
|
||||
<n-button type="primary" :loading="saving" :disabled="!!blocker" @click="save">
|
||||
保存
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="saving" :disabled="!!blocker" @click="save"> 保存 </n-button>
|
||||
<n-text v-if="blocker" :type="blocker.includes('没跑通') ? 'error' : 'default'" depth="2">
|
||||
{{ blocker }}
|
||||
</n-text>
|
||||
@@ -640,14 +545,12 @@ onMounted(async () => {
|
||||
第 {{ i + 1 }} 组<span v-if="statusOf(g) === 'error'" class="error"> ✗</span>
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="previewCollapsed = true">
|
||||
收起 ›
|
||||
</n-button>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="previewCollapsed = true"> 收起 › </n-button>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="titleRow">
|
||||
<h2>
|
||||
<span v-if="form._id">{{ form._id }}</span> {{ form.title || "(题目还没起名)" }}
|
||||
<span v-if="form._id">{{ form._id }}</span> {{ form.title || '(题目还没起名)' }}
|
||||
</h2>
|
||||
<ProblemTypeTag kind="sql" />
|
||||
<n-tag size="small" :bordered="false" :type="getTagColor(form.difficulty)">
|
||||
@@ -656,33 +559,18 @@ onMounted(async () => {
|
||||
</div>
|
||||
<h3 class="title">描述</h3>
|
||||
<Markdown html :content="form.description" />
|
||||
<p v-if="selected > 0" class="otherGroup">
|
||||
学生看到的是第 1 组,下面是第 {{ selected + 1 }} 组跑出来的样子
|
||||
</p>
|
||||
<p v-if="selected > 0" class="otherGroup">学生看到的是第 1 组,下面是第 {{ selected + 1 }} 组跑出来的样子</p>
|
||||
<template v-if="current">
|
||||
<p v-if="statusOf(current) === 'no-answer'" class="placeholder">
|
||||
写好标准答案,这里就跑出期望结果
|
||||
</p>
|
||||
<p v-else-if="statusOf(current) === 'empty'" class="placeholder">
|
||||
第 {{ selected + 1 }} 组还没写脚本
|
||||
</p>
|
||||
<p v-else-if="statusOf(current) === 'running' && !current.display" class="placeholder">
|
||||
在跑……
|
||||
</p>
|
||||
<p v-if="statusOf(current) === 'no-answer'" class="placeholder">写好标准答案,这里就跑出期望结果</p>
|
||||
<p v-else-if="statusOf(current) === 'empty'" class="placeholder">第 {{ selected + 1 }} 组还没写脚本</p>
|
||||
<p v-else-if="statusOf(current) === 'running' && !current.display" class="placeholder">在跑……</p>
|
||||
<div v-else-if="statusOf(current) === 'error'" class="errorBox">
|
||||
{{ current.error }}
|
||||
<div v-if="current.error.startsWith('标准答案在这组数据上')" class="raw">
|
||||
每组数据都要建齐标准答案用到的表和列。
|
||||
</div>
|
||||
<div v-if="current.error !== current.errorRaw" class="raw small">
|
||||
原话:{{ current.errorRaw }}
|
||||
</div>
|
||||
<div v-if="current.error.startsWith('标准答案在这组数据上')" class="raw">每组数据都要建齐标准答案用到的表和列。</div>
|
||||
<div v-if="current.error !== current.errorRaw" class="raw small">原话:{{ current.errorRaw }}</div>
|
||||
</div>
|
||||
<div v-else-if="current.display" :class="{ stale: statusOf(current) === 'running' }">
|
||||
<SqlProblemData
|
||||
:display="current.display"
|
||||
:order-sensitive="form.sqlConfig.order_sensitive"
|
||||
>
|
||||
<SqlProblemData :display="current.display" :order-sensitive="form.sqlConfig.order_sensitive">
|
||||
<template #title="{ text }">
|
||||
<h3 class="title sqlTitle">{{ text }}</h3>
|
||||
</template>
|
||||
@@ -732,13 +620,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.card :deep(.editorWrapper) {
|
||||
@@ -794,7 +682,7 @@ onMounted(async () => {
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -848,12 +736,12 @@ onMounted(async () => {
|
||||
|
||||
.group.on {
|
||||
background-color: rgba(24, 160, 88, 0.1);
|
||||
box-shadow: inset 3px 0 0 v-bind("theme.primaryColor");
|
||||
box-shadow: inset 3px 0 0 v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.group.on.bad {
|
||||
background-color: rgba(208, 48, 80, 0.08);
|
||||
box-shadow: inset 3px 0 0 v-bind("theme.errorColor");
|
||||
box-shadow: inset 3px 0 0 v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.groupNo {
|
||||
@@ -864,7 +752,7 @@ onMounted(async () => {
|
||||
.shown {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
color: v-bind('theme.primaryColorPressed');
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -873,7 +761,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.mark {
|
||||
@@ -882,15 +770,15 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.error {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.summary.error {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.remove {
|
||||
@@ -912,8 +800,8 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0 14px;
|
||||
background: v-bind("theme.bodyColor");
|
||||
border-top: 1px solid v-bind("theme.borderColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
border-top: 1px solid v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.preview {
|
||||
@@ -936,10 +824,10 @@ onMounted(async () => {
|
||||
.page {
|
||||
max-height: calc(100vh - 170px);
|
||||
overflow: auto;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 18px 22px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
@@ -963,7 +851,7 @@ onMounted(async () => {
|
||||
line-height: 1.4;
|
||||
margin: 0 0 6px;
|
||||
padding-left: 9px;
|
||||
border-left: 3px solid v-bind("theme.primaryColor");
|
||||
border-left: 3px solid v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.sqlTitle {
|
||||
@@ -976,12 +864,12 @@ onMounted(async () => {
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
margin: 14px 0 0;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
@@ -989,13 +877,13 @@ onMounted(async () => {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(208, 48, 80, 0.08);
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.errorBox .raw {
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.errorBox .small {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { StuckProblem, StuckProblemDetail } from "utils/types"
|
||||
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { groupClassOptions } from "utils/classes"
|
||||
import { periodRange, type Period } from "oj/statistics/period"
|
||||
import { useBackClose } from "shared/composables/backClose"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import "oj/learn/learn.css"
|
||||
import StuckDetail from "./components/StuckDetail.vue"
|
||||
import { avgTries, rate } from "./components/stuckMath"
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { StuckProblem, StuckProblemDetail } from 'utils/types'
|
||||
import { getStuckProblemDetail, getStuckProblems } from 'admin/api'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import { periodRange, type Period } from 'oj/statistics/period'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import 'oj/learn/learn.css'
|
||||
import StuckDetail from './components/StuckDetail.vue'
|
||||
import { avgTries, rate } from './components/stuckMath'
|
||||
|
||||
/**
|
||||
* 卡点分析(设计稿「卡点与趋势」页的定稿 StuckFinalAll / StuckFinalClass):
|
||||
@@ -27,18 +27,18 @@ const { isMobile } = useBreakpoints()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const PERIODS: { label: string; value: Period }[] = [
|
||||
{ label: "这学期", value: "term" },
|
||||
{ label: "最近 30 天", value: "30d" },
|
||||
{ label: "最近 7 天", value: "7d" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: '这学期', value: 'term' },
|
||||
{ label: '最近 30 天', value: '30d' },
|
||||
{ label: '最近 7 天', value: '7d' },
|
||||
{ label: '全部', value: 'all' },
|
||||
]
|
||||
const THRESHOLDS = [5, 10, 20, 50]
|
||||
|
||||
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
||||
const period = useRouteQuery<string>("period", "term", { mode: "replace" })
|
||||
const tag = useRouteQuery<string>("tag", "", { mode: "replace" })
|
||||
const picked = useRouteQuery<string>("problem", "", { mode: "replace" })
|
||||
const min = useRouteQuery<string>("min", "", { mode: "replace" })
|
||||
const className = useRouteQuery<string>('className', '', { mode: 'replace' })
|
||||
const period = useRouteQuery<string>('period', 'term', { mode: 'replace' })
|
||||
const tag = useRouteQuery<string>('tag', '', { mode: 'replace' })
|
||||
const picked = useRouteQuery<string>('problem', '', { mode: 'replace' })
|
||||
const min = useRouteQuery<string>('min', '', { mode: 'replace' })
|
||||
|
||||
const problems = ref<StuckProblem[]>([])
|
||||
const detail = ref<StuckProblemDetail | null>(null)
|
||||
@@ -47,9 +47,7 @@ const detailLoading = ref(false)
|
||||
|
||||
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||
const threshold = computed(() => Number(min.value) || (className.value ? 10 : 20))
|
||||
const thresholdOptions = computed(() =>
|
||||
THRESHOLDS.map((n) => ({ label: `至少 ${n} 人做过`, value: n })),
|
||||
)
|
||||
const thresholdOptions = computed(() => THRESHOLDS.map((n) => ({ label: `至少 ${n} 人做过`, value: n })))
|
||||
|
||||
function firstRate(row: StuckProblem) {
|
||||
return rate(row.firstTry, row.users)
|
||||
@@ -73,14 +71,11 @@ const knowledge = computed(() => {
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
const all = visible.value.reduce(
|
||||
(sum, row) => ({ users: sum.users + row.users, firstTry: sum.firstTry + row.firstTry }),
|
||||
{ users: 0, firstTry: 0 },
|
||||
)
|
||||
const all = visible.value.reduce((sum, row) => ({ users: sum.users + row.users, firstTry: sum.firstTry + row.firstTry }), { users: 0, firstTry: 0 })
|
||||
return [
|
||||
{
|
||||
name: "",
|
||||
label: "全部",
|
||||
name: '',
|
||||
label: '全部',
|
||||
problems: visible.value.length,
|
||||
rate: rate(all.firstTry, all.users),
|
||||
},
|
||||
@@ -95,19 +90,13 @@ const knowledge = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const activeTag = computed(() =>
|
||||
knowledge.value.some((row) => row.name === tag.value) ? tag.value : "",
|
||||
)
|
||||
const activeTag = computed(() => (knowledge.value.some((row) => row.name === tag.value) ? tag.value : ''))
|
||||
|
||||
const list = computed(() =>
|
||||
visible.value
|
||||
.filter((row) => !activeTag.value || row.tags.includes(activeTag.value))
|
||||
.sort((a, b) => firstRate(a) - firstRate(b) || b.users - a.users),
|
||||
visible.value.filter((row) => !activeTag.value || row.tags.includes(activeTag.value)).sort((a, b) => firstRate(a) - firstRate(b) || b.users - a.users),
|
||||
)
|
||||
|
||||
const selected = computed(
|
||||
() => list.value.find((row) => String(row.id) === picked.value) ?? list.value[0] ?? null,
|
||||
)
|
||||
const selected = computed(() => list.value.find((row) => String(row.id) === picked.value) ?? list.value[0] ?? null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -182,13 +171,7 @@ onMounted(load)
|
||||
@update:value="(value: string | null) => (className = value ?? '')"
|
||||
/>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="option in PERIODS"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="{ on: period === option.value }"
|
||||
@click="period = option.value"
|
||||
>
|
||||
<button v-for="option in PERIODS" :key="option.value" type="button" :class="{ on: period === option.value }" @click="period = option.value">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -203,14 +186,7 @@ onMounted(load)
|
||||
<span class="muted small">一次就对</span>
|
||||
</div>
|
||||
<div class="know-list">
|
||||
<button
|
||||
v-for="row in knowledge"
|
||||
:key="row.name"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.name === activeTag }"
|
||||
@click="tag = row.name"
|
||||
>
|
||||
<button v-for="row in knowledge" :key="row.name" type="button" class="item" :class="{ on: row.name === activeTag }" @click="tag = row.name">
|
||||
<span class="line">
|
||||
<span class="name">{{ row.label }}</span>
|
||||
<span class="grow" />
|
||||
@@ -226,7 +202,7 @@ onMounted(load)
|
||||
|
||||
<section class="card problems">
|
||||
<div class="card-head">
|
||||
<span class="strong">{{ activeTag || "全部" }}</span>
|
||||
<span class="strong">{{ activeTag || '全部' }}</span>
|
||||
<span class="muted small num">{{ list.length }} 道</span>
|
||||
<span class="grow" />
|
||||
<n-select
|
||||
@@ -238,30 +214,17 @@ onMounted(load)
|
||||
/>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div v-if="!list.length && !loading" class="none muted">
|
||||
这段时间还没有 {{ threshold }} 人以上做过的题
|
||||
</div>
|
||||
<button
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.id === selected?.id }"
|
||||
@click="pick(row)"
|
||||
>
|
||||
<div v-if="!list.length && !loading" class="none muted">这段时间还没有 {{ threshold }} 人以上做过的题</div>
|
||||
<button v-for="row in list" :key="row.id" type="button" class="item" :class="{ on: row.id === selected?.id }" @click="pick(row)">
|
||||
<span class="line">
|
||||
<span class="pid muted num">{{ row.displayId }}</span>
|
||||
<span class="ptitle">{{ row.title }}</span>
|
||||
<span v-if="row.users > row.solved" class="stuck num">
|
||||
{{ row.users - row.solved }} 人没对
|
||||
</span>
|
||||
<span v-if="row.users > row.solved" class="stuck num"> {{ row.users - row.solved }} 人没对 </span>
|
||||
</span>
|
||||
<span class="line indent">
|
||||
<span class="track short"><span :style="{ width: `${firstRate(row)}%` }" /></span>
|
||||
<span class="pct num">{{ firstRate(row) }}%</span>
|
||||
<span class="muted small num nowrap">
|
||||
{{ row.users }} 人 · 平均 {{ avgTries(row) }} 次
|
||||
</span>
|
||||
<span class="muted small num nowrap"> {{ row.users }} 人 · 平均 {{ avgTries(row) }} 次 </span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -272,7 +235,7 @@ onMounted(load)
|
||||
<n-button quaternary circle @click="detailOpen = false">
|
||||
<template #icon><Icon icon="ph:arrow-left" /></template>
|
||||
</n-button>
|
||||
<span class="strong">{{ activeTag || "全部" }}</span>
|
||||
<span class="strong">{{ activeTag || '全部' }}</span>
|
||||
</div>
|
||||
<n-spin :show="detailLoading">
|
||||
<StuckDetail v-if="detail" :detail="detail" :class-name="className" :period="period" />
|
||||
@@ -526,7 +489,7 @@ onMounted(load)
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow-y: auto;
|
||||
background: v-bind("theme.bodyColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
}
|
||||
.detail-bar {
|
||||
position: sticky;
|
||||
@@ -538,7 +501,7 @@ onMounted(load)
|
||||
height: 48px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--learn-line);
|
||||
background: v-bind("theme.bodyColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import type { GradeTrend } from "utils/types"
|
||||
import { getGradeTrend } from "admin/api"
|
||||
import "oj/learn/learn.css"
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import type { GradeTrend } from 'utils/types'
|
||||
import { getGradeTrend } from 'admin/api'
|
||||
import 'oj/learn/learn.css'
|
||||
|
||||
/**
|
||||
* 年度趋势(设计稿「卡点与趋势」页的 TrendB):选一届,和往届(或另一届)在两边都做过的题上
|
||||
@@ -17,15 +17,15 @@ const router = useRouter()
|
||||
/** 和后端的 GRADE_MIN_USERS 一致,只用在空状态的文案里 */
|
||||
const MIN_USERS = 20
|
||||
const SAME = 2
|
||||
const PAST = "past"
|
||||
const PAST = 'past'
|
||||
|
||||
const data = ref<GradeTrend | null>(null)
|
||||
const loading = ref(false)
|
||||
const grade = useRouteQuery<string>("grade", "", { mode: "replace" })
|
||||
const compare = useRouteQuery<string>("compare", PAST, { mode: "replace" })
|
||||
const grade = useRouteQuery<string>('grade', '', { mode: 'replace' })
|
||||
const compare = useRouteQuery<string>('compare', PAST, { mode: 'replace' })
|
||||
|
||||
const grades = computed(() => data.value?.grades ?? [])
|
||||
const selected = computed(() => data.value?.grade ?? "")
|
||||
const selected = computed(() => data.value?.grade ?? '')
|
||||
|
||||
function rate(part: number, whole: number) {
|
||||
return whole ? Math.round((part / whole) * 100) : 0
|
||||
@@ -34,14 +34,12 @@ function rate(part: number, whole: number) {
|
||||
const pastGrades = computed(() => grades.value.filter((g) => g < selected.value))
|
||||
const pastLabel = computed(() => {
|
||||
const list = pastGrades.value
|
||||
if (!list.length) return "往届"
|
||||
if (!list.length) return '往届'
|
||||
return list.length === 1 ? `往届(${list[0]} 级)` : `往届(${list.at(-1)}–${list[0]} 级)`
|
||||
})
|
||||
const otherValue = computed(() => data.value?.compare ?? PAST)
|
||||
const otherLabel = computed(() =>
|
||||
otherValue.value === PAST ? pastLabel.value : `${otherValue.value} 级`,
|
||||
)
|
||||
const shortOther = computed(() => (otherValue.value === PAST ? "往届" : `${otherValue.value} 级`))
|
||||
const otherLabel = computed(() => (otherValue.value === PAST ? pastLabel.value : `${otherValue.value} 级`))
|
||||
const shortOther = computed(() => (otherValue.value === PAST ? '往届' : `${otherValue.value} 级`))
|
||||
|
||||
const gradeOptions = computed(() => grades.value.map((g) => ({ label: `${g} 级`, value: g })))
|
||||
const compareOptions = computed(() => [
|
||||
@@ -56,15 +54,9 @@ const rows = computed(() =>
|
||||
return { row, mine: row.mine, theirs: row.theirs, now, before, delta: now - before }
|
||||
}),
|
||||
)
|
||||
const worse = computed(() =>
|
||||
rows.value.filter((item) => item.delta < -SAME).sort((a, b) => a.delta - b.delta),
|
||||
)
|
||||
const same = computed(() =>
|
||||
rows.value.filter((item) => Math.abs(item.delta) <= SAME).sort((a, b) => a.delta - b.delta),
|
||||
)
|
||||
const better = computed(() =>
|
||||
rows.value.filter((item) => item.delta > SAME).sort((a, b) => b.delta - a.delta),
|
||||
)
|
||||
const worse = computed(() => rows.value.filter((item) => item.delta < -SAME).sort((a, b) => a.delta - b.delta))
|
||||
const same = computed(() => rows.value.filter((item) => Math.abs(item.delta) <= SAME).sort((a, b) => a.delta - b.delta))
|
||||
const better = computed(() => rows.value.filter((item) => item.delta > SAME).sort((a, b) => b.delta - a.delta))
|
||||
|
||||
const summary = computed(() => {
|
||||
const total = rows.value.reduce(
|
||||
@@ -84,18 +76,18 @@ const summary = computed(() => {
|
||||
|
||||
const groups = computed(() =>
|
||||
[
|
||||
{ key: "worse", label: `比${shortOther.value}差`, items: worse.value },
|
||||
{ key: "same", label: "差不多", items: same.value },
|
||||
{ key: "better", label: `比${shortOther.value}好`, items: better.value },
|
||||
{ key: 'worse', label: `比${shortOther.value}差`, items: worse.value },
|
||||
{ key: 'same', label: '差不多', items: same.value },
|
||||
{ key: 'better', label: `比${shortOther.value}好`, items: better.value },
|
||||
].filter((group) => group.items.length),
|
||||
)
|
||||
|
||||
function tone(delta: number) {
|
||||
return delta < -SAME ? "worse" : delta > SAME ? "better" : "same"
|
||||
return delta < -SAME ? 'worse' : delta > SAME ? 'better' : 'same'
|
||||
}
|
||||
|
||||
function problemHref(displayId: string) {
|
||||
return router.resolve({ name: "problem", params: { problemID: displayId } }).href
|
||||
return router.resolve({ name: 'problem', params: { problemID: displayId } }).href
|
||||
}
|
||||
|
||||
function pickGrade(value: string) {
|
||||
@@ -127,19 +119,9 @@ watch([grade, compare], load, { immediate: true })
|
||||
<span class="title">年度趋势</span>
|
||||
<span class="grow" />
|
||||
<span class="muted">比</span>
|
||||
<n-select
|
||||
:value="selected || null"
|
||||
:options="gradeOptions"
|
||||
class="grade-select"
|
||||
@update:value="pickGrade"
|
||||
/>
|
||||
<n-select :value="selected || null" :options="gradeOptions" class="grade-select" @update:value="pickGrade" />
|
||||
<span class="muted">和</span>
|
||||
<n-select
|
||||
:value="otherValue"
|
||||
:options="compareOptions"
|
||||
class="compare-select"
|
||||
@update:value="(value: string) => (compare = value)"
|
||||
/>
|
||||
<n-select :value="otherValue" :options="compareOptions" class="compare-select" @update:value="(value: string) => (compare = value)" />
|
||||
</div>
|
||||
|
||||
<n-spin :show="loading">
|
||||
@@ -151,9 +133,7 @@ watch([grade, compare], load, { immediate: true })
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">
|
||||
一次就对<template v-if="data?.days !== null">
|
||||
· 各自开始做题的头 {{ data?.days }} 天</template
|
||||
>
|
||||
一次就对<template v-if="data?.days !== null"> · 各自开始做题的头 {{ data?.days }} 天</template>
|
||||
</div>
|
||||
<div class="value num">
|
||||
{{ summary.now }}%<span class="unit"> / {{ shortOther }} {{ summary.before }}%</span>
|
||||
@@ -165,9 +145,7 @@ watch([grade, compare], load, { immediate: true })
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">比{{ shortOther }}好</div>
|
||||
<div class="value num better-text">
|
||||
{{ better.length }}<span class="unit"> 道</span>
|
||||
</div>
|
||||
<div class="value num better-text">{{ better.length }}<span class="unit"> 道</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,9 +153,7 @@ watch([grade, compare], load, { immediate: true })
|
||||
<div class="row axis">
|
||||
<span class="c-title" />
|
||||
<span class="c-track ticks num">
|
||||
<span v-for="tick in [0, 25, 50, 75, 100]" :key="tick" :style="{ left: `${tick}%` }"
|
||||
>{{ tick }}%</span
|
||||
>
|
||||
<span v-for="tick in [0, 25, 50, 75, 100]" :key="tick" :style="{ left: `${tick}%` }">{{ tick }}%</span>
|
||||
</span>
|
||||
<span class="legend">
|
||||
<span><i class="dot-now" />{{ selected }} 级</span>
|
||||
@@ -210,13 +186,8 @@ watch([grade, compare], load, { immediate: true })
|
||||
<b :class="`${tone(item.delta)}-text`">{{ item.now }}%</b>
|
||||
<span class="muted"> / {{ item.before }}%</span>
|
||||
</span>
|
||||
<span class="c-delta num strong" :class="`${tone(item.delta)}-text`">
|
||||
{{ item.delta > 0 ? "+" : "" }}{{ item.delta }}
|
||||
</span>
|
||||
<span class="c-users muted small num">
|
||||
{{ selected }} 级 {{ item.mine.users }} 人 · {{ shortOther }}
|
||||
{{ item.theirs.users }} 人
|
||||
</span>
|
||||
<span class="c-delta num strong" :class="`${tone(item.delta)}-text`"> {{ item.delta > 0 ? '+' : '' }}{{ item.delta }} </span>
|
||||
<span class="c-users muted small num"> {{ selected }} 级 {{ item.mine.users }} 人 · {{ shortOther }} {{ item.theirs.users }} 人 </span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
|
||||
import download from "utils/download"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { errorCode, errorMessage } from 'utils/api'
|
||||
import { deleteContestProblem, deleteProblem, makeProblemPublic } from 'admin/api'
|
||||
import download from 'utils/download'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
/**
|
||||
* 题目列表一行的操作:编辑、在前台看、下载测试数据、删除直接摆出来(用户说的:不要收进 ⋯)。
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const dialog = useDialog()
|
||||
const emit = defineEmits(["updated"])
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -27,25 +27,25 @@ const message = useMessage()
|
||||
// 转成公开题要想好新题号,也留给电脑,都不摆
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const isContestProblem = computed(() => route.name === "admin contest problem list")
|
||||
const isContestProblem = computed(() => route.name === 'admin contest problem list')
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
const newDisplayID = ref('')
|
||||
|
||||
async function handleDeleteProblem() {
|
||||
try {
|
||||
if (route.name === "admin contest problem list") {
|
||||
if (route.name === 'admin contest problem list') {
|
||||
await deleteContestProblem(props.problemID)
|
||||
} else {
|
||||
await deleteProblem(props.problemID)
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
message.success('删除成功')
|
||||
emit('updated')
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "problem-has-submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
if (errorCode(err) === 'problem-has-submissions') {
|
||||
message.error('这道题有提交之后,就不能被删除')
|
||||
} else {
|
||||
message.error("删除失败")
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,57 +55,57 @@ function downloads() {
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
const name = route.name!.toString().replace("list", "edit")
|
||||
const name = route.name!.toString().replace('list', 'edit')
|
||||
router.push({ name, params: { problemID: props.problemID } })
|
||||
}
|
||||
|
||||
function goCheck() {
|
||||
let data = router.resolve("/problem/" + props.problemDisplayID)
|
||||
if (route.name === "admin contest problem list") {
|
||||
let data = router.resolve('/problem/' + props.problemDisplayID)
|
||||
if (route.name === 'admin contest problem list') {
|
||||
data = router.resolve({
|
||||
name: "contest problem",
|
||||
name: 'contest problem',
|
||||
params: {
|
||||
contestID: route.params.contestID,
|
||||
problemID: props.problemDisplayID,
|
||||
},
|
||||
})
|
||||
}
|
||||
window.open(data.href, "_blank")
|
||||
window.open(data.href, '_blank')
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
dialog.warning({
|
||||
title: `删除 ${props.problemDisplayID}?`,
|
||||
content: "删掉就找不回来了。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
content: '删掉就找不回来了。',
|
||||
positiveText: '删除',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: handleDeleteProblem,
|
||||
})
|
||||
}
|
||||
|
||||
function openMakePublicModal() {
|
||||
newDisplayID.value = ""
|
||||
newDisplayID.value = ''
|
||||
showMakePublicModal.value = true
|
||||
}
|
||||
|
||||
async function handleMakePublic() {
|
||||
if (!newDisplayID.value.trim()) {
|
||||
message.error("请输入新的题目编号")
|
||||
message.error('请输入新的题目编号')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
message.success('已成功转为公开题目(需要手动设置可见)')
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
emit('updated') // 刷新列表
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (errorCode(err) === "already-public") {
|
||||
message.error("该题目已经是公开题目")
|
||||
if (errorCode(err) === 'display-id-exists') {
|
||||
message.error('该题目编号已存在,请使用其他编号')
|
||||
} else if (errorCode(err) === 'already-public') {
|
||||
message.error('该题目已经是公开题目')
|
||||
} else {
|
||||
message.error("转换失败:" + errorMessage(err, "未知错误"))
|
||||
message.error('转换失败:' + errorMessage(err, '未知错误'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,41 +114,19 @@ async function handleMakePublic() {
|
||||
<n-flex :size="6" :wrap="isMobile">
|
||||
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||
<n-button size="small" title="在前台打开这道题" @click="goCheck">前台看</n-button>
|
||||
<n-button v-if="!isMobile" size="small" title="下载测试数据" @click="downloads">
|
||||
测试数据
|
||||
</n-button>
|
||||
<n-button v-if="isContestProblem && !isMobile" size="small" @click="openMakePublicModal">
|
||||
转成公开题
|
||||
</n-button>
|
||||
<n-button v-if="!isMobile" size="small" title="下载测试数据" @click="downloads"> 测试数据 </n-button>
|
||||
<n-button v-if="isContestProblem && !isMobile" size="small" @click="openMakePublicModal"> 转成公开题 </n-button>
|
||||
<span :title="props.triedUsers ? `有 ${props.triedUsers} 人交过,删不掉` : undefined">
|
||||
<n-button
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="!!props.triedUsers"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!!props.triedUsers" @click="confirmDelete"> 删除 </n-button>
|
||||
</span>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showMakePublicModal"
|
||||
preset="card"
|
||||
title="转为公开题目"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-modal v-model:show="showMakePublicModal" preset="card" title="转为公开题目" style="width: 500px">
|
||||
<n-space vertical>
|
||||
<p>将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。</p>
|
||||
<n-form>
|
||||
<n-form-item label="新的题目编号" required>
|
||||
<n-input
|
||||
v-model:value="newDisplayID"
|
||||
placeholder="例如: 1001"
|
||||
clearable
|
||||
@keyup.enter="handleMakePublic"
|
||||
/>
|
||||
<n-input v-model:value="newDisplayID" placeholder="例如: 1001" clearable @keyup.enter="handleMakePublic" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号"> </n-alert>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { addProblemForContest } from "admin/api"
|
||||
import { errorCode, errorMessage } from 'utils/api'
|
||||
import { addProblemForContest } from 'admin/api'
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
@@ -8,22 +8,22 @@ interface Props {
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["added"])
|
||||
const emit = defineEmits(['added'])
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const displayID = ref(props.nextDisplayId || "")
|
||||
const displayID = ref(props.nextDisplayId || '')
|
||||
|
||||
async function addProblem() {
|
||||
if (!displayID.value) return
|
||||
try {
|
||||
await addProblemForContest(props.contestID, props.problemID, displayID.value)
|
||||
emit("added")
|
||||
emit('added')
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
message.error("显示编号重复了,请重新写一个")
|
||||
} else if (errorCode(err) === "contest-ended") {
|
||||
message.error("这场比赛已经结束了,不能添加题目")
|
||||
if (errorCode(err) === 'display-id-exists') {
|
||||
message.error('显示编号重复了,请重新写一个')
|
||||
} else if (errorCode(err) === 'contest-ended') {
|
||||
message.error('这场比赛已经结束了,不能添加题目')
|
||||
} else {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { getLibraryProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemRow } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { getLibraryProblemList } from 'admin/api'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import type { AdminProblemRow } from 'utils/types'
|
||||
import AddButton from './AddButton.vue'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -12,8 +12,8 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:show", value: boolean): void
|
||||
(e: "change"): void
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'change'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -21,23 +21,23 @@ const route = useRoute()
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: "",
|
||||
keyword: '',
|
||||
})
|
||||
const total = ref(0)
|
||||
const problems = shallowRef<AdminProblemRow[]>([])
|
||||
|
||||
const columns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{ title: "编号", key: "_id", width: 80 },
|
||||
{ title: "标题", key: "title" },
|
||||
{ title: '编号', key: '_id', width: 80 },
|
||||
{ title: '标题', key: 'title' },
|
||||
{
|
||||
title: "选项",
|
||||
key: "add",
|
||||
title: '选项',
|
||||
key: 'add',
|
||||
render: (row) =>
|
||||
h(AddButton, {
|
||||
problemID: row.id,
|
||||
contestID: route.params.contestID as string,
|
||||
nextDisplayId: props.nextDisplayId,
|
||||
onAdded: () => emit("change"),
|
||||
onAdded: () => emit('change'),
|
||||
}),
|
||||
width: 60,
|
||||
},
|
||||
@@ -68,14 +68,7 @@ watchDebounced(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<n-modal
|
||||
:mask-closable="false"
|
||||
:show="props.show"
|
||||
preset="card"
|
||||
style="width: 600px"
|
||||
title="从题库中添加"
|
||||
@close="$emit('update:show', false)"
|
||||
>
|
||||
<n-modal :mask-closable="false" :show="props.show" preset="card" style="width: 600px" title="从题库中添加" @close="$emit('update:show', false)">
|
||||
<n-input class="search" v-model:value="query.keyword" clearable placeholder="搜索标题或编号" />
|
||||
<n-data-table :loading="loading" striped :columns="columns" :data="problems" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AST_NODE_TARGETS_BY_LANGUAGE,
|
||||
AST_OPERATOR_TARGETS_BY_LANGUAGE,
|
||||
AST_SUPPORTED_LANGUAGES,
|
||||
} from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { AstRule, AstRules, LANGUAGE } from "utils/types"
|
||||
import { checkAstRules } from "../../api"
|
||||
import type { AstCheckState } from "./editorTypes"
|
||||
import { AST_NODE_TARGETS_BY_LANGUAGE, AST_OPERATOR_TARGETS_BY_LANGUAGE, AST_SUPPORTED_LANGUAGES } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { AstRule, AstRules, LANGUAGE } from 'utils/types'
|
||||
import { checkAstRules } from '../../api'
|
||||
import type { AstCheckState } from './editorTypes'
|
||||
|
||||
interface Props {
|
||||
modelValue: AstRules | null
|
||||
@@ -24,20 +20,16 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: AstRules | null): void
|
||||
(e: 'update:modelValue', value: AstRules | null): void
|
||||
}>()
|
||||
|
||||
// 判题机只认 C / Python,别的语言配了规则也一条都不会跑(judge/ast.ts 的
|
||||
// loadLanguage 返回 null 就直接放行)。原来这里按题目的全部语言开 tab,老师给
|
||||
// C++ 配的规则存得下、题目页也照常显示成「要求」,判题却从不检查。
|
||||
const supportedLanguages = computed(() =>
|
||||
props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)),
|
||||
)
|
||||
const unsupportedLanguages = computed(() =>
|
||||
props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)),
|
||||
)
|
||||
const supportedLanguages = computed(() => props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)))
|
||||
const unsupportedLanguages = computed(() => props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)))
|
||||
|
||||
const activeTab = ref(supportedLanguages.value[0] || "Python")
|
||||
const activeTab = ref(supportedLanguages.value[0] || 'Python')
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
@@ -47,7 +39,7 @@ const theme = useThemeVars()
|
||||
* 规则配错(节点类型对不上)时完全静默:「必须使用 X」永远失败、「不能使用 X」永远通过。
|
||||
* 拿标准答案先跑一遍,标准答案自己都过不了,多半是规则配错了。顺带拿到学生看到的那句话
|
||||
*/
|
||||
const checks = defineModel<AstCheckState>("checks", { default: () => ({}) })
|
||||
const checks = defineModel<AstCheckState>('checks', { default: () => ({}) })
|
||||
|
||||
let generation = 0
|
||||
|
||||
@@ -59,12 +51,12 @@ async function runChecks() {
|
||||
if (!rules.length) continue
|
||||
try {
|
||||
next[lang] = await checkAstRules({
|
||||
language: lang as "C" | "C++" | "Python",
|
||||
code: props.answers[lang] ?? "",
|
||||
language: lang as 'C' | 'C++' | 'Python',
|
||||
code: props.answers[lang] ?? '',
|
||||
rules,
|
||||
})
|
||||
} catch (err) {
|
||||
next[lang] = { error: errorMessage(err, "这组规则检查不了") }
|
||||
next[lang] = { error: errorMessage(err, '这组规则检查不了') }
|
||||
}
|
||||
}
|
||||
// 跑的过程中又改了:这一轮作废,新的那轮另有一次
|
||||
@@ -79,7 +71,7 @@ watchDebounced(() => [props.modelValue, props.answers, supportedLanguages.value]
|
||||
|
||||
function checkOf(lang: string, index: number) {
|
||||
const state = checks.value[lang]
|
||||
if (!state || "error" in state) return null
|
||||
if (!state || 'error' in state) return null
|
||||
return state.rules[index] ?? null
|
||||
}
|
||||
|
||||
@@ -89,39 +81,39 @@ function countOf(lang: string) {
|
||||
|
||||
const ENGINE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "节点检查",
|
||||
type: "group",
|
||||
key: "node_group",
|
||||
label: '节点检查',
|
||||
type: 'group',
|
||||
key: 'node_group',
|
||||
children: [
|
||||
{ label: "必须存在", value: "must_exist_node" },
|
||||
{ label: "不能存在", value: "must_not_exist_node" },
|
||||
{ label: "出现次数", value: "count_node" },
|
||||
{ label: '必须存在', value: 'must_exist_node' },
|
||||
{ label: '不能存在', value: 'must_not_exist_node' },
|
||||
{ label: '出现次数', value: 'count_node' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "函数调用",
|
||||
type: "group",
|
||||
key: "func_group",
|
||||
label: '函数调用',
|
||||
type: 'group',
|
||||
key: 'func_group',
|
||||
children: [
|
||||
{ label: "必须调用函数", value: "must_call_function" },
|
||||
{ label: "不能调用函数", value: "must_not_call_function" },
|
||||
{ label: "函数调用次数", value: "count_function_call" },
|
||||
{ label: '必须调用函数', value: 'must_call_function' },
|
||||
{ label: '不能调用函数', value: 'must_not_call_function' },
|
||||
{ label: '函数调用次数', value: 'count_function_call' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "方法调用",
|
||||
type: "group",
|
||||
key: "method_group",
|
||||
label: '方法调用',
|
||||
type: 'group',
|
||||
key: 'method_group',
|
||||
children: [
|
||||
{ label: "必须调用方法", value: "must_call_method" },
|
||||
{ label: "不能调用方法", value: "must_not_call_method" },
|
||||
{ label: '必须调用方法', value: 'must_call_method' },
|
||||
{ label: '不能调用方法', value: 'must_not_call_method' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "运算符",
|
||||
type: "group",
|
||||
key: "op_group",
|
||||
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
|
||||
label: '运算符',
|
||||
type: 'group',
|
||||
key: 'op_group',
|
||||
children: [{ label: '必须使用运算符', value: 'must_use_operator' }],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -143,11 +135,11 @@ function operatorTargetOptions(lang: string): SelectOption[] {
|
||||
}))
|
||||
}
|
||||
|
||||
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
|
||||
const FUNCTION_ENGINES = ["must_call_function", "must_not_call_function", "count_function_call"]
|
||||
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
|
||||
const OPERATOR_ENGINES = ["must_use_operator"]
|
||||
const COUNT_ENGINES = ["count_node", "count_function_call"]
|
||||
const NODE_ENGINES = ['must_exist_node', 'must_not_exist_node', 'count_node']
|
||||
const FUNCTION_ENGINES = ['must_call_function', 'must_not_call_function', 'count_function_call']
|
||||
const METHOD_ENGINES = ['must_call_method', 'must_not_call_method']
|
||||
const OPERATOR_ENGINES = ['must_use_operator']
|
||||
const COUNT_ENGINES = ['count_node', 'count_function_call']
|
||||
|
||||
function isNodeEngine(engine: string) {
|
||||
return NODE_ENGINES.includes(engine)
|
||||
@@ -166,18 +158,18 @@ function isCountEngine(engine: string) {
|
||||
}
|
||||
|
||||
const COUNT_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: "正好", value: "exact" },
|
||||
{ label: "范围", value: "range" },
|
||||
{ label: '正好', value: 'exact' },
|
||||
{ label: '范围', value: 'range' },
|
||||
]
|
||||
|
||||
function getCountMode(rule: AstRule): "exact" | "range" {
|
||||
return rule.exact !== undefined ? "exact" : "range"
|
||||
function getCountMode(rule: AstRule): 'exact' | 'range' {
|
||||
return rule.exact !== undefined ? 'exact' : 'range'
|
||||
}
|
||||
|
||||
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
|
||||
function updateCountMode(lang: string, index: number, mode: 'exact' | 'range') {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (mode === "exact") {
|
||||
if (mode === 'exact') {
|
||||
rule.exact = rule.min ?? 1
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
@@ -222,7 +214,7 @@ function updateRules(lang: string, rules: AstRule[]) {
|
||||
} else {
|
||||
current[lang] = rules
|
||||
}
|
||||
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
|
||||
emit('update:modelValue', Object.keys(current).length > 0 ? current : null)
|
||||
}
|
||||
|
||||
function getTargetLabel(lang: string, engine: string, target: string): string | undefined {
|
||||
@@ -235,10 +227,10 @@ function getTargetLabel(lang: string, engine: string, target: string): string |
|
||||
function addRule(lang: string) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.push({
|
||||
engine: "must_exist_node",
|
||||
target: "for_loop",
|
||||
label: getTargetLabel(lang, "must_exist_node", "for_loop"),
|
||||
message: "",
|
||||
engine: 'must_exist_node',
|
||||
target: 'for_loop',
|
||||
label: getTargetLabel(lang, 'must_exist_node', 'for_loop'),
|
||||
message: '',
|
||||
})
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
@@ -253,16 +245,16 @@ function updateRule(lang: string, index: number, field: string, value: any) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
|
||||
if (field === "engine") {
|
||||
if (field === 'engine') {
|
||||
rule.engine = value
|
||||
if (isNodeEngine(value)) {
|
||||
rule.target = "for_loop"
|
||||
rule.label = getTargetLabel(lang, value, "for_loop")
|
||||
rule.target = 'for_loop'
|
||||
rule.label = getTargetLabel(lang, value, 'for_loop')
|
||||
} else if (isOperatorEngine(value)) {
|
||||
rule.target = "+"
|
||||
rule.target = '+'
|
||||
delete rule.label
|
||||
} else {
|
||||
rule.target = ""
|
||||
rule.target = ''
|
||||
delete rule.label
|
||||
}
|
||||
delete rule.min
|
||||
@@ -270,18 +262,18 @@ function updateRule(lang: string, index: number, field: string, value: any) {
|
||||
delete rule.exact
|
||||
// 次数引擎不给默认值的话,存下去就是一条恒真规则
|
||||
if (isCountEngine(value)) rule.exact = 1
|
||||
} else if (field === "target") {
|
||||
} else if (field === 'target') {
|
||||
rule.target = value
|
||||
const lbl = getTargetLabel(lang, rule.engine, value)
|
||||
if (lbl) rule.label = lbl
|
||||
else delete rule.label
|
||||
} else if (field === "min") {
|
||||
} else if (field === 'min') {
|
||||
if (value === null || value === undefined) delete rule.min
|
||||
else rule.min = value
|
||||
} else if (field === "max") {
|
||||
} else if (field === 'max') {
|
||||
if (value === null || value === undefined) delete rule.max
|
||||
else rule.max = value
|
||||
} else if (field === "message") {
|
||||
} else if (field === 'message') {
|
||||
rule.message = value
|
||||
}
|
||||
|
||||
@@ -299,13 +291,8 @@ watch(supportedLanguages, (langs) => {
|
||||
<template>
|
||||
<div class="astRules">
|
||||
<div class="head">
|
||||
<span class="label">{{ challenge ? "判定" : "语法要求" }}</span>
|
||||
<div
|
||||
v-if="supportedLanguages.length && !challenge"
|
||||
class="langs"
|
||||
role="tablist"
|
||||
aria-label="语言"
|
||||
>
|
||||
<span class="label">{{ challenge ? '判定' : '语法要求' }}</span>
|
||||
<div v-if="supportedLanguages.length && !challenge" class="langs" role="tablist" aria-label="语言">
|
||||
<button
|
||||
v-for="lang in supportedLanguages"
|
||||
:key="lang"
|
||||
@@ -316,7 +303,7 @@ watch(supportedLanguages, (langs) => {
|
||||
:class="{ on: activeTab === lang }"
|
||||
@click="activeTab = lang"
|
||||
>
|
||||
{{ lang }} · {{ countOf(lang) ? `${countOf(lang)} 条` : "没有" }}
|
||||
{{ lang }} · {{ countOf(lang) ? `${countOf(lang)} 条` : '没有' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
@@ -324,8 +311,7 @@ watch(supportedLanguages, (langs) => {
|
||||
<n-text v-else depth="3" class="note">几条都满足才算做到</n-text>
|
||||
</div>
|
||||
<n-text v-if="unsupportedLanguages.length" depth="3" class="note">
|
||||
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
|
||||
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }})
|
||||
{{ unsupportedLanguages.join('、') }} 检查不了语法要求(判题机只认 {{ AST_SUPPORTED_LANGUAGES.join(' / ') }})
|
||||
</n-text>
|
||||
<n-text v-if="!supportedLanguages.length" depth="3" class="note">先选能交的语言</n-text>
|
||||
<template v-for="lang in supportedLanguages" :key="lang">
|
||||
@@ -418,31 +404,19 @@ watch(supportedLanguages, (langs) => {
|
||||
size="tiny"
|
||||
class="message"
|
||||
:placeholder="
|
||||
checkOf(lang, index)?.description
|
||||
? `学生看到「${checkOf(lang, index)!.description}」,想换个说法写这里`
|
||||
: '学生看到的话(不填就按规则自动写)'
|
||||
checkOf(lang, index)?.description ? `学生看到「${checkOf(lang, index)!.description}」,想换个说法写这里` : '学生看到的话(不填就按规则自动写)'
|
||||
"
|
||||
@update:value="(v: string) => updateRule(lang, index, 'message', v)"
|
||||
/>
|
||||
<template v-if="challenge">
|
||||
<n-text v-if="checkOf(lang, index)?.passed === true" depth="3" class="note">
|
||||
标准答案这样写也满足
|
||||
</n-text>
|
||||
<n-text v-if="checkOf(lang, index)?.passed === true" depth="3" class="note"> 标准答案这样写也满足 </n-text>
|
||||
<n-text v-else-if="checkOf(lang, index)?.passed === false" depth="3" class="note">
|
||||
标准答案不是这样写的{{
|
||||
checkOf(lang, index)!.actual !== undefined
|
||||
? `(数到 ${checkOf(lang, index)!.actual} 次)`
|
||||
: ""
|
||||
}}
|
||||
标准答案不是这样写的{{ checkOf(lang, index)!.actual !== undefined ? `(数到 ${checkOf(lang, index)!.actual} 次)` : '' }}
|
||||
</n-text>
|
||||
</template>
|
||||
<span v-else-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span>
|
||||
<span v-else-if="checkOf(lang, index)?.passed === false" class="bad">
|
||||
✗ 标准答案自己没过{{
|
||||
checkOf(lang, index)!.actual !== undefined
|
||||
? `(数到 ${checkOf(lang, index)!.actual} 次)`
|
||||
: ""
|
||||
}}
|
||||
✗ 标准答案自己没过{{ checkOf(lang, index)!.actual !== undefined ? `(数到 ${checkOf(lang, index)!.actual} 次)` : '' }}
|
||||
</span>
|
||||
<n-text v-else-if="checkOf(lang, index)" depth="3">没有 {{ lang }} 的标准答案</n-text>
|
||||
</div>
|
||||
@@ -472,14 +446,14 @@ watch(supportedLanguages, (langs) => {
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
width: 72px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.langs {
|
||||
display: flex;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -491,17 +465,17 @@ watch(supportedLanguages, (langs) => {
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang + .lang {
|
||||
border-left: 1px solid v-bind("theme.borderColor");
|
||||
border-left: 1px solid v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.lang.on {
|
||||
background-color: rgba(24, 160, 88, 0.12);
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
color: v-bind('theme.primaryColorPressed');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -524,7 +498,7 @@ watch(supportedLanguages, (langs) => {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: 4px;
|
||||
background-color: rgba(128, 128, 128, 0.04);
|
||||
font-size: 13px;
|
||||
@@ -557,17 +531,17 @@ watch(supportedLanguages, (langs) => {
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ruleError {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { batchTagProblems, getTagAdminList } from "admin/api"
|
||||
import type { AdminTag } from 'utils/types'
|
||||
import { batchTagProblems, getTagAdminList } from 'admin/api'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problemIds: number[]
|
||||
action: "add" | "remove"
|
||||
action: 'add' | 'remove'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
'update:show': [value: boolean]
|
||||
done: []
|
||||
}>()
|
||||
|
||||
@@ -20,15 +20,11 @@ const tags = ref<AdminTag[]>([])
|
||||
const selected = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
|
||||
const title = computed(() => (props.action === "add" ? "批量添加标签" : "批量移除标签"))
|
||||
const title = computed(() => (props.action === 'add' ? '批量添加标签' : '批量移除标签'))
|
||||
|
||||
const selectedSet = computed(() => new Set(selected.value))
|
||||
|
||||
const names = computed(() =>
|
||||
props.action === "add"
|
||||
? Array.from(new Set([...selected.value, ...newTags.value]))
|
||||
: selected.value,
|
||||
)
|
||||
const names = computed(() => (props.action === 'add' ? Array.from(new Set([...selected.value, ...newTags.value])) : selected.value))
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selected.value)
|
||||
@@ -43,19 +39,19 @@ async function listTags() {
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!names.value.length) {
|
||||
message.error("请先选择标签")
|
||||
message.error('请先选择标签')
|
||||
return
|
||||
}
|
||||
const res = await batchTagProblems(props.problemIds, names.value, props.action)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
const verb = props.action === 'add' ? '添加' : '移除'
|
||||
message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
|
||||
close()
|
||||
emit("done")
|
||||
emit('done')
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -70,24 +66,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="title"
|
||||
style="width: 600px"
|
||||
:mask-closable="false"
|
||||
@close="close"
|
||||
>
|
||||
<n-modal :show="show" preset="card" :title="title" style="width: 600px" :mask-closable="false" @close="close">
|
||||
<n-flex vertical size="large">
|
||||
<div>已选中 {{ problemIds.length }} 道题目</div>
|
||||
<n-flex size="small">
|
||||
<n-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
checkable
|
||||
:checked="selectedSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
<n-tag v-for="tag in tags" :key="tag.id" checkable :checked="selectedSet.has(tag.name)" @update:checked="toggleTag(tag.name)">
|
||||
{{ tag.name }}({{ tag.problemCount }})
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { caseStatus, type AnswerSource, type CaseRow } from "./caseRows"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { caseStatus, type AnswerSource, type CaseRow } from './caseRows'
|
||||
|
||||
/**
|
||||
* 测试数据一组一行:输入 | 输出 | 当例子(设计稿「编程题出题页」B)。
|
||||
* 有标准答案时输出是跑出来的、只读;没有时(老题)输出手写。
|
||||
*/
|
||||
const rows = defineModel<CaseRow[]>("rows", { required: true })
|
||||
const rows = defineModel<CaseRow[]>('rows', { required: true })
|
||||
const props = defineProps<{
|
||||
source: AnswerSource | null
|
||||
/** 只有例子这一列表的时候(测试数据太大、单独写例子)不显示「当例子」 */
|
||||
@@ -23,14 +23,13 @@ const CLIP_LINES = 6
|
||||
const expanded = ref(new Set<number>())
|
||||
|
||||
function lines(text: string) {
|
||||
return text.split("\n")
|
||||
return text.split('\n')
|
||||
}
|
||||
|
||||
function clipped(row: CaseRow) {
|
||||
const all = lines(row.output)
|
||||
if (expanded.value.has(row.key) || all.length <= CLIP_LINES + 1)
|
||||
return { text: row.output, rest: 0 }
|
||||
return { text: all.slice(0, CLIP_LINES).join("\n"), rest: all.length - CLIP_LINES }
|
||||
if (expanded.value.has(row.key) || all.length <= CLIP_LINES + 1) return { text: row.output, rest: 0 }
|
||||
return { text: all.slice(0, CLIP_LINES).join('\n'), rest: all.length - CLIP_LINES }
|
||||
}
|
||||
|
||||
function toggle(key: number) {
|
||||
@@ -52,17 +51,12 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
<div class="row head">
|
||||
<span></span>
|
||||
<span>输入</span>
|
||||
<span>{{ source ? "输出(答案跑出来的)" : "输出(手写)" }}</span>
|
||||
<span>{{ source ? '输出(答案跑出来的)' : '输出(手写)' }}</span>
|
||||
<span v-if="!examplesOnly">当例子</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.key"
|
||||
class="row"
|
||||
:class="{ bad: status(row) === 'error', changed: status(row) === 'changed' }"
|
||||
>
|
||||
<div v-for="(row, index) in rows" :key="row.key" class="row" :class="{ bad: status(row) === 'error', changed: status(row) === 'changed' }">
|
||||
<b class="no">{{ index + 1 }}</b>
|
||||
<n-input
|
||||
v-model:value="row.input"
|
||||
@@ -86,25 +80,16 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
<div v-if="status(row) === 'error'" class="cell errorCell">{{ row.error }}</div>
|
||||
<template v-else>
|
||||
<pre class="cell" :class="{ stale: status(row) === 'running' }">{{
|
||||
clipped(row).text || (status(row) === "running" ? "在跑……" : "(没有输出)")
|
||||
clipped(row).text || (status(row) === 'running' ? '在跑……' : '(没有输出)')
|
||||
}}</pre>
|
||||
<button v-if="clipped(row).rest" type="button" class="more" @click="toggle(row.key)">
|
||||
⋯ 还有 {{ clipped(row).rest }} 行
|
||||
</button>
|
||||
<button
|
||||
v-else-if="expanded.has(row.key)"
|
||||
type="button"
|
||||
class="more"
|
||||
@click="toggle(row.key)"
|
||||
>
|
||||
收起
|
||||
</button>
|
||||
<button v-if="clipped(row).rest" type="button" class="more" @click="toggle(row.key)">⋯ 还有 {{ clipped(row).rest }} 行</button>
|
||||
<button v-else-if="expanded.has(row.key)" type="button" class="more" @click="toggle(row.key)">收起</button>
|
||||
<div v-if="status(row) === 'changed'" class="was">
|
||||
变了 · 原来存的是
|
||||
<del>{{
|
||||
lines(row.saved ?? "")
|
||||
lines(row.saved ?? '')
|
||||
.slice(0, 3)
|
||||
.join(" ⏎ ") || "(空)"
|
||||
.join(' ⏎ ') || '(空)'
|
||||
}}</del>
|
||||
</div>
|
||||
</template>
|
||||
@@ -123,15 +108,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
<template v-else-if="status(row) === 'error'">✗</template>
|
||||
<template v-else-if="status(row) === 'changed'">●</template>
|
||||
</span>
|
||||
<n-button
|
||||
quaternary
|
||||
size="tiny"
|
||||
class="remove"
|
||||
:aria-label="`删掉第 ${index + 1} 组`"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删掉
|
||||
</n-button>
|
||||
<n-button quaternary size="tiny" class="remove" :aria-label="`删掉第 ${index + 1} 组`" @click="remove(index)"> 删掉 </n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +126,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 7px 4px;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -162,7 +139,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
border-top: 0;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.row.changed {
|
||||
@@ -176,7 +153,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
|
||||
.mono :deep(textarea),
|
||||
.cell {
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
|
||||
font-family: ui-monospace, 'Cascadia Mono', Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -196,7 +173,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
min-height: 26px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
background-color: rgba(128, 128, 128, 0.05);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
@@ -209,8 +186,8 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
.errorCell {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: v-bind("theme.errorColor");
|
||||
border-color: v-bind("theme.errorColorSuppl");
|
||||
color: v-bind('theme.errorColor');
|
||||
border-color: v-bind('theme.errorColorSuppl');
|
||||
background-color: rgba(208, 48, 80, 0.06);
|
||||
}
|
||||
|
||||
@@ -219,7 +196,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -228,11 +205,11 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
}
|
||||
|
||||
.was {
|
||||
color: v-bind("theme.warningColorPressed");
|
||||
color: v-bind('theme.warningColorPressed');
|
||||
}
|
||||
|
||||
.was del {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.example {
|
||||
@@ -245,15 +222,15 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
}
|
||||
|
||||
.mark.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.mark.error {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.mark.changed {
|
||||
color: v-bind("theme.warningColor");
|
||||
color: v-bind('theme.warningColor');
|
||||
}
|
||||
|
||||
.remove {
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
type AstRules,
|
||||
type ChallengeInput,
|
||||
type ChallengeSuggestionsResponse,
|
||||
} from "@oj2/contract"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import { getChallengeSuggestions } from "../../api"
|
||||
import AstRulesEditor from "./AstRulesEditor.vue"
|
||||
} from '@oj2/contract'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { LANGUAGE } from 'utils/types'
|
||||
import { getChallengeSuggestions } from '../../api'
|
||||
import AstRulesEditor from './AstRulesEditor.vue'
|
||||
|
||||
/**
|
||||
* 出题页的「挑战」:做对之后,同一道题加一条写法上的限制再做一遍(设计稿 StkAdmin)。
|
||||
@@ -29,15 +29,13 @@ const props = defineProps<{
|
||||
problemId: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: Record<string, ChallengeInput[]> | null): void
|
||||
(e: 'update:modelValue', value: Record<string, ChallengeInput[]> | null): void
|
||||
}>()
|
||||
|
||||
const languages = computed(() =>
|
||||
props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)),
|
||||
)
|
||||
const tab = ref<string>(languages.value[0] ?? "Python")
|
||||
const languages = computed(() => props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)))
|
||||
const tab = ref<string>(languages.value[0] ?? 'Python')
|
||||
watch(languages, (list) => {
|
||||
if (!list.includes(tab.value as LANGUAGE)) tab.value = list[0] ?? "Python"
|
||||
if (!list.includes(tab.value as LANGUAGE)) tab.value = list[0] ?? 'Python'
|
||||
})
|
||||
|
||||
const list = computed(() => props.modelValue?.[tab.value] ?? [])
|
||||
@@ -46,7 +44,7 @@ function update(next: ChallengeInput[]) {
|
||||
const value = { ...props.modelValue }
|
||||
if (next.length) value[tab.value] = next
|
||||
else delete value[tab.value]
|
||||
emit("update:modelValue", Object.keys(value).length ? value : null)
|
||||
emit('update:modelValue', Object.keys(value).length ? value : null)
|
||||
}
|
||||
|
||||
function patch(index: number, change: Partial<ChallengeInput>) {
|
||||
@@ -57,7 +55,7 @@ function remove(index: number) {
|
||||
update(list.value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function add(item: ChallengeInput = { wording: "", rules: [] }) {
|
||||
function add(item: ChallengeInput = { wording: '', rules: [] }) {
|
||||
update([...list.value, item])
|
||||
}
|
||||
|
||||
@@ -75,19 +73,19 @@ const full = computed(() => list.value.length >= CHALLENGES_PER_LANGUAGE_MAX)
|
||||
|
||||
const suggestions = ref<Record<string, ChallengeSuggestionsResponse>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref("")
|
||||
const error = ref('')
|
||||
|
||||
async function findSuggestions() {
|
||||
if (!props.problemId) return
|
||||
loading.value = true
|
||||
error.value = ""
|
||||
error.value = ''
|
||||
try {
|
||||
suggestions.value = {
|
||||
...suggestions.value,
|
||||
[tab.value]: await getChallengeSuggestions(props.problemId, tab.value),
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = errorMessage(err, "没找成")
|
||||
error.value = errorMessage(err, '没找成')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -98,9 +96,7 @@ const current = computed(() => suggestions.value[tab.value] ?? null)
|
||||
/** 已经加上的不再列 */
|
||||
const shown = computed(() => {
|
||||
const taken = list.value.map((item) => JSON.stringify(item.rules))
|
||||
return (current.value?.suggestions ?? []).filter(
|
||||
(item) => !taken.includes(JSON.stringify(item.rules)),
|
||||
)
|
||||
return (current.value?.suggestions ?? []).filter((item) => !taken.includes(JSON.stringify(item.rules)))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -108,7 +104,7 @@ const shown = computed(() => {
|
||||
<div class="challenge-editor">
|
||||
<n-radio-group v-if="languages.length > 1" v-model:value="tab" size="small">
|
||||
<n-radio-button v-for="lang in languages" :key="lang" :value="lang">
|
||||
{{ lang }}{{ modelValue?.[lang]?.length ? ` ${modelValue[lang]!.length} 条` : "" }}
|
||||
{{ lang }}{{ modelValue?.[lang]?.length ? ` ${modelValue[lang]!.length} 条` : '' }}
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
|
||||
@@ -133,46 +129,26 @@ const shown = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-text v-if="!languages.length" depth="3" class="note">
|
||||
只有 Python、C、C++ 能配挑战,先在「学生怎么写」里勾上其中一种。
|
||||
</n-text>
|
||||
<n-text v-if="!languages.length" depth="3" class="note"> 只有 Python、C、C++ 能配挑战,先在「学生怎么写」里勾上其中一种。 </n-text>
|
||||
<template v-else>
|
||||
<div class="actions">
|
||||
<n-button size="small" :disabled="full" @click="add()">+ 自己写一条</n-button>
|
||||
<n-button
|
||||
v-if="problemId"
|
||||
size="small"
|
||||
:loading="loading"
|
||||
:disabled="full"
|
||||
@click="findSuggestions"
|
||||
>
|
||||
从做对的代码里找
|
||||
</n-button>
|
||||
<n-button v-if="problemId" size="small" :loading="loading" :disabled="full" @click="findSuggestions"> 从做对的代码里找 </n-button>
|
||||
</div>
|
||||
<n-text v-if="error" type="error" class="note">{{ error }}</n-text>
|
||||
|
||||
<div v-if="current" class="suggestions">
|
||||
<n-text depth="3" class="note">
|
||||
{{ current.acCount }} 个人用 {{ tab }} 做对过,下面是有一批人换了种写法也做对的地方
|
||||
</n-text>
|
||||
<n-text depth="3" class="note"> {{ current.acCount }} 个人用 {{ tab }} 做对过,下面是有一批人换了种写法也做对的地方 </n-text>
|
||||
<n-text v-if="!shown.length" depth="3" class="note">没找到别的写法</n-text>
|
||||
<div v-for="item in shown" :key="JSON.stringify(item.rules)" class="suggestion">
|
||||
<div class="suggestionHead">
|
||||
<div class="suggestionText">
|
||||
<b>{{ item.wording }}</b>
|
||||
<n-text depth="3" class="note">
|
||||
{{ item.did }} 人这样做对<template v-if="item.firstShare !== null"
|
||||
>,第一次做对时 {{ item.firstShare }}% 这样写</template
|
||||
>
|
||||
{{ item.did }} 人这样做对<template v-if="item.firstShare !== null">,第一次做对时 {{ item.firstShare }}% 这样写</template>
|
||||
</n-text>
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="full"
|
||||
@click="add({ wording: item.wording, rules: item.rules })"
|
||||
>
|
||||
加上
|
||||
</n-button>
|
||||
<n-button size="small" :disabled="full" @click="add({ wording: item.wording, rules: item.rules })"> 加上 </n-button>
|
||||
</div>
|
||||
<pre class="example">{{ item.example }}</pre>
|
||||
</div>
|
||||
@@ -255,7 +231,7 @@ const shown = computed(() => {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
|
||||
font-family: ui-monospace, 'Cascadia Mono', Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
import { DIFFICULTY, SOURCES } from "utils/constants"
|
||||
import { getTagColor } from "utils/functions"
|
||||
import type { AstRules, BlankProblem, LANGUAGE } from "utils/types"
|
||||
import type { FlowDoc } from "shared/components/FlowchartEditor/model"
|
||||
import type { AstCheckState, FlowchartMode, PreviewTab } from "./editorTypes"
|
||||
import type { TemplateParts } from "./codeTemplate"
|
||||
import { isBlankHtml } from "./problemForm"
|
||||
import Markdown from 'shared/components/Markdown.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useMermaid } from 'shared/composables/useMermaid'
|
||||
import { DIFFICULTY, SOURCES } from 'utils/constants'
|
||||
import { getTagColor } from 'utils/functions'
|
||||
import type { AstRules, BlankProblem, LANGUAGE } from 'utils/types'
|
||||
import type { FlowDoc } from 'shared/components/FlowchartEditor/model'
|
||||
import type { AstCheckState, FlowchartMode, PreviewTab } from './editorTypes'
|
||||
import type { TemplateParts } from './codeTemplate'
|
||||
import { isBlankHtml } from './problemForm'
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
const FlowchartView = defineAsyncComponent(
|
||||
() => import("shared/components/FlowchartEditor/FlowchartView.vue"),
|
||||
)
|
||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||
const FlowchartView = defineAsyncComponent(() => import('shared/components/FlowchartEditor/FlowchartView.vue'))
|
||||
|
||||
/**
|
||||
* 编程题出题页的右栏:学生打开这道题看到的样子(设计稿「编程题出题页」B)。
|
||||
@@ -23,7 +21,7 @@ const FlowchartView = defineAsyncComponent(
|
||||
const props = defineProps<{
|
||||
displayId: string
|
||||
title: string
|
||||
difficulty: BlankProblem["difficulty"]
|
||||
difficulty: BlankProblem['difficulty']
|
||||
description: string
|
||||
inputDescription: string
|
||||
outputDescription: string
|
||||
@@ -43,38 +41,29 @@ const props = defineProps<{
|
||||
flowchartDoc: FlowDoc | null
|
||||
}>()
|
||||
|
||||
const tab = defineModel<PreviewTab>("tab", { required: true })
|
||||
const tab = defineModel<PreviewTab>('tab', { required: true })
|
||||
const emit = defineEmits<{ renderState: [ok: boolean]; collapse: [] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
// ---------------------------------------------------------------- 语法要求
|
||||
|
||||
const KIND_TAG_TYPE = { require: "success", forbid: "error", count: "info" } as const
|
||||
const KIND_TAG_TYPE = { require: 'success', forbid: 'error', count: 'info' } as const
|
||||
|
||||
function requirementsOf(lang: string) {
|
||||
const rules = props.astRules?.[lang] ?? []
|
||||
const state = props.astChecks[lang]
|
||||
return rules.map((rule, i) => ({
|
||||
text:
|
||||
rule.message ||
|
||||
(state && !("error" in state) ? state.rules[i]?.description : "") ||
|
||||
"(规则还没写完)",
|
||||
kind: rule.engine.startsWith("must_not")
|
||||
? ("forbid" as const)
|
||||
: rule.engine.startsWith("count")
|
||||
? ("count" as const)
|
||||
: ("require" as const),
|
||||
text: rule.message || (state && !('error' in state) ? state.rules[i]?.description : '') || '(规则还没写完)',
|
||||
kind: rule.engine.startsWith('must_not') ? ('forbid' as const) : rule.engine.startsWith('count') ? ('count' as const) : ('require' as const),
|
||||
}))
|
||||
}
|
||||
|
||||
const requirementLanguages = computed(() =>
|
||||
props.languages.filter((lang) => (props.astRules?.[lang]?.length ?? 0) > 0),
|
||||
)
|
||||
const requirementLanguages = computed(() => props.languages.filter((lang) => (props.astRules?.[lang]?.length ?? 0) > 0))
|
||||
|
||||
// ---------------------------------------------------------------- 编辑器
|
||||
|
||||
const editorLanguage = ref<LANGUAGE>(props.languages[0] ?? "Python")
|
||||
const editorLanguage = ref<LANGUAGE>(props.languages[0] ?? 'Python')
|
||||
watch(
|
||||
() => props.languages,
|
||||
(langs) => {
|
||||
@@ -92,18 +81,18 @@ const editorCode = computed(() => {
|
||||
|
||||
const referenceDoc = computed(() => (props.flowchartDoc?.steps.length ? props.flowchartDoc : null))
|
||||
|
||||
const container = useTemplateRef<HTMLElement>("chart")
|
||||
const container = useTemplateRef<HTMLElement>('chart')
|
||||
const { renderFlowchart, renderError, renderSuccess } = useMermaid()
|
||||
|
||||
async function render() {
|
||||
await renderFlowchart(container.value, props.mermaidCode)
|
||||
emit("renderState", renderSuccess.value)
|
||||
emit('renderState', renderSuccess.value)
|
||||
}
|
||||
|
||||
// 改了先报「没验证」,防抖之后的渲染跑完再报结果(和 MermaidEditor 同一个理由)
|
||||
watch(
|
||||
() => props.mermaidCode,
|
||||
() => emit("renderState", false),
|
||||
() => emit('renderState', false),
|
||||
)
|
||||
watchDebounced(() => props.mermaidCode, render, { debounce: 300, maxWait: 1000 })
|
||||
onMounted(() => nextTick(render))
|
||||
@@ -117,20 +106,16 @@ onMounted(() => nextTick(render))
|
||||
<n-radio-group v-model:value="tab" size="small">
|
||||
<n-radio-button value="statement">题面</n-radio-button>
|
||||
<n-radio-button value="editor">编辑器</n-radio-button>
|
||||
<n-radio-button value="flowchart" :class="{ dim: flowchartMode === 'none' }">
|
||||
流程图
|
||||
</n-radio-button>
|
||||
<n-radio-button value="flowchart" :class="{ dim: flowchartMode === 'none' }"> 流程图 </n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="emit('collapse')">
|
||||
收起 ›
|
||||
</n-button>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="emit('collapse')"> 收起 › </n-button>
|
||||
</div>
|
||||
|
||||
<!-- 题面 -->
|
||||
<div v-show="tab === 'statement'" class="page">
|
||||
<div class="titleRow">
|
||||
<span v-if="displayId" class="pid">{{ displayId }}</span>
|
||||
<h2>{{ title || "(题目还没起名)" }}</h2>
|
||||
<h2>{{ title || '(题目还没起名)' }}</h2>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<n-tag size="small" :bordered="false" :type="getTagColor(difficulty)">
|
||||
@@ -144,13 +129,7 @@ onMounted(() => nextTick(render))
|
||||
<span v-if="requirementLanguages.length > 1 || languages.length > 1" class="langLabel">
|
||||
{{ lang }}
|
||||
</span>
|
||||
<n-tag
|
||||
v-for="(rule, i) in requirementsOf(lang)"
|
||||
:key="`${lang}-${i}`"
|
||||
:type="KIND_TAG_TYPE[rule.kind]"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
<n-tag v-for="(rule, i) in requirementsOf(lang)" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false">
|
||||
{{ rule.text }}
|
||||
</n-tag>
|
||||
</template>
|
||||
@@ -188,7 +167,7 @@ onMounted(() => nextTick(render))
|
||||
</template>
|
||||
</div>
|
||||
<p v-else class="placeholder">
|
||||
{{ bigData ? "在左边加一个例子" : "在左边的测试数据里勾几组「当例子」" }}
|
||||
{{ bigData ? '在左边加一个例子' : '在左边的测试数据里勾几组「当例子」' }}
|
||||
</p>
|
||||
|
||||
<template v-if="flowchartMode === 'show'">
|
||||
@@ -217,40 +196,23 @@ onMounted(() => nextTick(render))
|
||||
</div>
|
||||
<div v-if="(astRules?.[editorLanguage]?.length ?? 0) > 0" class="requirements">
|
||||
<span class="requirementsHead">语法要求</span>
|
||||
<n-tag
|
||||
v-for="(rule, i) in requirementsOf(editorLanguage)"
|
||||
:key="i"
|
||||
:type="KIND_TAG_TYPE[rule.kind]"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
<n-tag v-for="(rule, i) in requirementsOf(editorLanguage)" :key="i" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false">
|
||||
{{ rule.text }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
{{
|
||||
editorParts?.template.trim()
|
||||
? "学生打开编辑器时里面已经有这些:"
|
||||
? '学生打开编辑器时里面已经有这些:'
|
||||
: SOURCES[editorLanguage]
|
||||
? `没写 ${editorLanguage} 的预制代码,学生打开是全站默认的:`
|
||||
: `没写 ${editorLanguage} 的预制代码,学生打开是空白的`
|
||||
}}
|
||||
</p>
|
||||
<CodeEditor
|
||||
v-if="editorCode"
|
||||
:value="editorCode"
|
||||
:language="editorLanguage"
|
||||
:font-size="14"
|
||||
height="320px"
|
||||
readonly
|
||||
/>
|
||||
<CodeEditor v-if="editorCode" :value="editorCode" :language="editorLanguage" :font-size="14" height="320px" readonly />
|
||||
<p v-if="editorParts?.prepend.trim() || editorParts?.append.trim()" class="muted small">
|
||||
提交时,学生的代码{{
|
||||
editorParts?.prepend.trim() && editorParts?.append.trim()
|
||||
? "前后"
|
||||
: editorParts?.prepend.trim()
|
||||
? "前面"
|
||||
: "后面"
|
||||
editorParts?.prepend.trim() && editorParts?.append.trim() ? '前后' : editorParts?.prepend.trim() ? '前面' : '后面'
|
||||
}}还会拼上你藏起来的代码。
|
||||
</p>
|
||||
</div>
|
||||
@@ -258,9 +220,7 @@ onMounted(() => nextTick(render))
|
||||
<!-- 流程图:一直挂着,改了图的代码随时知道画不画得出来 -->
|
||||
<div v-show="tab === 'flowchart'" class="page">
|
||||
<p v-if="flowchartMode === 'none'" class="placeholder">这道题不用流程图</p>
|
||||
<p v-else-if="flowchartMode === 'draw'" class="placeholder">
|
||||
学生在题目页右边自己画,交上去按例子和测试数据评
|
||||
</p>
|
||||
<p v-else-if="flowchartMode === 'draw'" class="placeholder">学生在题目页右边自己画,交上去按例子和测试数据评</p>
|
||||
<template v-else>
|
||||
<p class="muted small">学生在题面例子下面看到的是:</p>
|
||||
<div v-if="referenceDoc" class="reference">
|
||||
@@ -304,10 +264,10 @@ onMounted(() => nextTick(render))
|
||||
.page {
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow: auto;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 18px 22px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -326,7 +286,7 @@ onMounted(() => nextTick(render))
|
||||
}
|
||||
|
||||
.pid {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
/* 和题目页的小节标题一个样子(oj/problem/components/ProblemContent.vue) */
|
||||
@@ -336,7 +296,7 @@ onMounted(() => nextTick(render))
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
padding-left: 9px;
|
||||
border-left: 3px solid v-bind("theme.primaryColor");
|
||||
border-left: 3px solid v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.io {
|
||||
@@ -363,7 +323,7 @@ onMounted(() => nextTick(render))
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -374,7 +334,7 @@ onMounted(() => nextTick(render))
|
||||
|
||||
.placeholder {
|
||||
margin: 0;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
/* 参考流程图:和学生题面(ProblemContent.vue)同一种浅灰底 */
|
||||
@@ -398,7 +358,7 @@ onMounted(() => nextTick(render))
|
||||
.sampleLabel,
|
||||
.sampleNo {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.sampleNo {
|
||||
@@ -412,7 +372,7 @@ onMounted(() => nextTick(render))
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
|
||||
font-family: ui-monospace, 'Cascadia Mono', Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
@@ -432,7 +392,7 @@ onMounted(() => nextTick(render))
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
background-color: rgba(208, 48, 80, 0.08);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
/**
|
||||
* 「新建题目」先选题型:编程题和 SQL 题是两张不同的出题页,测试数据的格式也不一样,
|
||||
@@ -7,39 +7,33 @@ import { Icon } from "@iconify/vue"
|
||||
* 每项要两行(名字 + 一句话),n-dropdown 的选项是固定行高,放不下,所以自己画
|
||||
*/
|
||||
defineProps<{ label: string; primary?: boolean }>()
|
||||
const emit = defineEmits<{ select: [kind: "code" | "sql"] }>()
|
||||
const emit = defineEmits<{ select: [kind: 'code' | 'sql'] }>()
|
||||
|
||||
const show = ref(false)
|
||||
|
||||
const OPTIONS = [
|
||||
{
|
||||
kind: "code",
|
||||
icon: "ph:code-bold",
|
||||
title: "编程题",
|
||||
note: "C / C++ / Python,有输入输出说明和样例,也能挂流程图、语法要求",
|
||||
kind: 'code',
|
||||
icon: 'ph:code-bold',
|
||||
title: '编程题',
|
||||
note: 'C / C++ / Python,有输入输出说明和样例,也能挂流程图、语法要求',
|
||||
},
|
||||
{
|
||||
kind: "sql",
|
||||
icon: "ph:database-bold",
|
||||
title: "SQL 题",
|
||||
note: "学生写 SQL 查数据库;给几组建表脚本和一条标准答案就行",
|
||||
kind: 'sql',
|
||||
icon: 'ph:database-bold',
|
||||
title: 'SQL 题',
|
||||
note: '学生写 SQL 查数据库;给几组建表脚本和一条标准答案就行',
|
||||
},
|
||||
] as const
|
||||
|
||||
function pick(kind: "code" | "sql") {
|
||||
function pick(kind: 'code' | 'sql') {
|
||||
show.value = false
|
||||
emit("select", kind)
|
||||
emit('select', kind)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popover
|
||||
v-model:show="show"
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
:show-arrow="false"
|
||||
style="padding: 6px; width: 340px"
|
||||
>
|
||||
<n-popover v-model:show="show" trigger="click" placement="bottom-end" :show-arrow="false" style="padding: 6px; width: 340px">
|
||||
<template #trigger>
|
||||
<n-button :type="primary ? 'primary' : 'default'" icon-placement="right">
|
||||
{{ label }}
|
||||
@@ -47,13 +41,7 @@ function pick(kind: "code" | "sql") {
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="menu">
|
||||
<button
|
||||
v-for="option in OPTIONS"
|
||||
:key="option.kind"
|
||||
type="button"
|
||||
class="option"
|
||||
@click="pick(option.kind)"
|
||||
>
|
||||
<button v-for="option in OPTIONS" :key="option.kind" type="button" class="option" @click="pick(option.kind)">
|
||||
<Icon :icon="option.icon" :width="20" class="icon" :class="option.kind" />
|
||||
<span class="text">
|
||||
<b>{{ option.title }}</b>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
|
||||
/**
|
||||
* 出题页右栏「学生看到的样子」收起来之后剩下的那一条:竖着贴在右边,点一下展开。
|
||||
@@ -27,18 +27,18 @@ const theme = useThemeVars()
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor2");
|
||||
background: v-bind('theme.cardColor');
|
||||
color: v-bind('theme.textColor2');
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.strip:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
border-color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.arrow {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { zonedParts } from "utils/functions"
|
||||
import type { StuckProblemDetail, StuckStudent } from "utils/types"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { resultName, useResultColor } from "oj/statistics/results"
|
||||
import { avgTries, rate } from "./stuckMath"
|
||||
import { zonedParts } from 'utils/functions'
|
||||
import type { StuckProblemDetail, StuckStudent } from 'utils/types'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { resultName, useResultColor } from 'oj/statistics/results'
|
||||
import { avgTries, rate } from './stuckMath'
|
||||
|
||||
/**
|
||||
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
||||
@@ -34,14 +34,12 @@ const summary = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const errorTotal = computed(() =>
|
||||
(props.detail.errors ?? []).reduce((sum, row) => sum + row.count, 0),
|
||||
)
|
||||
const errorTotal = computed(() => (props.detail.errors ?? []).reduce((sum, row) => sum + row.count, 0))
|
||||
|
||||
const byClass = computed(() => {
|
||||
const groups = new Map<string, StuckStudent[]>()
|
||||
for (const row of students.value) {
|
||||
const key = row.className ?? ""
|
||||
const key = row.className ?? ''
|
||||
groups.set(key, [...(groups.get(key) ?? []), row])
|
||||
}
|
||||
return [...groups]
|
||||
@@ -62,11 +60,11 @@ const byClass = computed(() => {
|
||||
})
|
||||
|
||||
const BUCKETS = [
|
||||
{ label: "1 次", min: 1, max: 1 },
|
||||
{ label: "2 次", min: 2, max: 2 },
|
||||
{ label: "3 次", min: 3, max: 3 },
|
||||
{ label: "4–5 次", min: 4, max: 5 },
|
||||
{ label: "6 次以上", min: 6, max: Infinity },
|
||||
{ label: '1 次', min: 1, max: 1 },
|
||||
{ label: '2 次', min: 2, max: 2 },
|
||||
{ label: '3 次', min: 3, max: 3 },
|
||||
{ label: '4–5 次', min: 4, max: 5 },
|
||||
{ label: '6 次以上', min: 6, max: Infinity },
|
||||
]
|
||||
const distribution = computed(() => {
|
||||
const solved = students.value.filter((row) => row.solved)
|
||||
@@ -85,9 +83,7 @@ const mostTries = computed(() =>
|
||||
.slice(0, 6),
|
||||
)
|
||||
|
||||
const stuckStudents = computed(() =>
|
||||
students.value.filter((row) => !row.solved).sort((a, b) => b.tries - a.tries),
|
||||
)
|
||||
const stuckStudents = computed(() => students.value.filter((row) => !row.solved).sort((a, b) => b.tries - a.tries))
|
||||
|
||||
const currentYear = (() => {
|
||||
const now = zonedParts(new Date())!
|
||||
@@ -103,8 +99,8 @@ const history = computed(() =>
|
||||
)
|
||||
|
||||
function gap(minutes: number | null) {
|
||||
if (minutes === null) return ""
|
||||
if (minutes < 1) return "不到 1 分钟"
|
||||
if (minutes === null) return ''
|
||||
if (minutes < 1) return '不到 1 分钟'
|
||||
if (minutes < 60) return `${minutes} 分钟`
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} 小时`
|
||||
return `隔了 ${Math.round(minutes / 1440)} 天`
|
||||
@@ -119,12 +115,12 @@ const links = computed(() => {
|
||||
if (!row) return null
|
||||
const scope = props.className ? { className: props.className } : {}
|
||||
return {
|
||||
problem: href({ name: "problem", params: { problemID: row.displayId } }),
|
||||
problem: href({ name: 'problem', params: { problemID: row.displayId } }),
|
||||
statistics: href({
|
||||
name: "statistics",
|
||||
name: 'statistics',
|
||||
query: { problem: row.displayId, period: props.period, ...scope },
|
||||
}),
|
||||
submissions: href({ name: "submissions", query: { problem: row.displayId, ...scope } }),
|
||||
submissions: href({ name: 'submissions', query: { problem: row.displayId, ...scope } }),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -156,9 +152,7 @@ const links = computed(() => {
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">还没做对</div>
|
||||
<div class="big num" :class="{ red: summary.stuck }">
|
||||
{{ summary.stuck }}<span class="unit"> 人</span>
|
||||
</div>
|
||||
<div class="big num" :class="{ red: summary.stuck }">{{ summary.stuck }}<span class="unit"> 人</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,11 +196,7 @@ const links = computed(() => {
|
||||
<div class="dist">
|
||||
<div v-for="row in distribution" :key="row.label" class="dcol">
|
||||
<span class="small num strong">{{ row.count }} 人</span>
|
||||
<span
|
||||
class="dbar"
|
||||
:class="{ first: row.min === 1, many: row.min === 6 }"
|
||||
:style="{ height: `${row.height}px` }"
|
||||
/>
|
||||
<span class="dbar" :class="{ first: row.min === 1, many: row.min === 6 }" :style="{ height: `${row.height}px` }" />
|
||||
<span class="muted small">{{ row.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,14 +226,10 @@ const links = computed(() => {
|
||||
</template>
|
||||
|
||||
<div v-if="history.length" class="block">
|
||||
<div class="muted small">历年一次就对{{ className ? "(全站)" : "" }}</div>
|
||||
<div class="muted small">历年一次就对{{ className ? '(全站)' : '' }}</div>
|
||||
<div v-for="row in history" :key="row.label" class="hrow num">
|
||||
<span class="hlabel" :class="{ muted: !row.current }">
|
||||
{{ row.label }}{{ row.current ? " 这学年" : "" }}
|
||||
</span>
|
||||
<span class="track mid" :class="{ past: !row.current }"
|
||||
><span :style="{ width: `${row.rate}%` }"
|
||||
/></span>
|
||||
<span class="hlabel" :class="{ muted: !row.current }"> {{ row.label }}{{ row.current ? ' 这学年' : '' }} </span>
|
||||
<span class="track mid" :class="{ past: !row.current }"><span :style="{ width: `${row.rate}%` }" /></span>
|
||||
<span class="pct" :class="{ strong: row.current }">{{ row.rate }}%</span>
|
||||
<span class="muted small">{{ row.users }} 人</span>
|
||||
</div>
|
||||
@@ -260,17 +246,10 @@ const links = computed(() => {
|
||||
<UserName :username="row.username" class="uname" />
|
||||
<span class="muted num ntries">交了 {{ row.tries }} 次</span>
|
||||
<span class="legend" :style="{ color: resultColor(row.lastResult) }">
|
||||
<i :style="{ background: resultColor(row.lastResult) }" />{{
|
||||
resultName(row.lastResult)
|
||||
}}
|
||||
<i :style="{ background: resultColor(row.lastResult) }" />{{ resultName(row.lastResult) }}
|
||||
</span>
|
||||
<span class="grow" />
|
||||
<a
|
||||
:href="href({ path: `/submission/${row.lastSubmissionId}` })"
|
||||
target="_blank"
|
||||
class="link small"
|
||||
>看代码</a
|
||||
>
|
||||
<a :href="href({ path: `/submission/${row.lastSubmissionId}` })" target="_blank" class="link small">看代码</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemRow } from "utils/types"
|
||||
import { batchTagProblems, getProblemList } from "admin/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import type { AdminProblemRow } from 'utils/types'
|
||||
import { batchTagProblems, getProblemList } from 'admin/api'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
'update:show': [value: boolean]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
@@ -25,34 +25,29 @@ const problems = ref<AdminProblemRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keyword = ref("")
|
||||
const keyword = ref('')
|
||||
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const allColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{ title: "题号", key: "_id", width: 90 },
|
||||
{ title: '题号', key: '_id', width: 90 },
|
||||
{
|
||||
title: "题目",
|
||||
key: "title",
|
||||
title: '题目',
|
||||
key: 'title',
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(NButton, { text: true, type: "primary", onClick: () => goEdit(row) }, () => row.title),
|
||||
render: (row) => h(NButton, { text: true, type: 'primary', onClick: () => goEdit(row) }, () => row.title),
|
||||
},
|
||||
{
|
||||
title: "学生看得到",
|
||||
key: "visible",
|
||||
title: '学生看得到',
|
||||
key: 'visible',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NTag, { size: "small", bordered: false, type: row.visible ? "success" : "default" }, () =>
|
||||
row.visible ? "看得到" : "藏着",
|
||||
),
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false, type: row.visible ? 'success' : 'default' }, () => (row.visible ? '看得到' : '藏着')),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (row) =>
|
||||
h(NButton, { size: "small", quaternary: true, onClick: () => removeTag(row) }, () => "摘掉"),
|
||||
render: (row) => h(NButton, { size: 'small', quaternary: true, onClick: () => removeTag(row) }, () => '摘掉'),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -69,27 +64,25 @@ const { loading, track } = useListLoader()
|
||||
async function listProblems() {
|
||||
if (page.value < 1) page.value = 1
|
||||
const offset = (page.value - 1) * limit.value
|
||||
const res = await track(
|
||||
getProblemList(offset, limit.value, keyword.value, "", undefined, props.tagId),
|
||||
)
|
||||
const res = await track(getProblemList(offset, limit.value, keyword.value, '', undefined, props.tagId))
|
||||
if (!res) return
|
||||
problems.value = res.results
|
||||
total.value = res.total
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function goEdit(row: AdminProblemRow) {
|
||||
close()
|
||||
router.push({ name: "admin problem edit", params: { problemID: row.id } })
|
||||
router.push({ name: 'admin problem edit', params: { problemID: row.id } })
|
||||
}
|
||||
|
||||
async function removeTag(row: AdminProblemRow) {
|
||||
await batchTagProblems([row.id], [props.tagName], "remove")
|
||||
await batchTagProblems([row.id], [props.tagName], 'remove')
|
||||
message.success(`已移除「${row.title}」的标签`)
|
||||
emit("changed")
|
||||
emit('changed')
|
||||
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
|
||||
if (problems.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
@@ -107,7 +100,7 @@ watch(
|
||||
(show) => {
|
||||
if (!show) return
|
||||
page.value = 1
|
||||
keyword.value = ""
|
||||
keyword.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
@@ -123,22 +116,11 @@ watchDebounced(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`标签「${tagName}」下的题目`"
|
||||
style="width: 720px"
|
||||
@close="close"
|
||||
>
|
||||
<n-modal :show="show" preset="card" :title="`标签「${tagName}」下的题目`" style="width: 720px" @close="close">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<span>共 {{ total }} 道题</span>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
:style="{ width: isMobile ? '160px' : '220px' }"
|
||||
placeholder="题号或题目名"
|
||||
clearable
|
||||
/>
|
||||
<n-input v-model:value="keyword" :style="{ width: isMobile ? '160px' : '220px' }" placeholder="题号或题目名" clearable />
|
||||
</n-flex>
|
||||
<n-data-table :loading="loading" striped :columns="columns" :data="problems" />
|
||||
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import {
|
||||
TEST_CASE_EDIT_MAX_CASES,
|
||||
TEST_CASE_EDIT_MAX_FILE_BYTES,
|
||||
TEST_CASE_MAX_CASES,
|
||||
} from "@oj2/contract"
|
||||
import type { RunnableLanguage } from "@oj2/contract"
|
||||
import { strFromU8, unzipSync } from "fflate"
|
||||
import type { Ref } from "vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import { trialRun } from "utils/judge"
|
||||
import { TEST_CASE_EDIT_MAX_CASES, TEST_CASE_EDIT_MAX_FILE_BYTES, TEST_CASE_MAX_CASES } from '@oj2/contract'
|
||||
import type { RunnableLanguage } from '@oj2/contract'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
import type { Ref } from 'vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
|
||||
import { trialRun } from 'utils/judge'
|
||||
|
||||
/**
|
||||
* 编程题出题页的测试数据(设计稿「编程题出题页」B):一组一行,输出由标准答案在判题机上
|
||||
@@ -37,19 +33,19 @@ export interface AnswerSource {
|
||||
code: string
|
||||
}
|
||||
|
||||
export type CaseStatus = "manual" | "empty" | "running" | "error" | "changed" | "ok"
|
||||
export type CaseStatus = 'manual' | 'empty' | 'running' | 'error' | 'changed' | 'ok'
|
||||
|
||||
let nextKey = 0
|
||||
|
||||
export function newRow(input = "", output = "", example = false, saved: string | null = null) {
|
||||
export function newRow(input = '', output = '', example = false, saved: string | null = null) {
|
||||
return {
|
||||
key: nextKey++,
|
||||
input,
|
||||
output,
|
||||
example,
|
||||
saved,
|
||||
ranFor: "",
|
||||
error: "",
|
||||
ranFor: '',
|
||||
error: '',
|
||||
} satisfies CaseRow
|
||||
}
|
||||
|
||||
@@ -59,32 +55,31 @@ export function newRow(input = "", output = "", example = false, saved: string |
|
||||
*/
|
||||
export function normalizeCase(input: string, output: string) {
|
||||
return {
|
||||
input: input.replace(/\r\n/g, "\n").replace(/\n$/, ""),
|
||||
output: output.replace(/\r\n/g, "\n").trimEnd(),
|
||||
input: input.replace(/\r\n/g, '\n').replace(/\n$/, ''),
|
||||
output: output.replace(/\r\n/g, '\n').trimEnd(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 判题机比对时去掉的是整段输出末尾的空白(见 services/test-case.ts 的 rstrip),这里同一个口径 */
|
||||
export function sameOutput(a: string, b: string) {
|
||||
return a.replace(/\r\n/g, "\n").trimEnd() === b.replace(/\r\n/g, "\n").trimEnd()
|
||||
return a.replace(/\r\n/g, '\n').trimEnd() === b.replace(/\r\n/g, '\n').trimEnd()
|
||||
}
|
||||
|
||||
const signatureOf = (source: AnswerSource, row: CaseRow) =>
|
||||
JSON.stringify([source.language, source.code, row.input])
|
||||
const signatureOf = (source: AnswerSource, row: CaseRow) => JSON.stringify([source.language, source.code, row.input])
|
||||
|
||||
export function caseStatus(row: CaseRow, source: AnswerSource | null): CaseStatus {
|
||||
if (!source) return "manual"
|
||||
if (row.ranFor !== signatureOf(source, row)) return "running"
|
||||
if (row.error) return "error"
|
||||
if (row.saved !== null && !sameOutput(row.saved, row.output)) return "changed"
|
||||
return "ok"
|
||||
if (!source) return 'manual'
|
||||
if (row.ranFor !== signatureOf(source, row)) return 'running'
|
||||
if (row.error) return 'error'
|
||||
if (row.saved !== null && !sameOutput(row.saved, row.output)) return 'changed'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/** 答案报错时给老师看的一句话:状态 + 输出的最后一行(Python 的异常原文就在那) */
|
||||
function describeFailure(result: SubmissionStatus, output: string, line?: number | null) {
|
||||
const name = JUDGE_STATUS[result]?.name ?? "没跑通"
|
||||
const last = output.trimEnd().split("\n").at(-1)?.trim()
|
||||
const where = line ? `(第 ${line} 行)` : ""
|
||||
const name = JUDGE_STATUS[result]?.name ?? '没跑通'
|
||||
const last = output.trimEnd().split('\n').at(-1)?.trim()
|
||||
const where = line ? `(第 ${line} 行)` : ''
|
||||
return last ? `${name}:${last}${where}` : `${name}${where}`
|
||||
}
|
||||
|
||||
@@ -118,26 +113,26 @@ export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | n
|
||||
})
|
||||
batch.forEach((row, i) => {
|
||||
row.ranFor = signatures[i]!
|
||||
if (response.status === "compile-error") {
|
||||
row.output = ""
|
||||
row.error = `标准答案编译不过:${response.message.trim().split("\n").at(-1) ?? ""}`
|
||||
} else if (response.status === "too-much-output") {
|
||||
row.output = ""
|
||||
row.error = "输出太多(多半是死循环在不停打印)"
|
||||
if (response.status === 'compile-error') {
|
||||
row.output = ''
|
||||
row.error = `标准答案编译不过:${response.message.trim().split('\n').at(-1) ?? ''}`
|
||||
} else if (response.status === 'too-much-output') {
|
||||
row.output = ''
|
||||
row.error = '输出太多(多半是死循环在不停打印)'
|
||||
} else {
|
||||
const run = response.cases[i]!
|
||||
if (run.result === SubmissionStatus.accepted) {
|
||||
row.output = run.output.trimEnd()
|
||||
row.error = ""
|
||||
row.error = ''
|
||||
} else {
|
||||
row.output = ""
|
||||
row.output = ''
|
||||
row.error = describeFailure(run.result, run.output, run.runtimeError?.line)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
// 判题机忙、连不上:这一批记成没跑通,别无限重试;老师点「重跑」再来
|
||||
const reason = errorMessage(err, "判题机暂时连不上")
|
||||
const reason = errorMessage(err, '判题机暂时连不上')
|
||||
batch.forEach((row, i) => {
|
||||
row.ranFor = signatures[i]!
|
||||
row.error = reason
|
||||
@@ -156,7 +151,7 @@ export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | n
|
||||
/** 「重跑」:把没跑通的几组作废重来 */
|
||||
function rerunFailed() {
|
||||
rows.value.forEach((row) => {
|
||||
if (row.error) row.ranFor = ""
|
||||
if (row.error) row.ranFor = ''
|
||||
})
|
||||
void runAll()
|
||||
}
|
||||
@@ -168,44 +163,37 @@ export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | n
|
||||
* 导入 zip:在浏览器里拆成一组组。和后端 collectPairs 同一个规矩 —— 从 1.in / 1.out
|
||||
* 开始连续编号,断了就停。太大(见契约 TEST_CASE_EDIT_*)就不拆,交给调用方直接上传。
|
||||
*/
|
||||
export function readCaseZip(
|
||||
archive: Uint8Array,
|
||||
): { ok: true; cases: { input: string; output: string }[] } | { ok: false; reason: string } {
|
||||
export function readCaseZip(archive: Uint8Array): { ok: true; cases: { input: string; output: string }[] } | { ok: false; reason: string } {
|
||||
let files: Record<string, Uint8Array>
|
||||
try {
|
||||
files = unzipSync(archive, { filter: (file) => /^\d+\.(in|out)$/.test(file.name) })
|
||||
} catch {
|
||||
return { ok: false, reason: "压缩包损坏,或者不是 zip" }
|
||||
return { ok: false, reason: '压缩包损坏,或者不是 zip' }
|
||||
}
|
||||
const cases: { input: string; output: string }[] = []
|
||||
for (let i = 1; files[`${i}.in`] && files[`${i}.out`]; i += 1) {
|
||||
const input = files[`${i}.in`]!
|
||||
const output = files[`${i}.out`]!
|
||||
if (
|
||||
input.length > TEST_CASE_EDIT_MAX_FILE_BYTES ||
|
||||
output.length > TEST_CASE_EDIT_MAX_FILE_BYTES
|
||||
)
|
||||
return { ok: false, reason: "too-large" }
|
||||
if (input.length > TEST_CASE_EDIT_MAX_FILE_BYTES || output.length > TEST_CASE_EDIT_MAX_FILE_BYTES) return { ok: false, reason: 'too-large' }
|
||||
cases.push(normalizeCase(strFromU8(input), strFromU8(output)))
|
||||
}
|
||||
if (!cases.length)
|
||||
return { ok: false, reason: "压缩包里没有从 1.in / 1.out 开始连续编号的测试点" }
|
||||
if (!cases.length) return { ok: false, reason: '压缩包里没有从 1.in / 1.out 开始连续编号的测试点' }
|
||||
if (cases.length > TEST_CASE_MAX_CASES)
|
||||
return {
|
||||
ok: false,
|
||||
reason: `测试数据最多 ${TEST_CASE_MAX_CASES} 组,这个压缩包里有 ${cases.length} 组,挑有代表性的留下`,
|
||||
}
|
||||
if (cases.length > TEST_CASE_EDIT_MAX_CASES) return { ok: false, reason: "too-large" }
|
||||
if (cases.length > TEST_CASE_EDIT_MAX_CASES) return { ok: false, reason: 'too-large' }
|
||||
return { ok: true, cases }
|
||||
}
|
||||
|
||||
/** 打包上传前的文件内容:CRLF 统一成 LF,非空输入补结尾换行(C 的 scanf / gets 读最后一行更稳) */
|
||||
export function caseFiles(rows: { input: string; output: string }[]) {
|
||||
const lf = (text: string) => text.replace(/\r\n/g, "\n")
|
||||
const lf = (text: string) => text.replace(/\r\n/g, '\n')
|
||||
return rows.flatMap((row, i) => {
|
||||
const input = lf(row.input)
|
||||
return [
|
||||
{ name: `${i + 1}.in`, content: input && !input.endsWith("\n") ? `${input}\n` : input },
|
||||
{ name: `${i + 1}.in`, content: input && !input.endsWith('\n') ? `${input}\n` : input },
|
||||
{ name: `${i + 1}.out`, content: lf(row.output) },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -14,18 +14,17 @@ export interface TemplateParts {
|
||||
append: string
|
||||
}
|
||||
|
||||
const section = (raw: string, name: string) =>
|
||||
raw.match(new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`))?.[1] ?? ""
|
||||
const section = (raw: string, name: string) => raw.match(new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`))?.[1] ?? ''
|
||||
|
||||
/** 段落末尾那个换行是标记格式要的,不是内容,显示时去掉 */
|
||||
const strip = (text: string) => text.replace(/\n$/, "")
|
||||
const strip = (text: string) => text.replace(/\n$/, '')
|
||||
|
||||
export function parseTemplate(raw: string | undefined): TemplateParts {
|
||||
if (!raw) return { prepend: "", template: "", append: "" }
|
||||
if (!raw) return { prepend: '', template: '', append: '' }
|
||||
return {
|
||||
prepend: strip(section(raw, "PREPEND")),
|
||||
template: strip(section(raw, "TEMPLATE")),
|
||||
append: strip(section(raw, "APPEND")),
|
||||
prepend: strip(section(raw, 'PREPEND')),
|
||||
template: strip(section(raw, 'TEMPLATE')),
|
||||
append: strip(section(raw, 'APPEND')),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +39,7 @@ export function buildTemplate(parts: TemplateParts): string | null {
|
||||
`//PREPEND BEGIN\n${body(parts.prepend)}//PREPEND END`,
|
||||
`//TEMPLATE BEGIN\n${body(parts.template)}//TEMPLATE END`,
|
||||
`//APPEND BEGIN\n${body(parts.append)}//APPEND END`,
|
||||
].join("\n\n")
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
/** 跑标准答案时套上前后两段,和判题时(routes/trial-run.ts、judgeSubmission)一个拼法 */
|
||||
@@ -51,5 +50,5 @@ export function wrapWithTemplate(code: string, parts: TemplateParts) {
|
||||
|
||||
/** 一段内容:非空时统一以一个换行结尾 */
|
||||
function body(text: string) {
|
||||
return text.trim() ? `${text.replace(/\n*$/, "")}\n` : ""
|
||||
return text.trim() ? `${text.replace(/\n*$/, '')}\n` : ''
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { AstCheckResponse } from "@oj2/contract"
|
||||
import type { AstCheckResponse } from '@oj2/contract'
|
||||
|
||||
/**
|
||||
* 出题页里每种语言的语法要求自测结果:每条规则学生看到的那句话 + 标准答案过不过。
|
||||
* 规则本身配得不对(后端 astRulesError)时是一句错误。右边预览和保存前的检查都看它
|
||||
*/
|
||||
export type AstCheckState = Record<string, { rules: AstCheckResponse["rules"] } | { error: string }>
|
||||
export type AstCheckState = Record<string, { rules: AstCheckResponse['rules'] } | { error: string }>
|
||||
|
||||
/** 右栏「学生看到的样子」开着哪一页 */
|
||||
export type PreviewTab = "statement" | "editor" | "flowchart"
|
||||
export type PreviewTab = 'statement' | 'editor' | 'flowchart'
|
||||
|
||||
/** 流程图三选一:不用 / 给学生看参考图(showFlowchart)/ 让学生自己画、按这张图打分(allowFlowchart) */
|
||||
export type FlowchartMode = "none" | "show" | "draw"
|
||||
export type FlowchartMode = 'none' | 'show' | 'draw'
|
||||
@@ -3,5 +3,5 @@
|
||||
* 记在本机:老师想专心写的时候收起来,下次打开还是收着的
|
||||
*/
|
||||
export function usePreviewCollapsed() {
|
||||
return useStorage("oj2:admin-problem-preview-collapsed", false)
|
||||
return useStorage('oj2:admin-problem-preview-collapsed', false)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
|
||||
import { StorageSerializers, useLocalStorage, watchDebounced } from "@vueuse/core"
|
||||
import type { WatchSource } from "vue"
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { unique } from "utils/functions"
|
||||
import { PROBLEM_TAG_MAX_LENGTH } from '@oj2/contract'
|
||||
import { StorageSerializers, useLocalStorage, watchDebounced } from '@vueuse/core'
|
||||
import type { WatchSource } from 'vue'
|
||||
import { errorCode, errorMessage } from 'utils/api'
|
||||
import { DIFFICULTY } from 'utils/constants'
|
||||
import { unique } from 'utils/functions'
|
||||
|
||||
/**
|
||||
* 编程题(detail.vue)、SQL 题(SqlDetail.vue)两个出题页共用的零件。两页要填的东西几乎
|
||||
@@ -11,23 +11,21 @@ import { unique } from "utils/functions"
|
||||
*/
|
||||
|
||||
/** 富文本编辑器清空之后留下的是一个空段落,不是空串 */
|
||||
export const isBlankHtml = (html: string) => !html.trim() || html === "<p><br></p>"
|
||||
export const isBlankHtml = (html: string) => !html.trim() || html === '<p><br></p>'
|
||||
|
||||
export const difficultyOptions = (["Low", "Mid", "High"] as const).map((value) => ({
|
||||
export const difficultyOptions = (['Low', 'Mid', 'High'] as const).map((value) => ({
|
||||
label: DIFFICULTY[value],
|
||||
value,
|
||||
}))
|
||||
|
||||
/** 出题页的「返回」:比赛题回这场比赛的题目列表,公开题回题库 */
|
||||
export function problemListRoute(contestID: string | undefined) {
|
||||
return contestID
|
||||
? { name: "admin contest problem list", params: { contestID } }
|
||||
: { name: "admin problem list" }
|
||||
return contestID ? { name: 'admin contest problem list', params: { contestID } } : { name: 'admin problem list' }
|
||||
}
|
||||
|
||||
/** 保存失败给老师看的话。编号撞了是最常见的一种,单独说清楚 */
|
||||
export function saveErrorText(err: unknown) {
|
||||
return errorCode(err) === "display-id-exists" ? "显示编号重复了,换一个" : errorMessage(err)
|
||||
return errorCode(err) === 'display-id-exists' ? '显示编号重复了,换一个' : errorMessage(err)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,10 +46,7 @@ export const tagTooLongText = (tag: string) => `标签最多 ${PROBLEM_TAG_MAX_L
|
||||
*
|
||||
* `enabled` 在页面数据摆好之前要返回 false,不然加载时那一下会把空表单存进去。
|
||||
*/
|
||||
export function useCreateDraft<T>(
|
||||
key: string,
|
||||
options: { source: WatchSource; enabled: () => boolean; empty: () => boolean; take: () => T },
|
||||
) {
|
||||
export function useCreateDraft<T>(key: string, options: { source: WatchSource; enabled: () => boolean; empty: () => boolean; take: () => T }) {
|
||||
// 初值是 null 时 VueUse 推不出类型,会拿 String() 存成 "[object Object]",得显式指定
|
||||
const draft = useLocalStorage<T | null>(key, null, { serializer: StorageSerializers.object })
|
||||
watchDebounced(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SqlDisplay } from "utils/types"
|
||||
import { changedRowCount, tableChanges } from "oj/problem/utils/sqlChanges"
|
||||
import type { SqlDisplay } from 'utils/types'
|
||||
import { changedRowCount, tableChanges } from 'oj/problem/utils/sqlChanges'
|
||||
|
||||
/** 出题页的一组测试数据:一份建表 + 插数据的脚本,和它跑出来的样子 */
|
||||
export interface ScriptGroup {
|
||||
@@ -16,7 +16,7 @@ export interface ScriptGroup {
|
||||
running: string
|
||||
}
|
||||
|
||||
export type GroupStatus = "empty" | "no-answer" | "running" | "ok" | "error"
|
||||
export type GroupStatus = 'empty' | 'no-answer' | 'running' | 'ok' | 'error'
|
||||
|
||||
/**
|
||||
* SQLite 的报错是英文,出题的老师未必看得懂。常见的几种翻成一句中文,
|
||||
@@ -28,17 +28,17 @@ export function explainSqlError(message: string) {
|
||||
[/no such column: (\S+)/, (m) => `没有 ${m[1]} 这一列`],
|
||||
[/table (\S+) already exists/, (m) => `${m[1]} 这张表建了两次`],
|
||||
[/near "([^"]*)": syntax error/, (m) => `语法有错,在「${m[1]}」附近`],
|
||||
[/incomplete input/, () => "语句没写完(少了右括号或分号?)"],
|
||||
[/incomplete input/, () => '语句没写完(少了右括号或分号?)'],
|
||||
[/UNIQUE constraint failed: (\S+)/, (m) => `${m[1]} 有重复的值`],
|
||||
[/(\d+) values for (\d+) columns/, (m) => `一行给了 ${m[1]} 个值,但表有 ${m[2]} 列`],
|
||||
]
|
||||
if (message.includes("标准答案未修改任何表数据")) {
|
||||
return "标准答案在这组数据上一行都没改:第 1 组是题面上给学生看的,里面要有会被改到的行"
|
||||
if (message.includes('标准答案未修改任何表数据')) {
|
||||
return '标准答案在这组数据上一行都没改:第 1 组是题面上给学生看的,里面要有会被改到的行'
|
||||
}
|
||||
for (const [pattern, say] of rules) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const where = message.startsWith("标准答案") ? "标准答案在这组数据上" : "这组的脚本"
|
||||
const where = message.startsWith('标准答案') ? '标准答案在这组数据上' : '这组的脚本'
|
||||
return `${where}跑不起来:${say(match)}`
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,9 @@ export function explainSqlError(message: string) {
|
||||
|
||||
/** 列表里那一行的小结:几张表各几行 → 期望几行 / 改了几行 */
|
||||
export function groupSummary(display: SqlDisplay) {
|
||||
const rows = display.tables.map((t) => t.total_rows).join(" + ")
|
||||
const rows = display.tables.map((t) => t.total_rows).join(' + ')
|
||||
const expected = display.expected
|
||||
if ("columns" in expected) return `${rows} 行 → 期望 ${expected.total_rows} 行`
|
||||
if ('columns' in expected) return `${rows} 行 → 期望 ${expected.total_rows} 行`
|
||||
const changed = expected.changed_tables.reduce((sum, table) => {
|
||||
if (table.dropped) return sum + 1
|
||||
const changes = tableChanges(
|
||||
|
||||
@@ -7,5 +7,5 @@ export function rate(part: number, whole: number) {
|
||||
|
||||
/** 做对的人平均交了几次(只数做对那次及之前的) */
|
||||
export function avgTries(row: { solved: number; solvedTries: number }) {
|
||||
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : "-"
|
||||
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : '-'
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { isSqlProblem } from "@oj2/contract"
|
||||
import type { AdminProblem } from "utils/types"
|
||||
import { getProblem } from "../api"
|
||||
import ProblemDetail from "./detail.vue"
|
||||
import SqlDetail from "./SqlDetail.vue"
|
||||
import { isSqlProblem } from '@oj2/contract'
|
||||
import type { AdminProblem } from 'utils/types'
|
||||
import { getProblem } from '../api'
|
||||
import ProblemDetail from './detail.vue'
|
||||
import SqlDetail from './SqlDetail.vue'
|
||||
|
||||
/**
|
||||
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
||||
@@ -20,25 +20,21 @@ const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const loaded = shallowRef<AdminProblem | null>(null)
|
||||
const kind = ref<"code" | "sql" | null>(null)
|
||||
const kind = ref<'code' | 'sql' | null>(null)
|
||||
|
||||
async function resolve() {
|
||||
kind.value = null
|
||||
loaded.value = null
|
||||
if (!props.problemID) {
|
||||
kind.value = route.query.type === "sql" ? "sql" : "code"
|
||||
kind.value = route.query.type === 'sql' ? 'sql' : 'code'
|
||||
return
|
||||
}
|
||||
try {
|
||||
loaded.value = await getProblem(props.problemID)
|
||||
kind.value = isSqlProblem(loaded.value) ? "sql" : "code"
|
||||
kind.value = isSqlProblem(loaded.value) ? 'sql' : 'code'
|
||||
} catch {
|
||||
message.error("获取题目失败")
|
||||
router.push(
|
||||
props.contestID
|
||||
? { name: "admin contest problem list", params: { contestID: props.contestID } }
|
||||
: { name: "admin problem list" },
|
||||
)
|
||||
message.error('获取题目失败')
|
||||
router.push(props.contestID ? { name: 'admin contest problem list', params: { contestID: props.contestID } } : { name: 'admin problem list' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,18 +42,6 @@ watch(() => [props.problemID, route.query.type], resolve, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SqlDetail
|
||||
v-if="kind === 'sql'"
|
||||
:key="`sql-${problemID ?? 'new'}`"
|
||||
:problem-i-d="problemID"
|
||||
:contest-i-d="contestID"
|
||||
:initial="loaded"
|
||||
/>
|
||||
<ProblemDetail
|
||||
v-else-if="kind === 'code'"
|
||||
:key="`code-${problemID ?? 'new'}`"
|
||||
:problem-i-d="problemID"
|
||||
:contest-i-d="contestID"
|
||||
:initial="loaded"
|
||||
/>
|
||||
<SqlDetail v-if="kind === 'sql'" :key="`sql-${problemID ?? 'new'}`" :problem-i-d="problemID" :contest-i-d="contestID" :initial="loaded" />
|
||||
<ProblemDetail v-else-if="kind === 'code'" :key="`code-${problemID ?? 'new'}`" :problem-i-d="problemID" :contest-i-d="contestID" :initial="loaded" />
|
||||
</template>
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, useThemeVars } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminProblemRow } from "utils/types"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { batchSetProblemVisible, getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import AddFromLibraryModal from "./components/AddFromLibraryModal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "admin/components/AuthorSelect.vue"
|
||||
import type { DataTableRowKey } from "naive-ui"
|
||||
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||
import CreateProblemMenu from "./components/CreateProblemMenu.vue"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { NSwitch, useThemeVars } from 'naive-ui'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { usePagination } from 'shared/composables/pagination'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { AdminProblemRow } from 'utils/types'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { batchSetProblemVisible, getProblemList, toggleProblemVisible } from '../api'
|
||||
import Actions from './components/Actions.vue'
|
||||
import AddFromLibraryModal from './components/AddFromLibraryModal.vue'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import AuthorSelect from 'admin/components/AuthorSelect.vue'
|
||||
import type { DataTableRowKey } from 'naive-ui'
|
||||
import BatchTagModal from './components/BatchTagModal.vue'
|
||||
import CreateProblemMenu from './components/CreateProblemMenu.vue'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
/**
|
||||
* 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、
|
||||
@@ -39,11 +39,11 @@ const { isMobile } = useBreakpoints()
|
||||
const title = computed(
|
||||
() =>
|
||||
({
|
||||
"admin problem list": "题目列表",
|
||||
"admin contest problem list": "比赛题目列表",
|
||||
'admin problem list': '题目列表',
|
||||
'admin contest problem list': '比赛题目列表',
|
||||
})[route.name as string],
|
||||
)
|
||||
const isContestProblemList = computed(() => route.name === "admin contest problem list")
|
||||
const isContestProblemList = computed(() => route.name === 'admin contest problem list')
|
||||
|
||||
const [show, toggleShow] = useToggle()
|
||||
const { count, inc } = useCounter(0)
|
||||
@@ -51,7 +51,7 @@ const total = ref(0)
|
||||
const problems = ref<AdminProblemRow[]>([])
|
||||
|
||||
const selectedRowKeys = ref<DataTableRowKey[]>([])
|
||||
const batchTagAction = ref<"add" | "remove">("add")
|
||||
const batchTagAction = ref<'add' | 'remove'>('add')
|
||||
const [showBatchTag, toggleBatchTag] = useToggle(false)
|
||||
|
||||
const selectedProblemIds = computed(() => selectedRowKeys.value.map((key) => Number(key)))
|
||||
@@ -62,7 +62,7 @@ function chooseProblems(rowKeys: DataTableRowKey[]) {
|
||||
selectedRowKeys.value = rowKeys
|
||||
}
|
||||
|
||||
function openBatchTag(action: "add" | "remove") {
|
||||
function openBatchTag(action: 'add' | 'remove') {
|
||||
batchTagAction.value = action
|
||||
toggleBatchTag(true)
|
||||
}
|
||||
@@ -73,38 +73,38 @@ function onBatchTagDone() {
|
||||
}
|
||||
|
||||
const nextDisplayID = computed(() => {
|
||||
if (!isContestProblemList.value) return ""
|
||||
if (problems.value.length === 0) return "1"
|
||||
if (!isContestProblemList.value) return ''
|
||||
if (problems.value.length === 0) return '1'
|
||||
const ids = problems.value.map((p) => p._id)
|
||||
if (ids.every((id) => /^\d+$/.test(id))) {
|
||||
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
|
||||
}
|
||||
return ""
|
||||
return ''
|
||||
})
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
kind: "" | "code" | "sql"
|
||||
kind: '' | 'code' | 'sql'
|
||||
/** 1 = 学生看得到的,0 = 藏着的 */
|
||||
visible: "" | "1" | "0"
|
||||
visible: '' | '1' | '0'
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
kind: useRouteQuery<ProblemQuery["kind"]>("kind", "").value,
|
||||
visible: useRouteQuery<ProblemQuery["visible"]>("visible", "").value,
|
||||
keyword: useRouteQuery('keyword', '').value,
|
||||
author: useRouteQuery('author', '').value,
|
||||
kind: useRouteQuery<ProblemQuery['kind']>('kind', '').value,
|
||||
visible: useRouteQuery<ProblemQuery['visible']>('visible', '').value,
|
||||
})
|
||||
|
||||
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
|
||||
// 手机上三个按钮放不下、「藏着的」被切掉:换小号,再不够就横着滑
|
||||
const narrow = useMediaQuery("(max-width: 640px)")
|
||||
const narrow = useMediaQuery('(max-width: 640px)')
|
||||
const visibleTabs = computed(() => [
|
||||
{ value: "" as const, label: "全部", count: counts.value?.all },
|
||||
{ value: "1" as const, label: "学生看得到", count: counts.value?.visible },
|
||||
{ value: "0" as const, label: "藏着的", count: counts.value?.hidden },
|
||||
{ value: '' as const, label: '全部', count: counts.value?.all },
|
||||
{ value: '1' as const, label: '学生看得到', count: counts.value?.visible },
|
||||
{ value: '0' as const, label: '藏着的', count: counts.value?.hidden },
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -112,27 +112,25 @@ const visibleTabs = computed(() => [
|
||||
* 列宽只放得下两个,摆两个再加 +N 的话 +N 会被截掉
|
||||
*/
|
||||
function renderTags(tags: string[]) {
|
||||
if (!tags.length) return h("span", { class: "muted" }, "—")
|
||||
if (!tags.length) return h('span', { class: 'muted' }, '—')
|
||||
const shown = tags.length > 2 ? tags.slice(0, 1) : tags
|
||||
return h("span", { class: "tags", title: tags.join("、") }, [
|
||||
...shown.map((tag) => h("span", { class: "tag", key: tag }, tag)),
|
||||
tags.length > shown.length
|
||||
? h("span", { class: "muted more" }, `+${tags.length - shown.length}`)
|
||||
: null,
|
||||
return h('span', { class: 'tags', title: tags.join('、') }, [
|
||||
...shown.map((tag) => h('span', { class: 'tag', key: tag }, tag)),
|
||||
tags.length > shown.length ? h('span', { class: 'muted more' }, `+${tags.length - shown.length}`) : null,
|
||||
])
|
||||
}
|
||||
|
||||
function renderTitle(row: AdminProblemRow) {
|
||||
const kinds = [
|
||||
row.isSql ? h(ProblemTypeTag, { kind: "sql" }) : null,
|
||||
row.allowFlowchart ? h(ProblemTypeTag, { kind: "flowchart" }) : null,
|
||||
row.hasAstRules ? h(ProblemTypeTag, { kind: "ast" }) : null,
|
||||
row.isSql ? h(ProblemTypeTag, { kind: 'sql' }) : null,
|
||||
row.allowFlowchart ? h(ProblemTypeTag, { kind: 'flowchart' }) : null,
|
||||
row.hasAstRules ? h(ProblemTypeTag, { kind: 'ast' }) : null,
|
||||
]
|
||||
return h("span", { class: "title-cell" }, [
|
||||
return h('span', { class: 'title-cell' }, [
|
||||
h(
|
||||
"a",
|
||||
'a',
|
||||
{
|
||||
class: "title-link",
|
||||
class: 'title-link',
|
||||
href: router.resolve(editRoute(row.id)).href,
|
||||
onClick: (event: MouseEvent) => {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
@@ -148,56 +146,53 @@ function renderTitle(row: AdminProblemRow) {
|
||||
|
||||
function editRoute(problemID: number) {
|
||||
return {
|
||||
name: isContestProblemList.value ? "admin contest problem edit" : "admin problem edit",
|
||||
name: isContestProblemList.value ? 'admin contest problem edit' : 'admin problem edit',
|
||||
params: isContestProblemList.value ? { contestID: props.contestID, problemID } : { problemID },
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{
|
||||
title: "题号",
|
||||
key: "_id",
|
||||
title: '题号',
|
||||
key: '_id',
|
||||
width: 70,
|
||||
render: (row) => h("span", { class: "did", title: `内部编号 ${row.id}` }, row._id),
|
||||
render: (row) => h('span', { class: 'did', title: `内部编号 ${row.id}` }, row._id),
|
||||
},
|
||||
// 题目定宽:p90 也就 12 个字加一两个类型标签。原来 minWidth 让它吃掉整行剩余空间(用户嫌空太多),
|
||||
// 剩下的宽度给最后的操作列
|
||||
{ title: "题目", key: "title", width: 196, ellipsis: false, render: renderTitle },
|
||||
{ title: "知识点", key: "tags", width: 158, render: (row) => renderTags(row.tags) },
|
||||
{ title: '题目', key: 'title', width: 196, ellipsis: false, render: renderTitle },
|
||||
{ title: '知识点', key: 'tags', width: 158, render: (row) => renderTags(row.tags) },
|
||||
{
|
||||
title: "做对 / 做过",
|
||||
key: "users",
|
||||
title: '做对 / 做过',
|
||||
key: 'users',
|
||||
width: 96,
|
||||
render: (row) =>
|
||||
row.triedUsers
|
||||
? h("span", { class: "num" }, [
|
||||
String(row.solvedUsers),
|
||||
h("span", { class: "muted" }, ` / ${row.triedUsers}`),
|
||||
])
|
||||
: h("span", { class: "muted" }, "没人做过"),
|
||||
? h('span', { class: 'num' }, [String(row.solvedUsers), h('span', { class: 'muted' }, ` / ${row.triedUsers}`)])
|
||||
: h('span', { class: 'muted' }, '没人做过'),
|
||||
},
|
||||
{ title: "出题人", key: "username", width: 86, ellipsis: { tooltip: true } },
|
||||
{ title: '出题人', key: 'username', width: 86, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "创建",
|
||||
key: "create_time",
|
||||
title: '创建',
|
||||
key: 'create_time',
|
||||
width: 100,
|
||||
render: (row) => h("span", { class: "num muted" }, parseTime(row.createTime, "YYYY-MM-DD")),
|
||||
render: (row) => h('span', { class: 'num muted' }, parseTime(row.createTime, 'YYYY-MM-DD')),
|
||||
},
|
||||
{
|
||||
title: "学生看得到",
|
||||
key: "visible",
|
||||
title: '学生看得到',
|
||||
key: 'visible',
|
||||
width: 96,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
size: 'small',
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
title: '',
|
||||
key: 'actions',
|
||||
minWidth: 240,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
@@ -215,24 +210,18 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
*/
|
||||
function renderMobileCard(row: AdminProblemRow) {
|
||||
const meta = [
|
||||
row.tags.length
|
||||
? row.tags.slice(0, 2).join("、") + (row.tags.length > 2 ? ` +${row.tags.length - 2}` : "")
|
||||
: null,
|
||||
isContestProblemList.value
|
||||
? null
|
||||
: row.triedUsers
|
||||
? `做对 ${row.solvedUsers} / ${row.triedUsers}`
|
||||
: "没人做过",
|
||||
row.tags.length ? row.tags.slice(0, 2).join('、') + (row.tags.length > 2 ? ` +${row.tags.length - 2}` : '') : null,
|
||||
isContestProblemList.value ? null : row.triedUsers ? `做对 ${row.solvedUsers} / ${row.triedUsers}` : '没人做过',
|
||||
row.username,
|
||||
parseTime(row.createTime, "YYYY-MM-DD"),
|
||||
parseTime(row.createTime, 'YYYY-MM-DD'),
|
||||
].filter((it): it is string => !!it)
|
||||
return h("div", { class: "m-card" }, [
|
||||
h("div", { class: "m-head" }, [h("span", { class: "did" }, row._id), renderTitle(row)]),
|
||||
return h('div', { class: 'm-card' }, [
|
||||
h('div', { class: 'm-head' }, [h('span', { class: 'did' }, row._id), renderTitle(row)]),
|
||||
// 每一项自己不断开(「做对 8 / 14」折成两半看不懂),放不下整项换行
|
||||
h(
|
||||
"div",
|
||||
{ class: "m-meta muted" },
|
||||
meta.map((text, i) => h("span", { class: "m-item", key: i }, i ? ` · ${text}` : text)),
|
||||
'div',
|
||||
{ class: 'm-meta muted' },
|
||||
meta.map((text, i) => h('span', { class: 'm-item', key: i }, i ? ` · ${text}` : text)),
|
||||
),
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
@@ -244,12 +233,12 @@ function renderMobileCard(row: AdminProblemRow) {
|
||||
}
|
||||
|
||||
const mobileColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{ title: "题目", key: "title", render: renderMobileCard },
|
||||
{ title: '题目', key: 'title', render: renderMobileCard },
|
||||
{
|
||||
title: "看得到",
|
||||
key: "visible",
|
||||
title: '看得到',
|
||||
key: 'visible',
|
||||
width: 72,
|
||||
align: "center",
|
||||
align: 'center',
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -261,9 +250,7 @@ const mobileColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
|
||||
const columns = computed<DataTableColumn<AdminProblemRow>[]>(() => {
|
||||
const cols = isMobile.value ? mobileColumns : baseColumns
|
||||
return isContestProblemList.value
|
||||
? cols.filter((it) => !("key" in it) || it.key !== "users")
|
||||
: [{ type: "selection" }, ...cols]
|
||||
return isContestProblemList.value ? cols.filter((it) => !('key' in it) || it.key !== 'users') : [{ type: 'selection' }, ...cols]
|
||||
})
|
||||
|
||||
const { loading, track } = useListLoader()
|
||||
@@ -271,16 +258,7 @@ async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await track(
|
||||
getProblemList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.author,
|
||||
props.contestID,
|
||||
undefined,
|
||||
query.kind,
|
||||
props.contestID ? "" : query.visible,
|
||||
),
|
||||
getProblemList(offset, query.limit, query.keyword, query.author, props.contestID, undefined, query.kind, props.contestID ? '' : query.visible),
|
||||
)
|
||||
if (!res) return
|
||||
total.value = res.total
|
||||
@@ -293,7 +271,7 @@ const message = useMessage()
|
||||
async function batchVisible(visible: boolean) {
|
||||
try {
|
||||
const { problemCount } = await batchSetProblemVisible(selectedProblemIds.value, visible)
|
||||
message.success(`${problemCount} 道题${visible ? "学生看得到了" : "藏起来了"}`)
|
||||
message.success(`${problemCount} 道题${visible ? '学生看得到了' : '藏起来了'}`)
|
||||
selectedRowKeys.value = []
|
||||
listProblems()
|
||||
} catch (error) {
|
||||
@@ -321,16 +299,16 @@ async function toggleVisible(problemID: number) {
|
||||
}
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部题型", value: "" },
|
||||
{ label: "编程题", value: "code" },
|
||||
{ label: "SQL 题", value: "sql" },
|
||||
{ label: '全部题型', value: '' },
|
||||
{ label: '编程题', value: 'code' },
|
||||
{ label: 'SQL 题', value: 'sql' },
|
||||
]
|
||||
|
||||
function createProblem(kind: string) {
|
||||
router.push({
|
||||
name: isContestProblemList.value ? "admin contest problem create" : "admin problem create",
|
||||
name: isContestProblemList.value ? 'admin contest problem create' : 'admin problem create',
|
||||
params: isContestProblemList.value ? { contestID: props.contestID } : {},
|
||||
query: kind === "sql" ? { type: "sql" } : {},
|
||||
query: kind === 'sql' ? { type: 'sql' } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -352,10 +330,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="title ?? '题目列表'"
|
||||
:back="isContestProblemList ? { name: 'admin contest list' } : undefined"
|
||||
>
|
||||
<PageHeader :title="title ?? '题目列表'" :back="isContestProblemList ? { name: 'admin contest list' } : undefined">
|
||||
<template #actions>
|
||||
<template v-if="!isContestProblemList">
|
||||
<CreateProblemMenu label="新建题目" primary @select="createProblem" />
|
||||
@@ -366,20 +341,8 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
</template>
|
||||
</template>
|
||||
<template #filters>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="题号或题目名"
|
||||
clearable
|
||||
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
<n-select
|
||||
v-if="!props.contestID"
|
||||
v-model:value="query.kind"
|
||||
:options="kindOptions"
|
||||
aria-label="题型"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-input v-model:value="query.keyword" placeholder="题号或题目名" clearable :style="{ width: isMobile ? '100%' : '220px' }" @clear="clearQuery" />
|
||||
<n-select v-if="!props.contestID" v-model:value="query.kind" :options="kindOptions" aria-label="题型" style="width: 120px" />
|
||||
<n-flex v-if="!props.contestID" align="center" :size="8">
|
||||
<n-text depth="3">出题人</n-text>
|
||||
<AuthorSelect v-model:value="query.author" all />
|
||||
@@ -419,18 +382,8 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
@update:checked-row-keys="chooseProblems"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
<AddFromLibraryModal
|
||||
v-model:show="show"
|
||||
:count="count"
|
||||
:next-display-id="nextDisplayID"
|
||||
@change="listProblems"
|
||||
/>
|
||||
<BatchTagModal
|
||||
v-model:show="showBatchTag"
|
||||
:problem-ids="selectedProblemIds"
|
||||
:action="batchTagAction"
|
||||
@done="onBatchTagDone"
|
||||
/>
|
||||
<AddFromLibraryModal v-model:show="show" :count="count" :next-display-id="nextDisplayID" @change="listProblems" />
|
||||
<BatchTagModal v-model:show="showBatchTag" :problem-ids="selectedProblemIds" :action="batchTagAction" @done="onBatchTagDone" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -441,8 +394,8 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid v-bind("theme.successColorSuppl");
|
||||
background: v-bind("theme.hoverColor");
|
||||
border: 1px solid v-bind('theme.successColorSuppl');
|
||||
background: v-bind('theme.hoverColor');
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -481,7 +434,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
}
|
||||
|
||||
.problem-table :deep(.title-link) {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -504,12 +457,12 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.problem-table :deep(.muted) {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.problem-table :deep(.more) {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { AdminTag, TagCategory } from "utils/types"
|
||||
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
|
||||
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { AdminTag, TagCategory } from 'utils/types'
|
||||
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from '../api'
|
||||
import TagProblemsModal from './components/TagProblemsModal.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const keyword = ref("")
|
||||
const keyword = ref('')
|
||||
|
||||
// 搜索在后端做(getTagAdminList 带 keyword),这里只是一份引用,两栏和待归类都从它分
|
||||
const visibleTags = computed(() => tags.value)
|
||||
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingName = ref("")
|
||||
const editingName = ref('')
|
||||
|
||||
const activeTag = ref<AdminTag | null>(null)
|
||||
const [showTagProblems, toggleTagProblems] = useToggle(false)
|
||||
@@ -36,11 +36,11 @@ const columnsOf = computed(() => {
|
||||
const confirmed = visibleTags.value.filter((t) => t.categoryConfirmed)
|
||||
return [
|
||||
{
|
||||
key: "knowledge" as const,
|
||||
title: "知识点",
|
||||
tags: confirmed.filter((t) => t.category === "knowledge"),
|
||||
key: 'knowledge' as const,
|
||||
title: '知识点',
|
||||
tags: confirmed.filter((t) => t.category === 'knowledge'),
|
||||
},
|
||||
{ key: "theme" as const, title: "主题", tags: confirmed.filter((t) => t.category === "theme") },
|
||||
{ key: 'theme' as const, title: '主题', tags: confirmed.filter((t) => t.category === 'theme') },
|
||||
].map((col) => ({
|
||||
...col,
|
||||
tags: [...col.tags].sort((a, b) => b.problemCount - a.problemCount),
|
||||
@@ -61,13 +61,13 @@ function startEdit(tag: AdminTag) {
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editingName.value = ""
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
async function saveTag(tag: AdminTag) {
|
||||
const name = editingName.value.trim()
|
||||
if (!name) {
|
||||
message.error("标签名不能为空")
|
||||
message.error('标签名不能为空')
|
||||
return
|
||||
}
|
||||
if (name === tag.name) {
|
||||
@@ -78,7 +78,7 @@ async function saveTag(tag: AdminTag) {
|
||||
if (res.merged) {
|
||||
message.success(`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
message.success('已重命名')
|
||||
}
|
||||
cancelEdit()
|
||||
listTags()
|
||||
@@ -88,20 +88,18 @@ async function changeCategory(tag: AdminTag, category: TagCategory) {
|
||||
await setTagCategory(tag.id, category)
|
||||
tag.category = category
|
||||
tag.categoryConfirmed = true
|
||||
message.success(`「${tag.name}」已归到${category === "theme" ? "主题" : "知识点"}`)
|
||||
message.success(`「${tag.name}」已归到${category === 'theme' ? '主题' : '知识点'}`)
|
||||
}
|
||||
|
||||
function confirmDelete(tag: AdminTag) {
|
||||
dialog.warning({
|
||||
title: `删除「${tag.name}」?`,
|
||||
content: tag.problemCount
|
||||
? `${tag.problemCount} 道题会摘掉这个标签,题目本身不动。`
|
||||
: "没有题目挂着它。",
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
content: tag.problemCount ? `${tag.problemCount} 道题会摘掉这个标签,题目本身不动。` : '没有题目挂着它。',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await deleteTag(tag.id)
|
||||
message.success("已删除")
|
||||
message.success('已删除')
|
||||
listTags()
|
||||
},
|
||||
})
|
||||
@@ -139,14 +137,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
<n-empty v-if="!col.tags.length" size="small" description="没有" class="empty" />
|
||||
<div v-for="tag in col.tags" :key="tag.id" class="row">
|
||||
<template v-if="editingId === tag.id">
|
||||
<n-input
|
||||
v-model:value="editingName"
|
||||
size="small"
|
||||
autofocus
|
||||
class="rename"
|
||||
@keyup.enter="saveTag(tag)"
|
||||
@keyup.esc="cancelEdit"
|
||||
/>
|
||||
<n-input v-model:value="editingName" size="small" autofocus class="rename" @keyup.enter="saveTag(tag)" @keyup.esc="cancelEdit" />
|
||||
<n-button size="small" type="primary" @click="saveTag(tag)">保存</n-button>
|
||||
<n-button size="small" @click="cancelEdit">取消</n-button>
|
||||
</template>
|
||||
@@ -154,33 +145,20 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
<button type="button" class="name" :title="tag.name" @click="openTagProblems(tag)">
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
<span class="bar"
|
||||
><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i
|
||||
></span>
|
||||
<span class="bar"><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i></span>
|
||||
<span class="count">{{ tag.problemCount }} 道</span>
|
||||
<n-button size="tiny" quaternary @click="startEdit(tag)">改名</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
@click="changeCategory(tag, tag.category === 'theme' ? 'knowledge' : 'theme')"
|
||||
>
|
||||
{{ tag.category === "theme" ? "改成知识点" : "改成主题" }}
|
||||
<n-button size="tiny" quaternary @click="changeCategory(tag, tag.category === 'theme' ? 'knowledge' : 'theme')">
|
||||
{{ tag.category === 'theme' ? '改成知识点' : '改成主题' }}
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary type="error" @click="confirmDelete(tag)"
|
||||
>删除</n-button
|
||||
>
|
||||
<n-button size="tiny" quaternary type="error" @click="confirmDelete(tag)">删除</n-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<TagProblemsModal
|
||||
v-model:show="showTagProblems"
|
||||
:tag-id="activeTag?.id ?? 0"
|
||||
:tag-name="activeTag?.name ?? ''"
|
||||
@changed="listTags"
|
||||
/>
|
||||
<TagProblemsModal v-model:show="showTagProblems" :tag-id="activeTag?.id ?? 0" :tag-name="activeTag?.name ?? ''" @changed="listTags" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -222,7 +200,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
}
|
||||
|
||||
.list {
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
@@ -233,7 +211,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
padding: 0 4px 0 8px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
@@ -242,7 +220,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
/* 斑马底纹,和后台其它列表一样(用户要的) */
|
||||
.row:nth-child(even) {
|
||||
background: v-bind("theme.tableColorStriped");
|
||||
background: v-bind('theme.tableColorStriped');
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -257,7 +235,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -269,7 +247,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
flex: 1 1 auto;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -277,7 +255,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
display: block;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.successColorSuppl");
|
||||
background: v-bind('theme.successColorSuppl');
|
||||
}
|
||||
|
||||
.count {
|
||||
@@ -285,7 +263,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.rename {
|
||||
@@ -294,7 +272,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
.muted {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { BadgeBody } from "admin/api"
|
||||
import type { AdminProblemSetBadge } from "utils/types"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { BadgeBody } from 'admin/api'
|
||||
import type { AdminProblemSetBadge } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 加奖章 / 改奖章共用一个弹窗:传了 badge 就是改,null 就是加。
|
||||
@@ -9,17 +9,17 @@ import type { AdminProblemSetBadge } from "utils/types"
|
||||
* 「做对 N 道」数的是做对的全部题(含选做),和后端 eligibleForBadge 同一个口径。
|
||||
*/
|
||||
const props = defineProps<{ badge: AdminProblemSetBadge | null; problemsCount: number }>()
|
||||
const show = defineModel<boolean>("show", { required: true })
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
const emit = defineEmits<{ save: [data: BadgeBody] }>()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const ICONS = [1, 2, 3, 4, 5, 6].map((i) => `/badge-${i}.png`)
|
||||
|
||||
const form = reactive<BadgeBody>({
|
||||
name: "",
|
||||
description: "",
|
||||
name: '',
|
||||
description: '',
|
||||
icon: ICONS[0]!,
|
||||
conditionType: "problem_count",
|
||||
conditionType: 'problem_count',
|
||||
conditionValue: 1,
|
||||
})
|
||||
|
||||
@@ -27,50 +27,33 @@ const form = reactive<BadgeBody>({
|
||||
watch(show, (open) => {
|
||||
if (!open) return
|
||||
Object.assign(form, {
|
||||
name: props.badge?.name ?? "",
|
||||
description: props.badge?.description ?? "",
|
||||
name: props.badge?.name ?? '',
|
||||
description: props.badge?.description ?? '',
|
||||
icon: props.badge?.icon || ICONS[0]!,
|
||||
conditionType: props.badge?.conditionType ?? "problem_count",
|
||||
conditionType: props.badge?.conditionType ?? 'problem_count',
|
||||
conditionValue: props.badge?.conditionValue || 1,
|
||||
})
|
||||
})
|
||||
|
||||
const valid = computed(
|
||||
() =>
|
||||
form.name.trim().length > 0 &&
|
||||
(form.conditionType === "all_problems" || form.conditionValue >= 1),
|
||||
)
|
||||
const valid = computed(() => form.name.trim().length > 0 && (form.conditionType === 'all_problems' || form.conditionValue >= 1))
|
||||
|
||||
function save() {
|
||||
if (!valid.value) return
|
||||
emit("save", {
|
||||
emit('save', {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
conditionValue: form.conditionType === "all_problems" ? 0 : form.conditionValue,
|
||||
conditionValue: form.conditionType === 'all_problems' ? 0 : form.conditionValue,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="badge ? '改奖章' : '加奖章'"
|
||||
style="width: 460px"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<n-modal v-model:show="show" preset="card" :title="badge ? '改奖章' : '加奖章'" style="width: 460px" :mask-closable="false">
|
||||
<div class="form">
|
||||
<div class="field">
|
||||
<span class="label">图标</span>
|
||||
<div class="icons">
|
||||
<button
|
||||
v-for="icon in ICONS"
|
||||
:key="icon"
|
||||
class="icon"
|
||||
:class="{ on: form.icon === icon }"
|
||||
:aria-label="`选这个图标`"
|
||||
@click="form.icon = icon"
|
||||
>
|
||||
<button v-for="icon in ICONS" :key="icon" class="icon" :class="{ on: form.icon === icon }" :aria-label="`选这个图标`" @click="form.icon = icon">
|
||||
<img :src="icon" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -100,11 +83,7 @@ function save() {
|
||||
</n-input-number>
|
||||
</div>
|
||||
<span class="hint">
|
||||
{{
|
||||
form.conditionType === "all_problems"
|
||||
? "必做题全部做对就发。"
|
||||
: "做对的题(选做也算)够这个数就发。"
|
||||
}}改了条件会按大家现在的进度重新发、收。
|
||||
{{ form.conditionType === 'all_problems' ? '必做题全部做对就发。' : '做对的题(选做也算)够这个数就发。' }}改了条件会按大家现在的进度重新发、收。
|
||||
</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -133,7 +112,7 @@ function save() {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.icons {
|
||||
@@ -152,7 +131,7 @@ function save() {
|
||||
}
|
||||
|
||||
.icon.on {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
border-color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.icon img {
|
||||
@@ -163,6 +142,6 @@ function save() {
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { fromPickerValue, parseTime, toPickerValue, zonedParts } from "utils/functions"
|
||||
import type { AdminProblemRow, AdminProblemSet, AdminProblemSetBadge } from "utils/types"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { fromPickerValue, parseTime, toPickerValue, zonedParts } from 'utils/functions'
|
||||
import type { AdminProblemRow, AdminProblemSet, AdminProblemSetBadge } from 'utils/types'
|
||||
import {
|
||||
addProblemsToSet,
|
||||
createProblemSet,
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
reorderProblemSetProblems,
|
||||
setProblemRequired,
|
||||
type BadgeBody,
|
||||
} from "../api"
|
||||
import BadgeModal from "./components/BadgeModal.vue"
|
||||
} from '../api'
|
||||
import BadgeModal from './components/BadgeModal.vue'
|
||||
|
||||
/**
|
||||
* 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
|
||||
@@ -43,18 +43,18 @@ const ready = ref(false)
|
||||
const saving = ref(false)
|
||||
const current = ref<AdminProblemSet | null>(null)
|
||||
|
||||
const form = reactive({ title: "", description: "", visible: true })
|
||||
const form = reactive({ title: '', description: '', visible: true })
|
||||
|
||||
// ---------------------------------------------------------------- 布置期
|
||||
|
||||
const DAY = 86_400_000
|
||||
const OFFSET = 8 * 3_600_000
|
||||
const CHOICES = [
|
||||
{ days: 7, label: "一周" },
|
||||
{ days: 14, label: "两周" },
|
||||
{ days: 28, label: "四周" },
|
||||
{ days: 7, label: '一周' },
|
||||
{ days: 14, label: '两周' },
|
||||
{ days: 28, label: '四周' },
|
||||
]
|
||||
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
|
||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
/** 东八区某一天结束的时刻(23:59:59.999) */
|
||||
function endOfDay(ms: number) {
|
||||
@@ -63,36 +63,32 @@ function endOfDay(ms: number) {
|
||||
}
|
||||
|
||||
/** "none" 不布置 / 天数 / "pick" 自己选日期 */
|
||||
const choice = ref<"none" | "pick" | number>(14)
|
||||
const choice = ref<'none' | 'pick' | number>(14)
|
||||
const picked = ref(toPickerValue(Date.now() + 14 * DAY))
|
||||
|
||||
const untilMs = computed(() => {
|
||||
if (choice.value === "none") return null
|
||||
if (choice.value === "pick") return endOfDay(fromPickerValue(picked.value))
|
||||
if (choice.value === 'none') return null
|
||||
if (choice.value === 'pick') return endOfDay(fromPickerValue(picked.value))
|
||||
return endOfDay(Date.now() + choice.value * DAY)
|
||||
})
|
||||
|
||||
function dayText(ms: number) {
|
||||
const iso = new Date(ms).toISOString()
|
||||
return `${parseTime(iso, "M月D日")} 周${WEEKDAYS[new Date(ms + OFFSET).getUTCDay()]}`
|
||||
return `${parseTime(iso, 'M月D日')} 周${WEEKDAYS[new Date(ms + OFFSET).getUTCDay()]}`
|
||||
}
|
||||
|
||||
const running = computed(() => !!current.value?.assigning)
|
||||
const expired = computed(
|
||||
() => !!current.value && !current.value.assigning && !!current.value.assignedUntil,
|
||||
)
|
||||
const expired = computed(() => !!current.value && !current.value.assigning && !!current.value.assignedUntil)
|
||||
|
||||
const assignHint = computed(() => {
|
||||
if (untilMs.value === null) {
|
||||
return running.value
|
||||
? "保存后马上结束布置,以前的代码都放出来。"
|
||||
: "不布置:学生照样能加入、做题,只是不藏以前的代码。"
|
||||
return running.value ? '保存后马上结束布置,以前的代码都放出来。' : '不布置:学生照样能加入、做题,只是不藏以前的代码。'
|
||||
}
|
||||
if (untilMs.value <= Date.now()) return "这一天已经过了,选个以后的日子。"
|
||||
if (untilMs.value <= Date.now()) return '这一天已经过了,选个以后的日子。'
|
||||
const head = `布置到 ${dayText(untilMs.value)}。`
|
||||
return running.value
|
||||
? `${head}这一轮从 ${parseTime(current.value!.assignedAt!, "M月D日")} 开始,改日期只是延长或提前结束。`
|
||||
: `${head}${expired.value ? "上一轮已经结束,保存就是再布置一次。" : ""}`
|
||||
? `${head}这一轮从 ${parseTime(current.value!.assignedAt!, 'M月D日')} 开始,改日期只是延长或提前结束。`
|
||||
: `${head}${expired.value ? '上一轮已经结束,保存就是再布置一次。' : ''}`
|
||||
})
|
||||
|
||||
const disabledDate = (ts: number) => fromPickerValue(ts) + DAY <= Date.now()
|
||||
@@ -112,7 +108,7 @@ async function loadProblems() {
|
||||
}))
|
||||
}
|
||||
|
||||
const keyword = ref("")
|
||||
const keyword = ref('')
|
||||
const results = ref<AdminProblemRow[]>([])
|
||||
const searching = ref(false)
|
||||
|
||||
@@ -142,8 +138,7 @@ watchDebounced(
|
||||
{ debounce: 300 },
|
||||
)
|
||||
|
||||
const added = (displayId: string) =>
|
||||
items.value.some((item) => item.displayId.toLowerCase() === displayId.toLowerCase())
|
||||
const added = (displayId: string) => items.value.some((item) => item.displayId.toLowerCase() === displayId.toLowerCase())
|
||||
|
||||
async function addMany(displayIds: string[], titles = new Map<string, string>()) {
|
||||
if (!editing.value) {
|
||||
@@ -153,9 +148,7 @@ async function addMany(displayIds: string[], titles = new Map<string, string>())
|
||||
let displayId = typed
|
||||
let title = titles.get(typed)
|
||||
if (!title) {
|
||||
const found = (await getLibraryProblemList(0, 5, typed)).results.find(
|
||||
(row) => row._id.toLowerCase() === typed.toLowerCase(),
|
||||
)
|
||||
const found = (await getLibraryProblemList(0, 5, typed)).results.find((row) => row._id.toLowerCase() === typed.toLowerCase())
|
||||
if (!found) {
|
||||
message.warning(`题号 ${typed} 找不到(或者没公开)`)
|
||||
continue
|
||||
@@ -165,17 +158,17 @@ async function addMany(displayIds: string[], titles = new Map<string, string>())
|
||||
}
|
||||
items.value.push({ id: 0, displayId, title, isRequired: true })
|
||||
}
|
||||
keyword.value = ""
|
||||
keyword.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await addProblemsToSet(id.value, displayIds)
|
||||
const notes = []
|
||||
if (res.missing.length) notes.push(`找不到:${res.missing.join("、")}`)
|
||||
if (res.duplicate.length) notes.push(`已经在里面:${res.duplicate.join("、")}`)
|
||||
if (notes.length) message.warning(notes.join(";"))
|
||||
if (res.missing.length) notes.push(`找不到:${res.missing.join('、')}`)
|
||||
if (res.duplicate.length) notes.push(`已经在里面:${res.duplicate.join('、')}`)
|
||||
if (notes.length) message.warning(notes.join(';'))
|
||||
else message.success(`加了 ${res.added.length} 道`)
|
||||
keyword.value = ""
|
||||
keyword.value = ''
|
||||
await Promise.all([loadProblems(), reloadSet()])
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
@@ -203,10 +196,10 @@ function remove(item: Item) {
|
||||
return
|
||||
}
|
||||
dialog.warning({
|
||||
title: "从题单里拿掉这道题?",
|
||||
title: '从题单里拿掉这道题?',
|
||||
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
|
||||
positiveText: "拿掉",
|
||||
negativeText: "不拿",
|
||||
positiveText: '拿掉',
|
||||
negativeText: '不拿',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await removeProblemFromSet(id.value, item.id)
|
||||
@@ -256,7 +249,7 @@ const badgeModal = ref(false)
|
||||
const editingBadge = ref<AdminProblemSetBadge | null>(null)
|
||||
|
||||
function badgeCondition(badge: AdminProblemSetBadge) {
|
||||
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
|
||||
return badge.conditionType === 'all_problems' ? '全部做完' : `做对 ${badge.conditionValue} 道`
|
||||
}
|
||||
|
||||
function openBadge(badge: AdminProblemSetBadge | null) {
|
||||
@@ -278,11 +271,9 @@ async function saveBadge(data: BadgeBody) {
|
||||
function removeBadge(badge: AdminProblemSetBadge) {
|
||||
dialog.warning({
|
||||
title: `删掉奖章「${badge.name}」?`,
|
||||
content: badge.earnedCount
|
||||
? `已经拿到它的 ${badge.earnedCount} 个人也会一起被收回。`
|
||||
: "还没有人拿到它。",
|
||||
positiveText: "删掉",
|
||||
negativeText: "不删",
|
||||
content: badge.earnedCount ? `已经拿到它的 ${badge.earnedCount} 个人也会一起被收回。` : '还没有人拿到它。',
|
||||
positiveText: '删掉',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteProblemSetBadge(id.value, badge.id)
|
||||
@@ -301,7 +292,7 @@ async function reloadSet() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
keyword.value = ""
|
||||
keyword.value = ''
|
||||
if (!editing.value) {
|
||||
ready.value = true
|
||||
return
|
||||
@@ -310,10 +301,10 @@ async function load() {
|
||||
const set = current.value!
|
||||
Object.assign(form, { title: set.title, description: set.description, visible: set.visible })
|
||||
if (set.assigning) {
|
||||
choice.value = "pick"
|
||||
choice.value = 'pick'
|
||||
picked.value = toPickerValue(Date.parse(set.assignedUntil!))
|
||||
} else {
|
||||
choice.value = "none"
|
||||
choice.value = 'none'
|
||||
}
|
||||
await Promise.all([loadProblems(), getProblemSetBadges(id.value).then((b) => (badges.value = b))])
|
||||
ready.value = true
|
||||
@@ -321,11 +312,11 @@ async function load() {
|
||||
|
||||
async function submit() {
|
||||
if (!form.title.trim()) {
|
||||
message.error("题单还没起名字")
|
||||
message.error('题单还没起名字')
|
||||
return
|
||||
}
|
||||
if (untilMs.value !== null && untilMs.value <= Date.now()) {
|
||||
message.error("布置到的日期已经过了")
|
||||
message.error('布置到的日期已经过了')
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
@@ -338,7 +329,7 @@ async function submit() {
|
||||
try {
|
||||
if (editing.value) {
|
||||
current.value = await editProblemSet(id.value, body)
|
||||
message.success("改好了")
|
||||
message.success('改好了')
|
||||
return
|
||||
}
|
||||
const created = await createProblemSet(body)
|
||||
@@ -355,10 +346,8 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
}
|
||||
message.success(
|
||||
items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : "题单建好了",
|
||||
)
|
||||
router.replace({ name: "admin problemset edit", params: { problemSetId: created.id } })
|
||||
message.success(items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : '题单建好了')
|
||||
router.replace({ name: 'admin problemset edit', params: { problemSetId: created.id } })
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
} finally {
|
||||
@@ -372,13 +361,13 @@ function removeSet() {
|
||||
title: `删除题单「${set.title}」?`,
|
||||
content: set.participantCount
|
||||
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
|
||||
: "还没有人加入。删掉找不回来。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
: '还没有人加入。删掉找不回来。',
|
||||
positiveText: '删除',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteProblemSet(set.id)
|
||||
router.replace({ name: "admin problemset list" })
|
||||
router.replace({ name: 'admin problemset list' })
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
@@ -393,9 +382,7 @@ onMounted(load)
|
||||
<template>
|
||||
<PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }">
|
||||
<template v-if="editing" #actions>
|
||||
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank">
|
||||
到前台看这个题单
|
||||
</n-button>
|
||||
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank"> 到前台看这个题单 </n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -414,28 +401,12 @@ onMounted(load)
|
||||
<span class="label">布置</span>
|
||||
<div class="stack">
|
||||
<n-flex :size="6" align="center">
|
||||
<button class="chip" :class="{ on: choice === 'none' }" @click="choice = 'none'">
|
||||
不布置
|
||||
</button>
|
||||
<button
|
||||
v-for="c in CHOICES"
|
||||
:key="c.days"
|
||||
class="chip"
|
||||
:class="{ on: choice === c.days }"
|
||||
@click="choice = c.days"
|
||||
>
|
||||
<button class="chip" :class="{ on: choice === 'none' }" @click="choice = 'none'">不布置</button>
|
||||
<button v-for="c in CHOICES" :key="c.days" class="chip" :class="{ on: choice === c.days }" @click="choice = c.days">
|
||||
{{ c.label }}
|
||||
</button>
|
||||
<button class="chip" :class="{ on: choice === 'pick' }" @click="choice = 'pick'">
|
||||
选日期…
|
||||
</button>
|
||||
<n-date-picker
|
||||
v-if="choice === 'pick'"
|
||||
v-model:value="picked"
|
||||
type="date"
|
||||
:is-date-disabled="disabledDate"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<button class="chip" :class="{ on: choice === 'pick' }" @click="choice = 'pick'">选日期…</button>
|
||||
<n-date-picker v-if="choice === 'pick'" v-model:value="picked" type="date" :is-date-disabled="disabledDate" style="width: 150px" />
|
||||
</n-flex>
|
||||
<span class="hint">{{ assignHint }}</span>
|
||||
<span class="hint">
|
||||
@@ -447,9 +418,7 @@ onMounted(load)
|
||||
<div class="field">
|
||||
<span class="label">公开</span>
|
||||
<n-switch v-model:value="form.visible" />
|
||||
<span class="hint">{{
|
||||
form.visible ? "学生能在题单列表里看到" : "藏起来,学生看不到也进不去"
|
||||
}}</span>
|
||||
<span class="hint">{{ form.visible ? '学生能在题单列表里看到' : '藏起来,学生看不到也进不去' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="current" class="stats">
|
||||
@@ -461,8 +430,7 @@ onMounted(load)
|
||||
<div class="save">
|
||||
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
|
||||
<span class="hint"
|
||||
>{{ items.length }} 道题<template v-if="optionalCount">
|
||||
· {{ optionalCount }} 道选做</template
|
||||
>{{ items.length }} 道题<template v-if="optionalCount"> · {{ optionalCount }} 道选做</template
|
||||
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
|
||||
>
|
||||
<div class="spacer"></div>
|
||||
@@ -485,7 +453,7 @@ onMounted(load)
|
||||
/>
|
||||
<div v-if="pasted.length" class="results">
|
||||
<div class="result">
|
||||
<span class="grow">粘贴了 {{ pasted.length }} 个题号:{{ pasted.join("、") }}</span>
|
||||
<span class="grow">粘贴了 {{ pasted.length }} 个题号:{{ pasted.join('、') }}</span>
|
||||
<n-button size="small" type="primary" @click="addMany(pasted)">全部加进来</n-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,11 +488,9 @@ onMounted(load)
|
||||
:title="item.isRequired ? '点一下改成选做' : '点一下改回必做'"
|
||||
@click="toggleRequired(item)"
|
||||
>
|
||||
{{ item.isRequired ? "必做" : "选做" }}
|
||||
{{ item.isRequired ? '必做' : '选做' }}
|
||||
</button>
|
||||
<n-button size="small" text :aria-label="`拿掉 ${item.title}`" @click="remove(item)"
|
||||
>✕</n-button
|
||||
>
|
||||
<n-button size="small" text :aria-label="`拿掉 ${item.title}`" @click="remove(item)">✕</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty hint">还没加题。在上面输入题号,比如 3065</div>
|
||||
@@ -546,13 +512,7 @@ onMounted(load)
|
||||
<span class="muted ell grow tiny">{{ badge.description }}</span>
|
||||
<span class="muted num tiny">{{ badge.earnedCount }} 人拿到</span>
|
||||
<n-button size="small" text type="primary" @click="openBadge(badge)">改</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
text
|
||||
:aria-label="`删掉 ${badge.name}`"
|
||||
@click="removeBadge(badge)"
|
||||
>✕</n-button
|
||||
>
|
||||
<n-button size="small" text :aria-label="`删掉 ${badge.name}`" @click="removeBadge(badge)">✕</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty hint">还没有奖章。比如「做对 3 道」发一个、「全部做完」发一个</div>
|
||||
@@ -560,12 +520,7 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BadgeModal
|
||||
v-model:show="badgeModal"
|
||||
:badge="editingBadge"
|
||||
:problems-count="items.length"
|
||||
@save="saveBadge"
|
||||
/>
|
||||
<BadgeModal v-model:show="badgeModal" :badge="editingBadge" :problems-count="items.length" @save="saveBadge" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -582,9 +537,9 @@ onMounted(load)
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.form {
|
||||
@@ -632,7 +587,7 @@ onMounted(load)
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.stack {
|
||||
@@ -654,11 +609,11 @@ onMounted(load)
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.tiny {
|
||||
@@ -680,18 +635,18 @@ onMounted(load)
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
color: v-bind("theme.primaryColor");
|
||||
border-color: v-bind('theme.primaryColor');
|
||||
color: v-bind('theme.primaryColor');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -704,7 +659,7 @@ onMounted(load)
|
||||
|
||||
.chip.dashed {
|
||||
border-style: dashed;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.chip.static {
|
||||
@@ -716,14 +671,14 @@ onMounted(load)
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.stats a {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -732,12 +687,12 @@ onMounted(load)
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: -4px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
@@ -754,11 +709,11 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.result + .result {
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.list {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -771,11 +726,11 @@ onMounted(load)
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.item.dragging {
|
||||
@@ -788,7 +743,7 @@ onMounted(load)
|
||||
|
||||
.grip {
|
||||
cursor: grab;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
letter-spacing: -3px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminProblemSet } from "utils/types"
|
||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||
import { NButton, NSwitch, NTag, useThemeVars } from "naive-ui"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { usePagination } from 'shared/composables/pagination'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { AdminProblemSet } from 'utils/types'
|
||||
import { getProblemSetList, toggleProblemSetVisible } from '../api'
|
||||
import { NButton, NSwitch, NTag, useThemeVars } from 'naive-ui'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
/**
|
||||
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
|
||||
@@ -19,34 +19,24 @@ const { isMobile } = useBreakpoints()
|
||||
const total = ref(0)
|
||||
const problemSets = ref<AdminProblemSet[]>([])
|
||||
|
||||
const { query, clearQuery } = usePagination<{ keyword: string }>({ keyword: "" })
|
||||
const { query, clearQuery } = usePagination<{ keyword: string }>({ keyword: '' })
|
||||
|
||||
function edit(row: AdminProblemSet) {
|
||||
router.push({ name: "admin problemset edit", params: { problemSetId: row.id } })
|
||||
router.push({ name: 'admin problemset edit', params: { problemSetId: row.id } })
|
||||
}
|
||||
|
||||
function renderTitle(row: AdminProblemSet) {
|
||||
return h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => edit(row) },
|
||||
{ default: () => row.title },
|
||||
)
|
||||
return h(NButton, { text: true, type: 'primary', onClick: () => edit(row) }, { default: () => row.title })
|
||||
}
|
||||
|
||||
function renderAssigned(row: AdminProblemSet) {
|
||||
return row.assigning
|
||||
? h(
|
||||
NTag,
|
||||
{ type: "info", size: "small", bordered: false },
|
||||
{ default: () => `到 ${parseTime(row.assignedUntil!, "M月D日")}` },
|
||||
)
|
||||
: "—"
|
||||
return row.assigning ? h(NTag, { type: 'info', size: 'small', bordered: false }, { default: () => `到 ${parseTime(row.assignedUntil!, 'M月D日')}` }) : '—'
|
||||
}
|
||||
|
||||
function renderVisible(row: AdminProblemSet) {
|
||||
return h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: isMobile.value ? "medium" : "small",
|
||||
size: isMobile.value ? 'medium' : 'small',
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
})
|
||||
@@ -59,60 +49,60 @@ function renderMobileCard(row: AdminProblemSet) {
|
||||
`${row.participantCount} 人加入 · ${row.completedCount} 人做完`,
|
||||
row.badgeCount ? `${row.badgeCount} 个奖章` : null,
|
||||
row.createdBy.username,
|
||||
parseTime(row.createTime, "YYYY-MM-DD"),
|
||||
parseTime(row.createTime, 'YYYY-MM-DD'),
|
||||
].filter((it): it is string => !!it)
|
||||
return h("div", { class: "m-card" }, [
|
||||
h("div", { class: "m-head" }, [renderTitle(row), row.assigning ? renderAssigned(row) : null]),
|
||||
return h('div', { class: 'm-card' }, [
|
||||
h('div', { class: 'm-head' }, [renderTitle(row), row.assigning ? renderAssigned(row) : null]),
|
||||
h(
|
||||
"div",
|
||||
{ class: "m-meta" },
|
||||
items.map((text, i) => h("span", { class: "m-item", key: i }, i ? ` · ${text}` : text)),
|
||||
'div',
|
||||
{ class: 'm-meta' },
|
||||
items.map((text, i) => h('span', { class: 'm-item', key: i }, i ? ` · ${text}` : text)),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
const mobileColumns: DataTableColumn<AdminProblemSet>[] = [
|
||||
{ title: "题单", key: "title", render: renderMobileCard },
|
||||
{ title: "公开", key: "visible", width: 72, align: "center", render: renderVisible },
|
||||
{ title: '题单', key: 'title', render: renderMobileCard },
|
||||
{ title: '公开', key: 'visible', width: 72, align: 'center', render: renderVisible },
|
||||
]
|
||||
|
||||
const desktopColumns: DataTableColumn<AdminProblemSet>[] = [
|
||||
{ title: "ID", key: "id", width: 70 },
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{
|
||||
title: "题单",
|
||||
key: "title",
|
||||
title: '题单',
|
||||
key: 'title',
|
||||
minWidth: 200,
|
||||
render: renderTitle,
|
||||
},
|
||||
{ title: "题数", key: "problemsCount", width: 70 },
|
||||
{ title: '题数', key: 'problemsCount', width: 70 },
|
||||
{
|
||||
title: "加入 / 做完",
|
||||
key: "participantCount",
|
||||
title: '加入 / 做完',
|
||||
key: 'participantCount',
|
||||
width: 110,
|
||||
render: (row) => `${row.participantCount} / ${row.completedCount}`,
|
||||
},
|
||||
{ title: "奖章", key: "badgeCount", width: 70 },
|
||||
{ title: '奖章', key: 'badgeCount', width: 70 },
|
||||
{
|
||||
title: "布置",
|
||||
key: "assignedUntil",
|
||||
title: '布置',
|
||||
key: 'assignedUntil',
|
||||
width: 130,
|
||||
render: renderAssigned,
|
||||
},
|
||||
{
|
||||
title: "创建者",
|
||||
key: "createdBy",
|
||||
title: '创建者',
|
||||
key: 'createdBy',
|
||||
width: 110,
|
||||
render: (row) => row.createdBy.username,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "createTime",
|
||||
title: '创建时间',
|
||||
key: 'createTime',
|
||||
width: 120,
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD"),
|
||||
render: (row) => parseTime(row.createTime, 'YYYY-MM-DD'),
|
||||
},
|
||||
{
|
||||
title: "公开",
|
||||
key: "visible",
|
||||
title: '公开',
|
||||
key: 'visible',
|
||||
width: 80,
|
||||
render: renderVisible,
|
||||
},
|
||||
@@ -143,27 +133,13 @@ watch(() => [query.page, query.limit], listProblemSets)
|
||||
<template>
|
||||
<PageHeader title="题单列表">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })">
|
||||
新建题单
|
||||
</n-button>
|
||||
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })"> 新建题单 </n-button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="搜题单名字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||
/>
|
||||
<n-input v-model:value="query.keyword" placeholder="搜题单名字" clearable @clear="clearQuery" :style="{ width: isMobile ? '100%' : '220px' }" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
striped
|
||||
class="set-table"
|
||||
:columns="columns"
|
||||
:data="problemSets"
|
||||
/>
|
||||
<n-data-table :loading="loading" striped class="set-table" :columns="columns" :data="problemSets" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
@@ -186,7 +162,7 @@ watch(() => [query.page, query.limit], listProblemSets)
|
||||
.set-table :deep(.m-meta) {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.set-table :deep(.m-item) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants"
|
||||
import { copyToClipboard, parseTime } from "utils/functions"
|
||||
import type { OrphanImage, OrphanTestCase, Server, WebsiteConfig } from "utils/types"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from 'utils/constants'
|
||||
import { copyToClipboard, parseTime } from 'utils/functions'
|
||||
import type { OrphanImage, OrphanTestCase, Server, WebsiteConfig } from 'utils/types'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import {
|
||||
deleteJudgeServer,
|
||||
editWebsite,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
listOrphanImages,
|
||||
pruneInvalidTestcases,
|
||||
pruneOrphanImages,
|
||||
} from "../api"
|
||||
} from '../api'
|
||||
|
||||
/**
|
||||
* 后台设置(设计稿「后台首页」页的 ConfAdmin):网站 / 班级 / 开关 / 判题机,有无效测试用例时
|
||||
@@ -32,7 +32,7 @@ const theme = useThemeVars()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const websiteConfig = reactive<WebsiteConfig>({
|
||||
websiteName: "判题狗",
|
||||
websiteName: '判题狗',
|
||||
allowRegister: false,
|
||||
submissionListShowAll: true,
|
||||
classList: [],
|
||||
@@ -42,8 +42,8 @@ const websiteConfig = reactive<WebsiteConfig>({
|
||||
* 上一次存进去(或刚读回来)的内容。和它一样就不存 —— 读回配置本身也是一次改动,
|
||||
* 只看「读完没有」的话,那次改动的延迟保存会在读完之后照样发出去
|
||||
*/
|
||||
let savedSnapshot = ""
|
||||
const saveState = ref<"idle" | "saving" | "saved" | "failed">("idle")
|
||||
let savedSnapshot = ''
|
||||
const saveState = ref<'idle' | 'saving' | 'saved' | 'failed'>('idle')
|
||||
|
||||
async function getWebsiteConfig() {
|
||||
const res = await getWebsite()
|
||||
@@ -65,29 +65,27 @@ watchDebounced(
|
||||
|
||||
async function saveWebsiteConfig() {
|
||||
if (!websiteConfig.websiteName.trim()) {
|
||||
saveState.value = "failed"
|
||||
message.error("网站名不能空着")
|
||||
saveState.value = 'failed'
|
||||
message.error('网站名不能空着')
|
||||
return
|
||||
}
|
||||
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。后端也会拦
|
||||
const invalid = websiteConfig.classList.filter((c) => !CLASS_NAME_RE.test(c))
|
||||
if (invalid.length) {
|
||||
message.error(
|
||||
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
||||
)
|
||||
message.error(`班级号 ${invalid.join('、')} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`)
|
||||
return
|
||||
}
|
||||
saveState.value = "saving"
|
||||
saveState.value = 'saving'
|
||||
const snapshot = JSON.stringify(websiteConfig)
|
||||
try {
|
||||
await editWebsite(websiteConfig)
|
||||
savedSnapshot = snapshot
|
||||
} catch (err) {
|
||||
saveState.value = "failed"
|
||||
message.error("没保存上:" + errorMessage(err))
|
||||
saveState.value = 'failed'
|
||||
message.error('没保存上:' + errorMessage(err))
|
||||
return
|
||||
}
|
||||
saveState.value = "saved"
|
||||
saveState.value = 'saved'
|
||||
// 广播由后端在 POST /admin/website 里做,开着页面的人不用刷新
|
||||
configStore.getConfig()
|
||||
}
|
||||
@@ -98,18 +96,18 @@ async function saveWebsiteConfig() {
|
||||
const classGroups = computed(() => {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const name of websiteConfig.classList) {
|
||||
const grade = /^\d{3,}$/.test(name) ? name.slice(0, 2) : "其他"
|
||||
const grade = /^\d{3,}$/.test(name) ? name.slice(0, 2) : '其他'
|
||||
groups.set(grade, [...(groups.get(grade) ?? []), name])
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([grade, list]) => ({
|
||||
grade,
|
||||
list: [...list].sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true })),
|
||||
list: [...list].sort((a, b) => a.localeCompare(b, 'zh-CN', { numeric: true })),
|
||||
}))
|
||||
})
|
||||
|
||||
const newClass = ref("")
|
||||
const newClass = ref('')
|
||||
function addClass() {
|
||||
const value = newClass.value.trim()
|
||||
if (!value) return
|
||||
@@ -122,7 +120,7 @@ function addClass() {
|
||||
return
|
||||
}
|
||||
websiteConfig.classList = [...websiteConfig.classList, value]
|
||||
newClass.value = ""
|
||||
newClass.value = ''
|
||||
}
|
||||
|
||||
function removeClass(name: string) {
|
||||
@@ -131,10 +129,10 @@ function removeClass(name: string) {
|
||||
|
||||
// ---------------------------------------------------------------- 判题机
|
||||
|
||||
const token = ref("")
|
||||
const token = ref('')
|
||||
const tokenShown = ref(false)
|
||||
const servers = ref<Server[]>([])
|
||||
const abnormalServers = computed(() => servers.value.filter((item) => item.status === "abnormal"))
|
||||
const abnormalServers = computed(() => servers.value.filter((item) => item.status === 'abnormal'))
|
||||
|
||||
async function getJudgeServerData() {
|
||||
const res = await getJudgeServer()
|
||||
@@ -145,7 +143,7 @@ async function getJudgeServerData() {
|
||||
/** 上一次心跳:一分钟内写「刚刚还在」,否则「N 分钟 / 小时 / 天没动静了」 */
|
||||
function heartbeatText(server: Server) {
|
||||
const seconds = Math.max(0, (Date.now() - Date.parse(server.lastHeartbeat)) / 1000)
|
||||
if (seconds < 60) return "刚刚还在"
|
||||
if (seconds < 60) return '刚刚还在'
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟没动静了`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时没动静了`
|
||||
return `${Math.floor(seconds / 86400)} 天没动静了`
|
||||
@@ -153,18 +151,18 @@ function heartbeatText(server: Server) {
|
||||
|
||||
async function delJudgeServer(hostname: string) {
|
||||
await deleteJudgeServer(hostname)
|
||||
message.success("删掉了")
|
||||
message.success('删掉了')
|
||||
getJudgeServerData()
|
||||
}
|
||||
|
||||
async function deleteAbnormalServers() {
|
||||
await Promise.all(abnormalServers.value.map((item) => deleteJudgeServer(item.hostname)))
|
||||
message.success("删掉了")
|
||||
message.success('删掉了')
|
||||
getJudgeServerData()
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (await copyToClipboard(token.value)) message.success("复制好了")
|
||||
if (await copyToClipboard(token.value)) message.success('复制好了')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 无效的测试用例
|
||||
@@ -177,7 +175,7 @@ async function getTestcases() {
|
||||
|
||||
async function deleteTestcase(id?: string) {
|
||||
await pruneInvalidTestcases(id)
|
||||
message.success("删掉了")
|
||||
message.success('删掉了')
|
||||
getTestcases()
|
||||
}
|
||||
|
||||
@@ -191,8 +189,8 @@ const orphanImages = ref<OrphanImage[] | null>(null)
|
||||
const scanningImages = ref(false)
|
||||
|
||||
const IMAGE_GROUPS = [
|
||||
{ folder: "upload", label: "编辑器里删掉的图" },
|
||||
{ folder: "avatar", label: "换下来的头像" },
|
||||
{ folder: 'upload', label: '编辑器里删掉的图' },
|
||||
{ folder: 'avatar', label: '换下来的头像' },
|
||||
] as const
|
||||
|
||||
const imageGroups = computed(() =>
|
||||
@@ -249,11 +247,7 @@ onMounted(() => {
|
||||
<h3>网站</h3>
|
||||
<div class="field">
|
||||
<label for="site-name" class="field-label">网站名</label>
|
||||
<n-input
|
||||
v-model:value="websiteConfig.websiteName"
|
||||
:input-props="{ id: 'site-name' }"
|
||||
style="width: 240px"
|
||||
/>
|
||||
<n-input v-model:value="websiteConfig.websiteName" :input-props="{ id: 'site-name' }" style="width: 240px" />
|
||||
<n-text depth="3" class="hint">顶栏左上角和浏览器标签页上显示的名字</n-text>
|
||||
</div>
|
||||
</section>
|
||||
@@ -263,27 +257,16 @@ onMounted(() => {
|
||||
班级 <span class="count">{{ websiteConfig.classList.length }} 个</span>
|
||||
</h3>
|
||||
<div v-for="group in classGroups" :key="group.grade" class="grade listed">
|
||||
<span class="grade-label">{{ group.grade === "其他" ? "其他" : `${group.grade} 级` }}</span>
|
||||
<span class="grade-label">{{ group.grade === '其他' ? '其他' : `${group.grade} 级` }}</span>
|
||||
<div class="chips">
|
||||
<n-tag
|
||||
v-for="name in group.list"
|
||||
:key="name"
|
||||
closable
|
||||
:bordered="false"
|
||||
@close="removeClass(name)"
|
||||
>
|
||||
<n-tag v-for="name in group.list" :key="name" closable :bordered="false" @close="removeClass(name)">
|
||||
{{ classLabel(name) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grade">
|
||||
<span class="grade-label"></span>
|
||||
<n-input
|
||||
v-model:value="newClass"
|
||||
placeholder="加一个班,如 2613"
|
||||
style="width: 180px"
|
||||
@keyup.enter="addClass"
|
||||
/>
|
||||
<n-input v-model:value="newClass" placeholder="加一个班,如 2613" style="width: 180px" @keyup.enter="addClass" />
|
||||
<n-button @click="addClass">加</n-button>
|
||||
<n-text depth="3" class="hint">批量建号时新班级会自动加进来,这里主要是删掉毕业的班</n-text>
|
||||
</div>
|
||||
@@ -304,14 +287,7 @@ onMounted(() => {
|
||||
<section class="block">
|
||||
<h3>
|
||||
判题机
|
||||
<n-button
|
||||
v-if="abnormalServers.length > 1"
|
||||
size="small"
|
||||
style="margin-left: 8px"
|
||||
@click="deleteAbnormalServers"
|
||||
>
|
||||
把断了的都删掉
|
||||
</n-button>
|
||||
<n-button v-if="abnormalServers.length > 1" size="small" style="margin-left: 8px" @click="deleteAbnormalServers"> 把断了的都删掉 </n-button>
|
||||
</h3>
|
||||
<n-empty v-if="!servers.length" description="一台都没有连上" />
|
||||
<div v-for="server in servers" :key="server.hostname" class="server">
|
||||
@@ -321,25 +297,16 @@ onMounted(() => {
|
||||
{{ server.cpuCore }} 核 · CPU {{ server.cpuUsage }}% · 内存 {{ server.memoryUsage }}% · 版本
|
||||
{{ server.judgerVersion }}
|
||||
</span>
|
||||
<span
|
||||
:class="server.status === 'normal' ? 'ok-text' : 'bad-text'"
|
||||
:title="parseTime(server.lastHeartbeat, 'YYYY-MM-DD HH:mm:ss')"
|
||||
>
|
||||
<span :class="server.status === 'normal' ? 'ok-text' : 'bad-text'" :title="parseTime(server.lastHeartbeat, 'YYYY-MM-DD HH:mm:ss')">
|
||||
{{ heartbeatText(server) }}
|
||||
</span>
|
||||
<n-button
|
||||
v-if="server.status !== 'normal'"
|
||||
size="small"
|
||||
@click="delJudgeServer(server.hostname)"
|
||||
>
|
||||
删掉
|
||||
</n-button>
|
||||
<n-button v-if="server.status !== 'normal'" size="small" @click="delJudgeServer(server.hostname)"> 删掉 </n-button>
|
||||
</div>
|
||||
<div class="server token">
|
||||
<span class="muted">接口凭证</span>
|
||||
<span class="mono">{{ tokenShown ? token : "••••••••••••" }}</span>
|
||||
<span class="mono">{{ tokenShown ? token : '••••••••••••' }}</span>
|
||||
<n-button size="small" @click="tokenShown = !tokenShown">
|
||||
{{ tokenShown ? "藏起来" : "显示" }}
|
||||
{{ tokenShown ? '藏起来' : '显示' }}
|
||||
</n-button>
|
||||
<n-button size="small" @click="copyToken">复制</n-button>
|
||||
</div>
|
||||
@@ -349,12 +316,10 @@ onMounted(() => {
|
||||
<h3>
|
||||
没人用的图片
|
||||
<n-button size="small" style="margin-left: 8px" :loading="scanningImages" @click="scanImages">
|
||||
{{ orphanImages ? "再查一遍" : "查一下" }}
|
||||
{{ orphanImages ? '再查一遍' : '查一下' }}
|
||||
</n-button>
|
||||
</h3>
|
||||
<n-text v-if="orphanImages && !imageGroups.length" depth="3" class="hint">
|
||||
都有地方在用
|
||||
</n-text>
|
||||
<n-text v-if="orphanImages && !imageGroups.length" depth="3" class="hint"> 都有地方在用 </n-text>
|
||||
<div v-for="group in imageGroups" :key="group.folder" class="image-group">
|
||||
<div class="image-group-head">
|
||||
{{ group.label }}
|
||||
@@ -368,21 +333,10 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="thumbs">
|
||||
<div v-for="image in group.images" :key="image.name" class="thumb">
|
||||
<a
|
||||
:href="image.url"
|
||||
target="_blank"
|
||||
:title="`${image.name} · ${parseTime(new Date(image.createTime * 1000), 'YYYY-MM-DD')}`"
|
||||
>
|
||||
<a :href="image.url" target="_blank" :title="`${image.name} · ${parseTime(new Date(image.createTime * 1000), 'YYYY-MM-DD')}`">
|
||||
<img :src="image.url" alt="" loading="lazy" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="thumb-delete"
|
||||
title="删掉"
|
||||
@click="deleteImages({ folder: image.folder, name: image.name })"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button type="button" class="thumb-delete" title="删掉" @click="deleteImages({ folder: image.folder, name: image.name })">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,13 +349,7 @@ onMounted(() => {
|
||||
</h3>
|
||||
<n-text depth="3" class="hint">删题时测试数据留在了磁盘上(故意的,删错了还能捞回来)</n-text>
|
||||
<div class="chips">
|
||||
<n-tag
|
||||
v-for="item in testcases"
|
||||
:key="item.id"
|
||||
closable
|
||||
:bordered="false"
|
||||
@close="deleteTestcase(item.id)"
|
||||
>
|
||||
<n-tag v-for="item in testcases" :key="item.id" closable :bordered="false" @close="deleteTestcase(item.id)">
|
||||
{{ item.id }}
|
||||
</n-tag>
|
||||
</div>
|
||||
@@ -416,7 +364,7 @@ onMounted(() => {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -430,7 +378,7 @@ h3 {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.field,
|
||||
@@ -445,7 +393,7 @@ h3 {
|
||||
.field-label {
|
||||
width: 60px;
|
||||
font-size: 14px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.hint {
|
||||
@@ -456,7 +404,7 @@ h3 {
|
||||
width: 50px;
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.chips {
|
||||
@@ -489,8 +437,8 @@ h3 {
|
||||
height: 72px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.actionColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
@@ -535,23 +483,23 @@ h3 {
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: v-bind("theme.successColor");
|
||||
background: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.dot.bad {
|
||||
background: v-bind("theme.errorColor");
|
||||
background: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.ok-text {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.bad-text {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.token {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { DashboardInfo } from "@oj2/contract"
|
||||
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from "chart.js"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
import { rgba } from "shared/composables/tone"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { classLabel, groupClassOptions } from "utils/classes"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { getBaseInfo, randomUser10 } from "../api"
|
||||
import type { DashboardInfo } from '@oj2/contract'
|
||||
import { BarElement, CategoryScale, Chart as ChartJS, LinearScale, Tooltip } from 'chart.js'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import { useChartTheme } from 'shared/composables/chartTheme'
|
||||
import { rgba } from 'shared/composables/tone'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { classLabel, groupClassOptions } from 'utils/classes'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import { getBaseInfo, randomUser10 } from '../api'
|
||||
|
||||
/**
|
||||
* 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、
|
||||
@@ -39,20 +39,20 @@ onMounted(async () => {
|
||||
const siteTiles = computed(() => {
|
||||
const site = info.value?.site
|
||||
if (!site) return []
|
||||
const n = (value: number) => value.toLocaleString("zh-CN")
|
||||
const n = (value: number) => value.toLocaleString('zh-CN')
|
||||
return [
|
||||
{ label: "学生", value: n(site.students), note: `老师 ${site.teachers} 人` },
|
||||
{ label: '学生', value: n(site.students), note: `老师 ${site.teachers} 人` },
|
||||
{
|
||||
label: "题目",
|
||||
label: '题目',
|
||||
value: n(site.problems),
|
||||
note: `学生看得到 ${n(site.visibleProblems)} 道`,
|
||||
to: "/admin/problem/list",
|
||||
to: '/admin/problem/list',
|
||||
},
|
||||
{ label: "提交", value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` },
|
||||
{ label: "这周做题的人", value: n(site.weekActive), note: "周一起交过题的" },
|
||||
{ label: "比赛", value: n(site.contests), note: "", to: "/admin/contest/list" },
|
||||
{ label: "题单", value: n(site.problemSets), note: "", to: "/admin/problemset/list" },
|
||||
{ label: "自学", value: n(site.tutorials), note: "课", to: "/admin/tutorial/list" },
|
||||
{ label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` },
|
||||
{ label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' },
|
||||
{ label: '比赛', value: n(site.contests), note: '', to: '/admin/contest/list' },
|
||||
{ label: '题单', value: n(site.problemSets), note: '', to: '/admin/problemset/list' },
|
||||
{ label: '自学', value: n(site.tutorials), note: '课', to: '/admin/tutorial/list' },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -64,9 +64,7 @@ const dailyData = computed(() => {
|
||||
datasets: [
|
||||
{
|
||||
data: daily.map((d) => d.count),
|
||||
backgroundColor: daily.map((_, i) =>
|
||||
i === daily.length - 1 ? theme.value.primaryColor : rgba(theme.value.primaryColor, 0.4),
|
||||
),
|
||||
backgroundColor: daily.map((_, i) => (i === daily.length - 1 ? theme.value.primaryColor : rgba(theme.value.primaryColor, 0.4))),
|
||||
borderRadius: 3,
|
||||
maxBarThickness: 22,
|
||||
},
|
||||
@@ -89,21 +87,21 @@ const dailyOptions = {
|
||||
}
|
||||
|
||||
function openSystemErrors() {
|
||||
router.push({ name: "submissions", query: { result: String(SubmissionStatus.system_error) } })
|
||||
router.push({ name: 'submissions', query: { result: String(SubmissionStatus.system_error) } })
|
||||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ label: "新建题目", to: "/admin/problem/create" },
|
||||
{ label: "新建比赛", to: "/admin/contest/create" },
|
||||
{ label: "批量建号", to: "/admin/user/generate" },
|
||||
{ label: "课堂看板", to: "/classroom" },
|
||||
{ label: '新建题目', to: '/admin/problem/create' },
|
||||
{ label: '新建比赛', to: '/admin/contest/create' },
|
||||
{ label: '批量建号', to: '/admin/user/generate' },
|
||||
{ label: '课堂看板', to: '/classroom' },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------- 抽一个人
|
||||
|
||||
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||
const luckyClass = ref<string | null>(null)
|
||||
const luckyGuy = ref("")
|
||||
const luckyGuy = ref('')
|
||||
const isRolling = ref(false)
|
||||
const pulseKey = ref(0)
|
||||
let rollingTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -123,7 +121,7 @@ async function draw() {
|
||||
const prefix = `ks${luckyClass.value}`
|
||||
const names = (await randomUser10(prefix)).map((name) => name.slice(prefix.length) || name)
|
||||
if (!names.length) {
|
||||
luckyGuy.value = "这个班没有人"
|
||||
luckyGuy.value = '这个班没有人'
|
||||
return
|
||||
}
|
||||
stopRolling()
|
||||
@@ -142,7 +140,7 @@ async function draw() {
|
||||
|
||||
watch(luckyClass, () => {
|
||||
stopRolling()
|
||||
luckyGuy.value = ""
|
||||
luckyGuy.value = ''
|
||||
})
|
||||
onUnmounted(stopRolling)
|
||||
</script>
|
||||
@@ -159,12 +157,7 @@ onUnmounted(stopRolling)
|
||||
<div class="tile">
|
||||
<span class="label">今天提交</span>
|
||||
<b>{{ info.todaySubmissionCount }} 次</b>
|
||||
<button
|
||||
v-if="info.todaySystemErrorCount"
|
||||
type="button"
|
||||
class="link bad-note"
|
||||
@click="openSystemErrors"
|
||||
>
|
||||
<button v-if="info.todaySystemErrorCount" type="button" class="link bad-note" @click="openSystemErrors">
|
||||
系统错误 {{ info.todaySystemErrorCount }} 次 →
|
||||
</button>
|
||||
<span v-else class="note">系统错误 0 次</span>
|
||||
@@ -176,14 +169,8 @@ onUnmounted(stopRolling)
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">正在上课</span>
|
||||
<b>{{ info.activeClass ? classLabel(info.activeClass) : "没有" }}</b>
|
||||
<router-link
|
||||
v-if="info.activeClass"
|
||||
class="link"
|
||||
:to="{ name: 'classroom board', query: { className: info.activeClass } }"
|
||||
>
|
||||
去课堂看板 →
|
||||
</router-link>
|
||||
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b>
|
||||
<router-link v-if="info.activeClass" class="link" :to="{ name: 'classroom board', query: { className: info.activeClass } }"> 去课堂看板 → </router-link>
|
||||
<span v-else class="note">最近两小时没有哪个班在交题</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,19 +207,12 @@ onUnmounted(stopRolling)
|
||||
|
||||
<div class="lucky">
|
||||
<b class="lucky-title">抽一个人</b>
|
||||
<n-select
|
||||
v-model:value="luckyClass"
|
||||
:options="classOptions"
|
||||
filterable
|
||||
placeholder="选班级"
|
||||
aria-label="班级"
|
||||
class="lucky-select"
|
||||
/>
|
||||
<n-select v-model:value="luckyClass" :options="classOptions" filterable placeholder="选班级" aria-label="班级" class="lucky-select" />
|
||||
<span :key="pulseKey" class="lucky-name" :class="{ pulse: !isRolling && luckyGuy }">
|
||||
{{ luckyGuy }}
|
||||
</span>
|
||||
<n-button type="primary" :disabled="!luckyClass || isRolling" @click="draw">
|
||||
{{ luckyGuy ? "再抽一个" : "抽" }}
|
||||
{{ luckyGuy ? '再抽一个' : '抽' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -251,13 +231,13 @@ onUnmounted(stopRolling)
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.label,
|
||||
.note {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.tile b {
|
||||
@@ -266,12 +246,12 @@ onUnmounted(stopRolling)
|
||||
}
|
||||
|
||||
.tile b.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.tile b.bad,
|
||||
.bad-note {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.bad-note {
|
||||
@@ -285,7 +265,7 @@ onUnmounted(stopRolling)
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -307,7 +287,7 @@ onUnmounted(stopRolling)
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -318,14 +298,14 @@ onUnmounted(stopRolling)
|
||||
}
|
||||
|
||||
.site-tile.clickable:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.chart-head {
|
||||
@@ -350,7 +330,7 @@ onUnmounted(stopRolling)
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.lucky-select {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AdminProblemRow, AdminProblemListItem } from "utils/types"
|
||||
import type { AdminProblemRow, AdminProblemListItem } from 'utils/types'
|
||||
|
||||
// 把后端的列表项塑形成管理端列表行,与请求逻辑解耦。
|
||||
export function toAdminProblemRow(result: AdminProblemListItem): AdminProblemRow {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type {
|
||||
Exercise,
|
||||
ExerciseType,
|
||||
@@ -11,8 +11,8 @@ import type {
|
||||
ExercisePredictData,
|
||||
ExerciseDebugData,
|
||||
ExerciseGroupData,
|
||||
} from "utils/types"
|
||||
import { getAdminExercises, createExercise, updateExercise, deleteExercise } from "admin/api"
|
||||
} from 'utils/types'
|
||||
import { getAdminExercises, createExercise, updateExercise, deleteExercise } from 'admin/api'
|
||||
|
||||
/**
|
||||
* 编辑页右栏的「练一练」:一道一行,写着它在正文的哪一节;新建的直接插到正文光标处,
|
||||
@@ -34,85 +34,85 @@ const editingId = ref<number | null>(null)
|
||||
/** 编辑的这道有几个人做过;为 0 时不问要不要清空 */
|
||||
const editingAttempts = ref(0)
|
||||
const resetAttempts = ref(false)
|
||||
const formType = ref<ExerciseType>("mcq")
|
||||
const formType = ref<ExerciseType>('mcq')
|
||||
const formOrder = ref(0)
|
||||
|
||||
const mcqQuestion = ref("")
|
||||
const mcqOptions = ref(["", ""])
|
||||
const mcqQuestion = ref('')
|
||||
const mcqOptions = ref(['', ''])
|
||||
const mcqAnswer = ref<number[]>([])
|
||||
|
||||
const sortQuestion = ref("")
|
||||
const sortCode = ref("")
|
||||
const sortQuestion = ref('')
|
||||
const sortCode = ref('')
|
||||
|
||||
const fillQuestion = ref("")
|
||||
const fillCode = ref("")
|
||||
const fillQuestion = ref('')
|
||||
const fillCode = ref('')
|
||||
|
||||
const matchQuestion = ref("")
|
||||
const matchLeft = ref("")
|
||||
const matchRight = ref("")
|
||||
const matchQuestion = ref('')
|
||||
const matchLeft = ref('')
|
||||
const matchRight = ref('')
|
||||
|
||||
const predictQuestion = ref("")
|
||||
const predictCode = ref("")
|
||||
const predictAnswer = ref("")
|
||||
const predictQuestion = ref('')
|
||||
const predictCode = ref('')
|
||||
const predictAnswer = ref('')
|
||||
|
||||
const debugQuestion = ref("")
|
||||
const debugCode = ref("")
|
||||
const debugQuestion = ref('')
|
||||
const debugCode = ref('')
|
||||
const debugAnswer = ref<number[]>([])
|
||||
const debugExplanation = ref("")
|
||||
const debugExplanation = ref('')
|
||||
|
||||
const groupQuestion = ref("")
|
||||
const groupBuckets = ref("")
|
||||
const groupItems = ref("")
|
||||
const groupQuestion = ref('')
|
||||
const groupBuckets = ref('')
|
||||
const groupItems = ref('')
|
||||
|
||||
const debugLines = computed(() => (debugCode.value === "" ? [] : debugCode.value.split("\n")))
|
||||
const debugLines = computed(() => (debugCode.value === '' ? [] : debugCode.value.split('\n')))
|
||||
|
||||
async function load() {
|
||||
exercises.value = await getAdminExercises(props.tutorialId)
|
||||
emit("change", exercises.value)
|
||||
emit('change', exercises.value)
|
||||
}
|
||||
|
||||
/** 这道题在正文的哪一节:占位符前面最近的二级标题;没放进正文就是 null */
|
||||
function placeOf(id: number) {
|
||||
const at = props.content.indexOf(`[[exercise:${id}]]`)
|
||||
if (at < 0) return null
|
||||
const before = props.content.slice(0, at).split("\n")
|
||||
const before = props.content.slice(0, at).split('\n')
|
||||
for (let i = before.length - 1; i >= 0; i--) {
|
||||
const heading = before[i]!.match(/^##\s+(.+)$/)
|
||||
if (heading) return heading[1]!.trim()
|
||||
}
|
||||
return "开头"
|
||||
return '开头'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function resetForms() {
|
||||
mcqQuestion.value = ""
|
||||
mcqOptions.value = ["", ""]
|
||||
mcqQuestion.value = ''
|
||||
mcqOptions.value = ['', '']
|
||||
mcqAnswer.value = []
|
||||
sortQuestion.value = ""
|
||||
sortCode.value = ""
|
||||
fillQuestion.value = ""
|
||||
fillCode.value = ""
|
||||
matchQuestion.value = ""
|
||||
matchLeft.value = ""
|
||||
matchRight.value = ""
|
||||
predictQuestion.value = ""
|
||||
predictCode.value = ""
|
||||
predictAnswer.value = ""
|
||||
debugQuestion.value = ""
|
||||
debugCode.value = ""
|
||||
sortQuestion.value = ''
|
||||
sortCode.value = ''
|
||||
fillQuestion.value = ''
|
||||
fillCode.value = ''
|
||||
matchQuestion.value = ''
|
||||
matchLeft.value = ''
|
||||
matchRight.value = ''
|
||||
predictQuestion.value = ''
|
||||
predictCode.value = ''
|
||||
predictAnswer.value = ''
|
||||
debugQuestion.value = ''
|
||||
debugCode.value = ''
|
||||
debugAnswer.value = []
|
||||
debugExplanation.value = ""
|
||||
groupQuestion.value = ""
|
||||
groupBuckets.value = ""
|
||||
groupItems.value = ""
|
||||
debugExplanation.value = ''
|
||||
groupQuestion.value = ''
|
||||
groupBuckets.value = ''
|
||||
groupItems.value = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
editingAttempts.value = 0
|
||||
resetAttempts.value = false
|
||||
formType.value = "mcq"
|
||||
formType.value = 'mcq'
|
||||
formOrder.value = exercises.value.length
|
||||
resetForms()
|
||||
showForm.value = true
|
||||
@@ -125,41 +125,41 @@ function openEdit(ex: Exercise) {
|
||||
formType.value = ex.type
|
||||
formOrder.value = ex.order
|
||||
resetForms()
|
||||
if (ex.type === "mcq") {
|
||||
if (ex.type === 'mcq') {
|
||||
const d = ex.data as ExerciseMcqData
|
||||
mcqQuestion.value = d.question
|
||||
mcqOptions.value = [...d.options]
|
||||
mcqAnswer.value = [...d.answer]
|
||||
} else if (ex.type === "sort") {
|
||||
} else if (ex.type === 'sort') {
|
||||
const d = ex.data as ExerciseSortData
|
||||
sortQuestion.value = d.question
|
||||
sortCode.value = d.lines.join("\n")
|
||||
} else if (ex.type === "fill") {
|
||||
sortCode.value = d.lines.join('\n')
|
||||
} else if (ex.type === 'fill') {
|
||||
const d = ex.data as ExerciseFillData
|
||||
fillQuestion.value = d.question
|
||||
fillCode.value = d.code
|
||||
} else if (ex.type === "match") {
|
||||
} else if (ex.type === 'match') {
|
||||
const d = ex.data as ExerciseMatchData
|
||||
matchQuestion.value = d.question
|
||||
matchLeft.value = d.left.join("\n")
|
||||
matchLeft.value = d.left.join('\n')
|
||||
// 按答案顺序还原右列,重存时识别答案保持为顺序对应
|
||||
matchRight.value = d.answer.map((a) => d.right[a]).join("\n")
|
||||
} else if (ex.type === "predict") {
|
||||
matchRight.value = d.answer.map((a) => d.right[a]).join('\n')
|
||||
} else if (ex.type === 'predict') {
|
||||
const d = ex.data as ExercisePredictData
|
||||
predictQuestion.value = d.question
|
||||
predictCode.value = d.code
|
||||
predictAnswer.value = d.answer.join("\n===\n")
|
||||
} else if (ex.type === "debug") {
|
||||
predictAnswer.value = d.answer.join('\n===\n')
|
||||
} else if (ex.type === 'debug') {
|
||||
const d = ex.data as ExerciseDebugData
|
||||
debugQuestion.value = d.question
|
||||
debugCode.value = d.lines.join("\n")
|
||||
debugCode.value = d.lines.join('\n')
|
||||
debugAnswer.value = [...d.answer]
|
||||
debugExplanation.value = d.explanation ?? ""
|
||||
} else if (ex.type === "group") {
|
||||
debugExplanation.value = d.explanation ?? ''
|
||||
} else if (ex.type === 'group') {
|
||||
const d = ex.data as ExerciseGroupData
|
||||
groupQuestion.value = d.question
|
||||
groupBuckets.value = d.buckets.join("\n")
|
||||
groupItems.value = d.items.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`).join("\n")
|
||||
groupBuckets.value = d.buckets.join('\n')
|
||||
groupItems.value = d.items.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`).join('\n')
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
@@ -178,102 +178,102 @@ function toggleDebug(i: number) {
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== "")
|
||||
.filter((l) => l !== '')
|
||||
}
|
||||
|
||||
function buildData(): Record<string, unknown> | null {
|
||||
if (formType.value === "mcq") {
|
||||
if (formType.value === 'mcq') {
|
||||
// 后端 exerciseDataError 也卡这三条,先在这儿报,省一次往返
|
||||
if (mcqOptions.value.length < 2) {
|
||||
message.error("选择题至少要有 2 个选项")
|
||||
message.error('选择题至少要有 2 个选项')
|
||||
return null
|
||||
}
|
||||
if (mcqOptions.value.some((option) => !option.trim())) {
|
||||
message.error("选项不能留空")
|
||||
message.error('选项不能留空')
|
||||
return null
|
||||
}
|
||||
if (mcqAnswer.value.length === 0) {
|
||||
message.error("请至少勾选一个正确答案")
|
||||
message.error('请至少勾选一个正确答案')
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: mcqQuestion.value || "下面选项中正确是哪个?",
|
||||
question: mcqQuestion.value || '下面选项中正确是哪个?',
|
||||
options: mcqOptions.value,
|
||||
answer: mcqAnswer.value,
|
||||
}
|
||||
}
|
||||
if (formType.value === "sort") {
|
||||
const lines = sortCode.value.split("\n").filter((l) => l.trim() !== "")
|
||||
if (formType.value === 'sort') {
|
||||
const lines = sortCode.value.split('\n').filter((l) => l.trim() !== '')
|
||||
if (lines.length < 2) {
|
||||
message.error("排序题至少要有 2 行代码")
|
||||
message.error('排序题至少要有 2 行代码')
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: sortQuestion.value || "将下列代码行排列为正确顺序",
|
||||
question: sortQuestion.value || '将下列代码行排列为正确顺序',
|
||||
lines,
|
||||
}
|
||||
}
|
||||
if (formType.value === "fill") {
|
||||
if (formType.value === 'fill') {
|
||||
if (!fillCode.value.trim()) {
|
||||
message.error("请填写含空位的代码")
|
||||
message.error('请填写含空位的代码')
|
||||
return null
|
||||
}
|
||||
// 学生端按 {{...}} 抠空位,没有标记就是一段谁也做不了的代码,
|
||||
// 而老师这边看不出任何异样 —— 这是这个编辑器以前唯一完全不校验的题型
|
||||
if (!/\{\{[^}]+\}\}/.test(fillCode.value)) {
|
||||
message.error("代码里没有空位,用 {{答案}} 标记,多个合法答案用 | 分隔")
|
||||
message.error('代码里没有空位,用 {{答案}} 标记,多个合法答案用 | 分隔')
|
||||
return null
|
||||
}
|
||||
return { question: fillQuestion.value, code: fillCode.value }
|
||||
}
|
||||
if (formType.value === "match") {
|
||||
if (formType.value === 'match') {
|
||||
const left = splitLines(matchLeft.value)
|
||||
const right = splitLines(matchRight.value)
|
||||
if (left.length < 2 || left.length !== right.length) {
|
||||
message.error("左右两列需各至少 2 项且行数相等(按行一一对应)")
|
||||
message.error('左右两列需各至少 2 项且行数相等(按行一一对应)')
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: matchQuestion.value || "把左右两列正确连线",
|
||||
question: matchQuestion.value || '把左右两列正确连线',
|
||||
left,
|
||||
right,
|
||||
answer: left.map((_, i) => i),
|
||||
}
|
||||
}
|
||||
if (formType.value === "predict") {
|
||||
if (predictCode.value.trim() === "") {
|
||||
message.error("请填写代码")
|
||||
if (formType.value === 'predict') {
|
||||
if (predictCode.value.trim() === '') {
|
||||
message.error('请填写代码')
|
||||
return null
|
||||
}
|
||||
const answer = predictAnswer.value
|
||||
.split(/\n===\n/)
|
||||
.map((a) => a.replace(/\s+$/, ""))
|
||||
.filter((a) => a.trim() !== "")
|
||||
.map((a) => a.replace(/\s+$/, ''))
|
||||
.filter((a) => a.trim() !== '')
|
||||
if (answer.length === 0) {
|
||||
message.error("请填写至少一个正确输出")
|
||||
message.error('请填写至少一个正确输出')
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: predictQuestion.value || "这段代码会输出什么?",
|
||||
question: predictQuestion.value || '这段代码会输出什么?',
|
||||
code: predictCode.value,
|
||||
answer,
|
||||
}
|
||||
}
|
||||
if (formType.value === "debug") {
|
||||
const lines = debugCode.value.split("\n")
|
||||
if (formType.value === 'debug') {
|
||||
const lines = debugCode.value.split('\n')
|
||||
const answer = debugAnswer.value.filter((i) => i < lines.length).sort((a, b) => a - b)
|
||||
if (lines.length === 0 || answer.length === 0) {
|
||||
message.error("请填写代码并勾选至少一行错误")
|
||||
message.error('请填写代码并勾选至少一行错误')
|
||||
return null
|
||||
}
|
||||
const data: Record<string, unknown> = {
|
||||
question: debugQuestion.value || "下面代码哪几行有错?",
|
||||
question: debugQuestion.value || '下面代码哪几行有错?',
|
||||
lines,
|
||||
answer,
|
||||
}
|
||||
if (debugExplanation.value.trim() !== "") {
|
||||
if (debugExplanation.value.trim() !== '') {
|
||||
data.explanation = debugExplanation.value.trim()
|
||||
}
|
||||
return data
|
||||
@@ -281,21 +281,21 @@ function buildData(): Record<string, unknown> | null {
|
||||
// group
|
||||
const buckets = splitLines(groupBuckets.value)
|
||||
if (buckets.length < 2) {
|
||||
message.error("请至少填写 2 个分组")
|
||||
message.error('请至少填写 2 个分组')
|
||||
return null
|
||||
}
|
||||
const items: string[] = []
|
||||
const answer: number[] = []
|
||||
for (const line of groupItems.value.split("\n")) {
|
||||
if (line.trim() === "") continue
|
||||
const parts = line.split("=>")
|
||||
for (const line of groupItems.value.split('\n')) {
|
||||
if (line.trim() === '') continue
|
||||
const parts = line.split('=>')
|
||||
if (parts.length !== 2) {
|
||||
message.error(`项目格式应为「项目 => 分组名」:${line}`)
|
||||
return null
|
||||
}
|
||||
const item = parts[0].trim()
|
||||
const bucket = buckets.indexOf(parts[1].trim())
|
||||
if (item === "" || bucket === -1) {
|
||||
if (item === '' || bucket === -1) {
|
||||
message.error(`项目或分组名无效:${line}`)
|
||||
return null
|
||||
}
|
||||
@@ -303,11 +303,11 @@ function buildData(): Record<string, unknown> | null {
|
||||
answer.push(bucket)
|
||||
}
|
||||
if (items.length === 0) {
|
||||
message.error("请至少填写一个项目")
|
||||
message.error('请至少填写一个项目')
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: groupQuestion.value || "把下列项目归类到正确的分组",
|
||||
question: groupQuestion.value || '把下列项目归类到正确的分组',
|
||||
buckets,
|
||||
items,
|
||||
answer,
|
||||
@@ -327,7 +327,7 @@ async function save() {
|
||||
order: formOrder.value,
|
||||
resetAttempts: resetAttempts.value,
|
||||
})
|
||||
message.success(resetAttempts.value ? "练习题已更新,学生的作答已清空" : "练习题已更新")
|
||||
message.success(resetAttempts.value ? '练习题已更新,学生的作答已清空' : '练习题已更新')
|
||||
} else {
|
||||
const created = await createExercise({
|
||||
tutorialId: props.tutorialId,
|
||||
@@ -335,37 +335,37 @@ async function save() {
|
||||
data,
|
||||
order: formOrder.value,
|
||||
})
|
||||
message.success("已新建,插到了正文光标处")
|
||||
emit("insert", created.id)
|
||||
message.success('已新建,插到了正文光标处')
|
||||
emit('insert', created.id)
|
||||
}
|
||||
showForm.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(errorMessage(e, "保存失败"))
|
||||
message.error(errorMessage(e, '保存失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
dialog.warning({
|
||||
title: "删除练习题",
|
||||
content: "此操作不可撤销",
|
||||
positiveText: "删除",
|
||||
title: '删除练习题',
|
||||
content: '此操作不可撤销',
|
||||
positiveText: '删除',
|
||||
onPositiveClick: async () => {
|
||||
await deleteExercise(id)
|
||||
message.success("已删除")
|
||||
message.success('已删除')
|
||||
await load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const TYPE_NAMES: Record<ExerciseType, string> = {
|
||||
mcq: "选择",
|
||||
sort: "排序",
|
||||
fill: "填空",
|
||||
match: "连线",
|
||||
predict: "预测输出",
|
||||
debug: "找错",
|
||||
group: "分组",
|
||||
mcq: '选择',
|
||||
sort: '排序',
|
||||
fill: '填空',
|
||||
match: '连线',
|
||||
predict: '预测输出',
|
||||
debug: '找错',
|
||||
group: '分组',
|
||||
}
|
||||
|
||||
function typeName(type: ExerciseType) {
|
||||
@@ -403,12 +403,7 @@ function typeName(type: ExerciseType) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showForm"
|
||||
:title="editingId ? '编辑练习题' : '新建练习题'"
|
||||
preset="card"
|
||||
style="width: 560px"
|
||||
>
|
||||
<n-modal v-model:show="showForm" :title="editingId ? '编辑练习题' : '新建练习题'" preset="card" style="width: 560px">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="题型">
|
||||
<n-radio-group v-model:value="formType" :disabled="!!editingId">
|
||||
@@ -428,22 +423,13 @@ function typeName(type: ExerciseType) {
|
||||
|
||||
<template v-if="formType === 'mcq'">
|
||||
<n-form-item label="题目">
|
||||
<n-input
|
||||
v-model:value="mcqQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="下面选项中正确是哪个?"
|
||||
/>
|
||||
<n-input v-model:value="mcqQuestion" type="textarea" :rows="2" placeholder="下面选项中正确是哪个?" />
|
||||
</n-form-item>
|
||||
<n-form-item label="选项(勾选所有正确答案)">
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-flex v-for="(_opt, i) in mcqOptions" :key="i" align="center" :size="8">
|
||||
<n-checkbox :checked="mcqAnswer.includes(i)" @update:checked="toggleAnswer(i)" />
|
||||
<n-input
|
||||
v-model:value="mcqOptions[i]"
|
||||
:placeholder="`选项 ${String.fromCharCode(65 + i)}`"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<n-input v-model:value="mcqOptions[i]" :placeholder="`选项 ${String.fromCharCode(65 + i)}`" style="flex: 1" />
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="mcqOptions.length <= 2"
|
||||
@@ -464,12 +450,7 @@ function typeName(type: ExerciseType) {
|
||||
|
||||
<template v-else-if="formType === 'sort'">
|
||||
<n-form-item label="题目">
|
||||
<n-input
|
||||
v-model:value="sortQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="将下列代码行排列为正确顺序"
|
||||
/>
|
||||
<n-input v-model:value="sortQuestion" type="textarea" :rows="2" placeholder="将下列代码行排列为正确顺序" />
|
||||
</n-form-item>
|
||||
<n-form-item label="正确代码(每行将自动成为一道排序项)">
|
||||
<n-input
|
||||
@@ -477,19 +458,14 @@ function typeName(type: ExerciseType) {
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="在此粘贴正确的代码,保存后将自动按行拆分并乱序"
|
||||
style="font-family: "Monaco""
|
||||
style="font-family: 'Monaco'"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'fill'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="fillQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:补全下面的循环语句"
|
||||
/>
|
||||
<n-input v-model:value="fillQuestion" type="textarea" :rows="2" placeholder="例:补全下面的循环语句" />
|
||||
</n-form-item>
|
||||
<n-form-item label="含空位的代码">
|
||||
<n-input
|
||||
@@ -497,122 +473,62 @@ function typeName(type: ExerciseType) {
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="用 {{答案}} 标记空位,多个合法答案用 | 分隔,例如:for {{i|idx}} in range(10):"
|
||||
style="font-family: "Monaco""
|
||||
style="font-family: 'Monaco'"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'match'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="matchQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:把函数和它的功能连起来"
|
||||
/>
|
||||
<n-input v-model:value="matchQuestion" type="textarea" :rows="2" placeholder="例:把函数和它的功能连起来" />
|
||||
</n-form-item>
|
||||
<n-form-item label="左列(每行一项)">
|
||||
<n-input
|
||||
v-model:value="matchLeft"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="print len type"
|
||||
/>
|
||||
<n-input v-model:value="matchLeft" type="textarea" :rows="6" placeholder="print len type" />
|
||||
</n-form-item>
|
||||
<n-form-item label="右列(与左列按行一一对应,保存后右列自动乱序)">
|
||||
<n-input
|
||||
v-model:value="matchRight"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="输出内容 返回长度 返回类型"
|
||||
/>
|
||||
<n-input v-model:value="matchRight" type="textarea" :rows="6" placeholder="输出内容 返回长度 返回类型" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'predict'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="predictQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:这段代码会输出什么?"
|
||||
/>
|
||||
<n-input v-model:value="predictQuestion" type="textarea" :rows="2" placeholder="例:这段代码会输出什么?" />
|
||||
</n-form-item>
|
||||
<n-form-item label="代码">
|
||||
<n-input
|
||||
v-model:value="predictCode"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="print(1 + 2)"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
<n-input v-model:value="predictCode" type="textarea" :rows="8" placeholder="print(1 + 2)" style="font-family: 'Monaco'" />
|
||||
</n-form-item>
|
||||
<n-form-item label="正确输出(多个可接受答案之间用单独一行 === 分隔)">
|
||||
<n-input
|
||||
v-model:value="predictAnswer"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="3"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
<n-input v-model:value="predictAnswer" type="textarea" :rows="4" placeholder="3" style="font-family: 'Monaco'" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'debug'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="debugQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:下面代码哪几行有错?"
|
||||
/>
|
||||
<n-input v-model:value="debugQuestion" type="textarea" :rows="2" placeholder="例:下面代码哪几行有错?" />
|
||||
</n-form-item>
|
||||
<n-form-item label="代码(每行一项)">
|
||||
<n-input
|
||||
v-model:value="debugCode"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="在此粘贴含错误的代码"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
<n-input v-model:value="debugCode" type="textarea" :rows="8" placeholder="在此粘贴含错误的代码" style="font-family: 'Monaco'" />
|
||||
</n-form-item>
|
||||
<n-form-item label="勾选错误行">
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-empty v-if="debugLines.length === 0" description="先填写代码" size="small" />
|
||||
<n-flex v-for="(line, i) in debugLines" :key="i" align="center" :size="8">
|
||||
<n-checkbox :checked="debugAnswer.includes(i)" @update:checked="toggleDebug(i)" />
|
||||
<n-text style="font-family: Monaco; white-space: pre">
|
||||
{{ i + 1 }}. {{ line }}
|
||||
</n-text>
|
||||
<n-text style="font-family: Monaco; white-space: pre"> {{ i + 1 }}. {{ line }} </n-text>
|
||||
</n-flex>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="错误说明(可选,提交后展示)">
|
||||
<n-input
|
||||
v-model:value="debugExplanation"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:第 2 行少了冒号"
|
||||
/>
|
||||
<n-input v-model:value="debugExplanation" type="textarea" :rows="2" placeholder="例:第 2 行少了冒号" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'group'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="groupQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:把下面的值归类到正确的类型"
|
||||
/>
|
||||
<n-input v-model:value="groupQuestion" type="textarea" :rows="2" placeholder="例:把下面的值归类到正确的类型" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分组(每行一个分组名)">
|
||||
<n-input
|
||||
v-model:value="groupBuckets"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="int float str"
|
||||
/>
|
||||
<n-input v-model:value="groupBuckets" type="textarea" :rows="4" placeholder="int float str" />
|
||||
</n-form-item>
|
||||
<n-form-item label="项目(每行「项目 => 分组名」)">
|
||||
<n-input
|
||||
@@ -620,7 +536,7 @@ function typeName(type: ExerciseType) {
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="3 => int 3.14 => float hello => str"
|
||||
style="font-family: "Monaco""
|
||||
style="font-family: 'Monaco'"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
@@ -629,9 +545,7 @@ function typeName(type: ExerciseType) {
|
||||
<template #footer>
|
||||
<n-flex align="center" :size="8">
|
||||
<!-- 改了答案或选项时勾上:按旧答案做对的人不该还算做对 -->
|
||||
<n-checkbox v-if="editingId && editingAttempts" v-model:checked="resetAttempts">
|
||||
同时清空学生的作答({{ editingAttempts }} 人做过)
|
||||
</n-checkbox>
|
||||
<n-checkbox v-if="editingId && editingAttempts" v-model:checked="resetAttempts"> 同时清空学生的作答({{ editingAttempts }} 人做过) </n-checkbox>
|
||||
<span style="flex: 1" />
|
||||
<n-button @click="showForm = false">取消</n-button>
|
||||
<n-button type="primary" @click="save">保存</n-button>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import {
|
||||
TUTORIAL_PROBLEM_MAX,
|
||||
type AdminTutorialProblem,
|
||||
type TutorialProblemCandidate,
|
||||
} from "@oj2/contract"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { getTutorialProblemCandidates, setTutorialProblems } from "admin/api"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { TUTORIAL_PROBLEM_MAX, type AdminTutorialProblem, type TutorialProblemCandidate } from '@oj2/contract'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { getTutorialProblemCandidates, setTutorialProblems } from 'admin/api'
|
||||
|
||||
/**
|
||||
* 一课结尾「去做几道题」挑哪几道(设计稿「后台 · 编辑一课」右栏)。
|
||||
@@ -16,7 +12,7 @@ const props = defineProps<{ tutorialId: number; problems: AdminTutorialProblem[]
|
||||
const emit = defineEmits<{ update: [problems: AdminTutorialProblem[]] }>()
|
||||
const message = useMessage()
|
||||
|
||||
const keyword = ref("")
|
||||
const keyword = ref('')
|
||||
const candidates = ref<TutorialProblemCandidate[]>([])
|
||||
const dragIndex = ref<number | null>(null)
|
||||
|
||||
@@ -38,14 +34,14 @@ onMounted(search)
|
||||
|
||||
async function save(next: AdminTutorialProblem[]) {
|
||||
const before = props.problems
|
||||
emit("update", next)
|
||||
emit('update', next)
|
||||
try {
|
||||
await setTutorialProblems(
|
||||
props.tutorialId,
|
||||
next.map((item) => item.id),
|
||||
)
|
||||
} catch (err) {
|
||||
emit("update", before)
|
||||
emit('update', before)
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
@@ -89,9 +85,7 @@ function drop(target: number) {
|
||||
@dragover.prevent
|
||||
@drop="drop(index)"
|
||||
>
|
||||
<span class="handle" aria-hidden="true"
|
||||
><Icon icon="ph:dots-six-vertical-bold" :width="14"
|
||||
/></span>
|
||||
<span class="handle" aria-hidden="true"><Icon icon="ph:dots-six-vertical-bold" :width="14" /></span>
|
||||
<span class="pid">{{ problem.displayId }}</span>
|
||||
<span class="name">{{ problem.title }}</span>
|
||||
<span v-if="problem.mustDo" class="must">必会</span>
|
||||
@@ -107,7 +101,7 @@ function drop(target: number) {
|
||||
<span class="pid">{{ item.displayId }}</span>
|
||||
<span class="name">{{ item.title }}</span>
|
||||
<span v-if="item.mustDo" class="must">必会</span>
|
||||
<span v-else class="tags">{{ item.tags.join(" · ") }}</span>
|
||||
<span v-else class="tags">{{ item.tags.join(' · ') }}</span>
|
||||
<n-button size="tiny" @click="add(item)">
|
||||
<template #icon><Icon icon="ph:plus" /></template>
|
||||
加
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "@iconify/vue"
|
||||
import type { AdminTutorialProblem } from "@oj2/contract"
|
||||
import { errorMessage } from "utils/api"
|
||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||
import type { Exercise, TutorialEdit, TutorialListItem } from "utils/types"
|
||||
import { parseLesson } from "oj/learn/composables/useLessonParse"
|
||||
import { joinTitle, lessonPath, splitTitle } from "oj/learn/lesson"
|
||||
import LessonBody from "oj/learn/components/LessonBody.vue"
|
||||
import "oj/learn/learn.css"
|
||||
import {
|
||||
createTutorial,
|
||||
deleteTutorial,
|
||||
getTutorial,
|
||||
getTutorialList,
|
||||
updateTutorial,
|
||||
} from "../api"
|
||||
import ExerciseManager from "./components/ExerciseManager.vue"
|
||||
import RelatedProblems from "./components/RelatedProblems.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { AdminTutorialProblem } from '@oj2/contract'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
|
||||
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
|
||||
import { parseLesson } from 'oj/learn/composables/useLessonParse'
|
||||
import { joinTitle, lessonPath, splitTitle } from 'oj/learn/lesson'
|
||||
import LessonBody from 'oj/learn/components/LessonBody.vue'
|
||||
import 'oj/learn/learn.css'
|
||||
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
|
||||
import ExerciseManager from './components/ExerciseManager.vue'
|
||||
import RelatedProblems from './components/RelatedProblems.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
/**
|
||||
* 编辑一课(设计稿「后台 · 编辑一课」):知识点 / 情境 / 项目三个字段,正文左边写、
|
||||
@@ -30,32 +24,32 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const isCreate = computed(() => route.name === "admin tutorial create")
|
||||
const isCreate = computed(() => route.name === 'admin tutorial create')
|
||||
|
||||
// 手机上左右分栏各剩一百多像素,改成正文「写 / 预览」切着看
|
||||
const { isMobile } = useBreakpoints()
|
||||
const pane = ref<"source" | "preview">("source")
|
||||
const pane = ref<'source' | 'preview'>('source')
|
||||
|
||||
const tutorial = reactive<TutorialEdit>({
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
project: typeof route.query.project === "string" ? route.query.project : "",
|
||||
title: '',
|
||||
content: '',
|
||||
project: typeof route.query.project === 'string' ? route.query.project : '',
|
||||
isPublic: false,
|
||||
order: 0,
|
||||
type: route.query.type === "c" ? "c" : "python",
|
||||
type: route.query.type === 'c' ? 'c' : 'python',
|
||||
})
|
||||
const kp = ref("")
|
||||
const scene = ref("")
|
||||
const kp = ref('')
|
||||
const scene = ref('')
|
||||
const problems = ref<AdminTutorialProblem[]>([])
|
||||
const exercises = ref<Exercise[]>([])
|
||||
const allTutorials = ref<{ [key: string]: TutorialListItem[] }>({ python: [], c: [] })
|
||||
const saving = ref(false)
|
||||
const editor = useTemplateRef<InstanceType<typeof MarkdownEditor>>("editor")
|
||||
const editor = useTemplateRef<InstanceType<typeof MarkdownEditor>>('editor')
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "Python", value: "python" },
|
||||
{ label: "C 语言", value: "c" },
|
||||
{ label: 'Python', value: 'python' },
|
||||
{ label: 'C 语言', value: 'c' },
|
||||
]
|
||||
|
||||
const siblings = computed(() => allTutorials.value[tutorial.type] ?? [])
|
||||
@@ -71,8 +65,8 @@ const step = computed(() => {
|
||||
return index >= 0 ? index + 1 : null
|
||||
})
|
||||
const heading = computed(() => {
|
||||
if (isCreate.value) return "新建一课"
|
||||
const label = tutorial.type === "c" ? "C 语言" : "Python"
|
||||
if (isCreate.value) return '新建一课'
|
||||
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
|
||||
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
|
||||
})
|
||||
const preview = computed(() => parseLesson(tutorial.content, exercises.value))
|
||||
@@ -101,18 +95,18 @@ async function init() {
|
||||
async function submit() {
|
||||
tutorial.title = joinTitle(kp.value, scene.value)
|
||||
if (!kp.value.trim() || !tutorial.content.trim()) {
|
||||
message.error("知识点和正文必填")
|
||||
message.error('知识点和正文必填')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (isCreate.value) {
|
||||
const created = await createTutorial(tutorial)
|
||||
message.success("已新建,接着可以加练一练和相关题")
|
||||
router.replace({ name: "admin tutorial edit", params: { tutorialID: created.id } })
|
||||
message.success('已新建,接着可以加练一练和相关题')
|
||||
router.replace({ name: 'admin tutorial edit', params: { tutorialID: created.id } })
|
||||
} else {
|
||||
await updateTutorial(tutorial)
|
||||
message.success("已保存")
|
||||
message.success('已保存')
|
||||
allTutorials.value = await getTutorialList().catch(() => allTutorials.value)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -125,8 +119,8 @@ async function submit() {
|
||||
async function remove() {
|
||||
try {
|
||||
await deleteTutorial(tutorial.id)
|
||||
message.success("已删除")
|
||||
router.push({ name: "admin tutorial list" })
|
||||
message.success('已删除')
|
||||
router.push({ name: 'admin tutorial list' })
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
@@ -137,7 +131,7 @@ function insertExercise(id: number) {
|
||||
}
|
||||
|
||||
function openStudentView() {
|
||||
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, "_blank")
|
||||
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, '_blank')
|
||||
}
|
||||
|
||||
watch(() => props.tutorialID, init)
|
||||
@@ -147,9 +141,7 @@ onMounted(init)
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="top">
|
||||
<router-link :to="{ name: 'admin tutorial list' }" class="back">
|
||||
<Icon icon="ph:caret-left-bold" :width="13" />教程
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'admin tutorial list' }" class="back"> <Icon icon="ph:caret-left-bold" :width="13" />教程 </router-link>
|
||||
<span class="slash">/</span>
|
||||
<span class="heading">{{ heading }}</span>
|
||||
<span class="grow" />
|
||||
@@ -181,15 +173,7 @@ onMounted(init)
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>项目</span>
|
||||
<n-select
|
||||
v-model:value="tutorial.project"
|
||||
:options="projectOptions"
|
||||
filterable
|
||||
tag
|
||||
clearable
|
||||
placeholder="选一个,或者打字新建"
|
||||
style="width: 250px"
|
||||
/>
|
||||
<n-select v-model:value="tutorial.project" :options="projectOptions" filterable tag clearable placeholder="选一个,或者打字新建" style="width: 250px" />
|
||||
</label>
|
||||
<label v-if="isCreate" class="field">
|
||||
<span>语言</span>
|
||||
@@ -212,12 +196,7 @@ onMounted(init)
|
||||
</div>
|
||||
<div class="split" :class="isMobile ? `only-${pane}` : ''">
|
||||
<div class="source">
|
||||
<MarkdownEditor
|
||||
ref="editor"
|
||||
v-model:value="tutorial.content"
|
||||
:code-lang="tutorial.type"
|
||||
height="100%"
|
||||
/>
|
||||
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
|
||||
</div>
|
||||
<div class="preview">
|
||||
<LessonBody
|
||||
@@ -233,22 +212,11 @@ onMounted(init)
|
||||
|
||||
<aside class="side">
|
||||
<section class="card pad">
|
||||
<ExerciseManager
|
||||
v-if="tutorial.id"
|
||||
:tutorial-id="tutorial.id"
|
||||
:content="tutorial.content"
|
||||
@insert="insertExercise"
|
||||
@change="exercises = $event"
|
||||
/>
|
||||
<ExerciseManager v-if="tutorial.id" :tutorial-id="tutorial.id" :content="tutorial.content" @insert="insertExercise" @change="exercises = $event" />
|
||||
<div v-else class="muted">保存之后就能加练一练</div>
|
||||
</section>
|
||||
<section class="card pad">
|
||||
<RelatedProblems
|
||||
v-if="tutorial.id"
|
||||
:tutorial-id="tutorial.id"
|
||||
:problems="problems"
|
||||
@update="problems = $event"
|
||||
/>
|
||||
<RelatedProblems v-if="tutorial.id" :tutorial-id="tutorial.id" :problems="problems" @update="problems = $event" />
|
||||
<div v-else class="muted">保存之后就能挑相关题</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { TutorialListItem } from "utils/types"
|
||||
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
|
||||
import ProjectTile from "oj/learn/components/ProjectTile.vue"
|
||||
import "oj/learn/learn.css"
|
||||
import {
|
||||
getTutorialList,
|
||||
renameTutorialProject,
|
||||
reorderTutorials,
|
||||
setTutorialVisibility,
|
||||
} from "../api"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { TutorialListItem } from 'utils/types'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import ProjectTile from 'oj/learn/components/ProjectTile.vue'
|
||||
import 'oj/learn/learn.css'
|
||||
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
|
||||
|
||||
/**
|
||||
* 教程列表(设计稿「后台 · 教程列表」):按项目分组,拖动排序;每课看得到几道练一练、
|
||||
@@ -20,14 +15,14 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
const type = ref<TutorialType>(route.query.type === "c" ? "c" : "python")
|
||||
const type = ref<TutorialType>(route.query.type === 'c' ? 'c' : 'python')
|
||||
const all = ref<{ [key: string]: TutorialListItem[] }>({ python: [], c: [] })
|
||||
const collapsed = ref(new Set<string>())
|
||||
const renaming = ref<string | null>(null)
|
||||
const renameTo = ref("")
|
||||
const renameTo = ref('')
|
||||
const dragId = ref<number | null>(null)
|
||||
const showNewProject = ref(false)
|
||||
const newProject = ref("")
|
||||
const newProject = ref('')
|
||||
|
||||
const list = computed(() => all.value[type.value] ?? [])
|
||||
const groups = computed(() =>
|
||||
@@ -54,9 +49,7 @@ const publicStep = computed(() => {
|
||||
async function load() {
|
||||
all.value = await getTutorialList()
|
||||
// 整组没公开又没项目的(老教程)默认收起
|
||||
collapsed.value = new Set(
|
||||
groups.value.filter((group) => group.hidden && !group.project).map((group) => group.key),
|
||||
)
|
||||
collapsed.value = new Set(groups.value.filter((group) => group.hidden && !group.project).map((group) => group.key))
|
||||
}
|
||||
|
||||
function pickType(value: TutorialType) {
|
||||
@@ -106,7 +99,7 @@ async function drop(target: TutorialListItem) {
|
||||
dragId.value = null
|
||||
if (!from || from.id === target.id) return
|
||||
if (from.project !== target.project) {
|
||||
message.warning("只能在同一个项目里拖,换项目请到编辑页里改")
|
||||
message.warning('只能在同一个项目里拖,换项目请到编辑页里改')
|
||||
return
|
||||
}
|
||||
const ids = list.value.map((item) => item.id).filter((id) => id !== from.id)
|
||||
@@ -129,14 +122,14 @@ async function drop(target: TutorialListItem) {
|
||||
}
|
||||
|
||||
function createIn(project: string) {
|
||||
router.push({ name: "admin tutorial create", query: { type: type.value, project } })
|
||||
router.push({ name: 'admin tutorial create', query: { type: type.value, project } })
|
||||
}
|
||||
|
||||
function confirmNewProject() {
|
||||
const name = newProject.value.trim()
|
||||
if (!name) return
|
||||
showNewProject.value = false
|
||||
newProject.value = ""
|
||||
newProject.value = ''
|
||||
createIn(name)
|
||||
}
|
||||
|
||||
@@ -148,9 +141,7 @@ onMounted(load)
|
||||
<div class="top">
|
||||
<span class="title">教程</span>
|
||||
<div class="seg">
|
||||
<button type="button" :class="{ on: type === 'python' }" @click="pickType('python')">
|
||||
Python
|
||||
</button>
|
||||
<button type="button" :class="{ on: type === 'python' }" @click="pickType('python')">Python</button>
|
||||
<button type="button" :class="{ on: type === 'c' }" @click="pickType('c')">C 语言</button>
|
||||
</div>
|
||||
<span class="grow" />
|
||||
@@ -178,37 +169,19 @@ onMounted(load)
|
||||
<template v-for="group in groups" :key="group.key">
|
||||
<!-- 整行都能点开合;行里的改名、加一课自己拦住,不顺带开合 -->
|
||||
<div class="row grouprow" @click="toggle(group.key)">
|
||||
<button
|
||||
type="button"
|
||||
class="fold"
|
||||
:aria-label="collapsed.has(group.key) ? '展开' : '收起'"
|
||||
>
|
||||
<Icon
|
||||
:icon="collapsed.has(group.key) ? 'ph:caret-right-bold' : 'ph:caret-down-bold'"
|
||||
:width="13"
|
||||
/>
|
||||
<button type="button" class="fold" :aria-label="collapsed.has(group.key) ? '展开' : '收起'">
|
||||
<Icon :icon="collapsed.has(group.key) ? 'ph:caret-right-bold' : 'ph:caret-down-bold'" :width="13" />
|
||||
</button>
|
||||
<ProjectTile :icon="group.icon" :tone="group.tone" :size="26" />
|
||||
<span v-if="renaming === group.project && group.project" class="rename" @click.stop>
|
||||
<n-input
|
||||
v-model:value="renameTo"
|
||||
size="small"
|
||||
style="width: 280px"
|
||||
autofocus
|
||||
@keyup.enter="saveRename"
|
||||
@keyup.esc="renaming = null"
|
||||
/>
|
||||
<n-input v-model:value="renameTo" size="small" style="width: 280px" autofocus @keyup.enter="saveRename" @keyup.esc="renaming = null" />
|
||||
<n-button size="small" type="primary" @click="saveRename">保存</n-button>
|
||||
<n-button size="small" quaternary @click="renaming = null">取消</n-button>
|
||||
</span>
|
||||
<template v-else>
|
||||
<span class="group-name">{{
|
||||
group.project || (group.hidden ? "没公开" : "没有项目")
|
||||
}}</span>
|
||||
<span class="group-name">{{ group.project || (group.hidden ? '没公开' : '没有项目') }}</span>
|
||||
<span class="muted">{{ group.items.length }} 课</span>
|
||||
<a v-if="group.project" href="#" @click.stop.prevent="startRename(group.project)"
|
||||
>改名</a
|
||||
>
|
||||
<a v-if="group.project" href="#" @click.stop.prevent="startRename(group.project)">改名</a>
|
||||
<a href="#" @click.stop.prevent="createIn(group.project)">加一课</a>
|
||||
</template>
|
||||
</div>
|
||||
@@ -224,52 +197,33 @@ onMounted(load)
|
||||
@dragover.prevent
|
||||
@drop="drop(item)"
|
||||
>
|
||||
<span class="c-drag handle" aria-hidden="true"
|
||||
><Icon icon="ph:dots-six-vertical-bold" :width="16"
|
||||
/></span>
|
||||
<span class="c-no num muted">{{ publicStep.get(item.id) ?? "—" }}</span>
|
||||
<span class="c-drag handle" aria-hidden="true"><Icon icon="ph:dots-six-vertical-bold" :width="16" /></span>
|
||||
<span class="c-no num muted">{{ publicStep.get(item.id) ?? '—' }}</span>
|
||||
<span class="c-title">
|
||||
<span class="kp">{{ splitTitle(item.title).kp }}</span>
|
||||
<span class="scene">{{ splitTitle(item.title).scene }}</span>
|
||||
</span>
|
||||
<span class="c-num c-ex num">{{ item.exerciseCount || "-" }}</span>
|
||||
<span class="c-num c-ex num">{{ item.exerciseCount || '-' }}</span>
|
||||
<span class="c-num num">
|
||||
<template v-if="item.problemCount">{{ item.problemCount }}</template>
|
||||
<span v-else class="warn">还没挑</span>
|
||||
</span>
|
||||
<span class="c-num wide num">{{ item.readers }}</span>
|
||||
<span class="c-switch">
|
||||
<n-switch
|
||||
:value="item.isPublic"
|
||||
size="small"
|
||||
:rubber-band="false"
|
||||
@update:value="toggleVisible(item)"
|
||||
/>
|
||||
<n-switch :value="item.isPublic" size="small" :rubber-band="false" @update:value="toggleVisible(item)" />
|
||||
</span>
|
||||
<router-link
|
||||
:to="{ name: 'admin tutorial edit', params: { tutorialID: item.id } }"
|
||||
class="c-edit"
|
||||
>
|
||||
编辑
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'admin tutorial edit', params: { tutorialID: item.id } }" class="c-edit"> 编辑 </router-link>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<n-modal v-model:show="showNewProject" preset="card" title="新建项目" style="width: 420px">
|
||||
<n-input
|
||||
v-model:value="newProject"
|
||||
placeholder="项目五 · 智慧物流·仓储管理"
|
||||
maxlength="64"
|
||||
@keyup.enter="confirmNewProject"
|
||||
/>
|
||||
<n-input v-model:value="newProject" placeholder="项目五 · 智慧物流·仓储管理" maxlength="64" @keyup.enter="confirmNewProject" />
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showNewProject = false">取消</n-button>
|
||||
<n-button type="primary" :disabled="!newProject.trim()" @click="confirmNewProject">
|
||||
接着写第一课
|
||||
</n-button>
|
||||
<n-button type="primary" :disabled="!newProject.trim()" @click="confirmNewProject"> 接着写第一课 </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
* 账号名单下成表格:用户名、密码两列。批量建号建完下名单、用户列表按班下名单共用这一份。
|
||||
* 开头带 BOM,Excel 打开中文才不乱码
|
||||
*/
|
||||
export function downloadAccountsCsv(
|
||||
rows: { username: string; password: string }[],
|
||||
filename: string,
|
||||
) {
|
||||
export function downloadAccountsCsv(rows: { username: string; password: string }[], filename: string) {
|
||||
const cell = (text: string) => (/[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text)
|
||||
const csv =
|
||||
"用户名,密码\n" + rows.map((it) => `${cell(it.username)},${cell(it.password)}`).join("\n")
|
||||
const link = document.createElement("a")
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }))
|
||||
const csv = '用户名,密码\n' + rows.map((it) => `${cell(it.username)},${cell(it.password)}`).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
|
||||
link.download = `${filename}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { editUser } from "admin/api"
|
||||
import type { AdminUserForm } from "utils/types"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { editUser } from 'admin/api'
|
||||
import type { AdminUserForm } from 'utils/types'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
/**
|
||||
* 用户列表一行的操作:重置密码、编辑、禁用、删除直接摆出来(用户说的:不要收进 ⋯)。
|
||||
@@ -12,10 +12,10 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "deleteUser", value: number[]): void
|
||||
(e: "userBanned", value: AdminUserForm): void
|
||||
(e: "openEditModal", value: AdminUserForm): void
|
||||
(e: "resetPassword", value: AdminUserForm): void
|
||||
(e: 'deleteUser', value: number[]): void
|
||||
(e: 'userBanned', value: AdminUserForm): void
|
||||
(e: 'openEditModal', value: AdminUserForm): void
|
||||
(e: 'resetPassword', value: AdminUserForm): void
|
||||
}>()
|
||||
|
||||
const dialog = useDialog()
|
||||
@@ -25,16 +25,16 @@ const { isMobile } = useBreakpoints()
|
||||
async function banUser() {
|
||||
props.user.isDisabled = !props.user.isDisabled
|
||||
await editUser(props.user)
|
||||
emit("userBanned", props.user)
|
||||
emit('userBanned', props.user)
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
dialog.warning({
|
||||
title: `删除 ${props.user.username}?`,
|
||||
content: "删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
onPositiveClick: () => emit("deleteUser", [props.user.id]),
|
||||
content: '删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。',
|
||||
positiveText: '删除',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: () => emit('deleteUser', [props.user.id]),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -43,7 +43,7 @@ function confirmDelete() {
|
||||
<n-button size="small" @click="emit('resetPassword', props.user)">重置密码</n-button>
|
||||
<n-button size="small" @click="emit('openEditModal', props.user)">编辑</n-button>
|
||||
<n-button size="small" @click="banUser">
|
||||
{{ props.user.isDisabled ? "解除禁用" : "禁用" }}
|
||||
{{ props.user.isDisabled ? '解除禁用' : '禁用' }}
|
||||
</n-button>
|
||||
<n-button size="small" quaternary type="error" @click="confirmDelete">删除</n-button>
|
||||
</n-flex>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
import { USER_TYPE } from "utils/constants"
|
||||
import type { AdminUserForm } from "utils/types"
|
||||
import TextCopy from 'shared/components/TextCopy.vue'
|
||||
import { USER_TYPE } from 'utils/constants'
|
||||
import type { AdminUserForm } from 'utils/types'
|
||||
|
||||
interface Props {
|
||||
user: AdminUserForm
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<{
|
||||
(e: "toggle", value: number): void
|
||||
(e: 'toggle', value: number): void
|
||||
}>()
|
||||
|
||||
/**
|
||||
@@ -21,9 +21,7 @@ defineEmits<{
|
||||
* `rawPassword` 为空时不打码:给一个点了什么也不显示的按钮没有意义,直接走原来的
|
||||
* TextCopy(渲染成空)。
|
||||
*/
|
||||
const maskable = computed(
|
||||
() => props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
|
||||
)
|
||||
const maskable = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword)
|
||||
</script>
|
||||
<template>
|
||||
<!-- 显示出来之后要能再收回去:看一眼就该关掉,不然一整页管理员密码就那么摊着,
|
||||
@@ -32,7 +30,7 @@ const maskable = computed(
|
||||
<span v-if="!props.revealed" class="dots">••••••</span>
|
||||
<TextCopy v-else>{{ props.user.rawPassword }}</TextCopy>
|
||||
<n-button size="tiny" secondary @click="$emit('toggle', props.user.id)">
|
||||
{{ props.revealed ? "隐藏" : "显示" }}
|
||||
{{ props.revealed ? '隐藏' : '显示' }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<TextCopy v-else>{{ props.user.rawPassword }}</TextCopy>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { getUserRole } from "utils/functions"
|
||||
import type { AdminUserForm } from "utils/types"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from 'utils/constants'
|
||||
import { getUserRole } from 'utils/functions'
|
||||
import type { AdminUserForm } from 'utils/types'
|
||||
import TextCopy from 'shared/components/TextCopy.vue'
|
||||
|
||||
interface Props {
|
||||
user: AdminUserForm
|
||||
@@ -15,23 +15,11 @@ const hasRoleTag = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USE
|
||||
<n-flex align="center" :size="6" :wrap="false">
|
||||
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||
<n-tag v-if="props.user.isDisabled" type="error" size="small" :bordered="false"> 已禁用 </n-tag>
|
||||
<n-tag
|
||||
v-if="hasRoleTag"
|
||||
:type="getUserRole(props.user.adminType).type"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
<n-tag v-if="hasRoleTag" :type="getUserRole(props.user.adminType).type" size="small" :bordered="false">
|
||||
{{ getUserRole(props.user.adminType).label }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="
|
||||
props.user.adminType === USER_TYPE.STUDENT_ADMIN ||
|
||||
props.user.adminType === USER_TYPE.TEACHER_ADMIN
|
||||
"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? "全部题目" : "自己的题" }}
|
||||
<n-tag v-if="props.user.adminType === USER_TYPE.STUDENT_ADMIN || props.user.adminType === USER_TYPE.TEACHER_ADMIN" size="small" :bordered="false">
|
||||
{{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? '全部题目' : '自己的题' }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -1,19 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImportUsersRequest } from "@oj2/contract"
|
||||
import { downloadAccountsCsv } from "./accountsCsv"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { classLabel } from "utils/classes"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import {
|
||||
CLASS_NAME_MAX_DIGITS,
|
||||
CLASS_NAME_MAX_VALUE,
|
||||
CLASS_NAME_MIN_DIGITS,
|
||||
CLASS_NAME_MIN_VALUE,
|
||||
} from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { checkUsernames, importUsers } from "../api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import type { ImportUsersRequest } from '@oj2/contract'
|
||||
import { downloadAccountsCsv } from './accountsCsv'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MAX_VALUE, CLASS_NAME_MIN_DIGITS, CLASS_NAME_MIN_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import { checkUsernames, importUsers } from '../api'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
/**
|
||||
* 批量建号(设计稿「后台用户与批量建号」UsGen / UsGenIssue / UsDone)。
|
||||
@@ -27,25 +22,25 @@ const message = useMessage()
|
||||
// 手机上左右两栏改成上下:先填班级和名单,预览排在下面
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const classNo = ref("")
|
||||
const rawInput = ref("")
|
||||
const classNo = ref('')
|
||||
const rawInput = ref('')
|
||||
|
||||
const classError = computed(() => {
|
||||
const value = classNo.value.trim()
|
||||
if (!value) return ""
|
||||
if (!value) return ''
|
||||
const n = Number(value)
|
||||
if (!/^\d+$/.test(value) || n < CLASS_NAME_MIN_VALUE || n > CLASS_NAME_MAX_VALUE) {
|
||||
return `班级号要是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`
|
||||
}
|
||||
return ""
|
||||
return ''
|
||||
})
|
||||
const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : ""))
|
||||
const prefix = computed(() => (classNo.value.trim() ? `ks${classNo.value.trim()}` : ''))
|
||||
|
||||
/** 名单:一行一个,前后和中间的空格都去掉(从表格里复制常带空格),空行不算 */
|
||||
const names = computed(() =>
|
||||
rawInput.value
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\s+/g, ""))
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ''))
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
@@ -54,8 +49,8 @@ const passwords = new Map<string, string>()
|
||||
function passwordOf(name: string) {
|
||||
let value = passwords.get(name)
|
||||
if (!value) {
|
||||
value = ""
|
||||
for (let j = 0; j < 6; j++) value += "123456789".charAt(Math.floor(Math.random() * 9))
|
||||
value = ''
|
||||
for (let j = 0; j < 6; j++) value += '123456789'.charAt(Math.floor(Math.random() * 9))
|
||||
passwords.set(name, value)
|
||||
}
|
||||
return value
|
||||
@@ -86,7 +81,7 @@ const duplicates = computed(() => {
|
||||
const existing = ref<{ username: string; createTime: string | null }[]>([])
|
||||
const checking = ref(false)
|
||||
watchDebounced(
|
||||
() => rows.value.map((row) => row.username).join("\n"),
|
||||
() => rows.value.map((row) => row.username).join('\n'),
|
||||
async () => {
|
||||
const usernames = [...new Set(rows.value.map((row) => row.username))]
|
||||
if (!usernames.length || classError.value || usernames.length > 500) {
|
||||
@@ -106,16 +101,8 @@ watchDebounced(
|
||||
)
|
||||
|
||||
const tooMany = computed(() => rows.value.length > 500)
|
||||
const issueCount = computed(
|
||||
() =>
|
||||
(classError.value ? 1 : 0) +
|
||||
duplicates.value.length +
|
||||
existing.value.length +
|
||||
(tooMany.value ? 1 : 0),
|
||||
)
|
||||
const canSubmit = computed(
|
||||
() => rows.value.length > 0 && !issueCount.value && !checking.value && !!prefix.value,
|
||||
)
|
||||
const issueCount = computed(() => (classError.value ? 1 : 0) + duplicates.value.length + existing.value.length + (tooMany.value ? 1 : 0))
|
||||
const canSubmit = computed(() => rows.value.length > 0 && !issueCount.value && !checking.value && !!prefix.value)
|
||||
|
||||
// ---------------------------------------------------------------- 建号
|
||||
|
||||
@@ -128,10 +115,10 @@ async function submit() {
|
||||
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重。每批一段随机后缀错开,
|
||||
// 同一个班分两批导也不会撞。这个地址不发给任何人,纯占位
|
||||
const batch = Math.random().toString(36).slice(2, 8)
|
||||
const payload: ImportUsersRequest["users"] = rows.value.map((row, i) => [
|
||||
const payload: ImportUsersRequest['users'] = rows.value.map((row, i) => [
|
||||
row.username,
|
||||
row.password,
|
||||
`${prefix.value || "user"}.${batch}.${i + 1}@example.com`,
|
||||
`${prefix.value || 'user'}.${batch}.${i + 1}@example.com`,
|
||||
row.name,
|
||||
])
|
||||
saving.value = true
|
||||
@@ -140,7 +127,7 @@ async function submit() {
|
||||
addedClasses.value = res.addedClasses ?? []
|
||||
done.value = rows.value.map(({ username, password }) => ({ username, password }))
|
||||
} catch (err) {
|
||||
message.error("没建成:" + errorMessage(err))
|
||||
message.error('没建成:' + errorMessage(err))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -148,37 +135,30 @@ async function submit() {
|
||||
|
||||
function again() {
|
||||
done.value = null
|
||||
rawInput.value = ""
|
||||
rawInput.value = ''
|
||||
addedClasses.value = []
|
||||
passwords.clear()
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!done.value) return
|
||||
downloadAccountsCsv(done.value, prefix.value || "账号")
|
||||
downloadAccountsCsv(done.value, prefix.value || '账号')
|
||||
}
|
||||
|
||||
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
|
||||
function printSlips() {
|
||||
if (!done.value) return
|
||||
const escape = (text: string) =>
|
||||
text.replace(
|
||||
/[&<>"]/g,
|
||||
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]!,
|
||||
)
|
||||
const escape = (text: string) => text.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]!)
|
||||
const slips = done.value
|
||||
.map(
|
||||
(it) =>
|
||||
`<div class="slip"><div class="u">${escape(it.username)}</div><div class="p">密码 ${escape(it.password)}</div></div>`,
|
||||
)
|
||||
.join("")
|
||||
const win = window.open("", "_blank")
|
||||
.map((it) => `<div class="slip"><div class="u">${escape(it.username)}</div><div class="p">密码 ${escape(it.password)}</div></div>`)
|
||||
.join('')
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) {
|
||||
message.warning("浏览器拦住了新窗口,允许弹出窗口后再点一次")
|
||||
message.warning('浏览器拦住了新窗口,允许弹出窗口后再点一次')
|
||||
return
|
||||
}
|
||||
win.document.write(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escape(
|
||||
classLabel(classNo.value.trim()) || "账号条",
|
||||
classLabel(classNo.value.trim()) || '账号条',
|
||||
)}</title><style>
|
||||
body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:0}
|
||||
@@ -198,9 +178,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
<div v-if="done" class="done">
|
||||
<div class="done-bar">
|
||||
<b>建好了 {{ done.length }} 个账号</b>
|
||||
<n-text v-if="addedClasses.length" depth="3">
|
||||
{{ addedClasses.map((name) => classLabel(name)).join("、") }}已加进班级列表
|
||||
</n-text>
|
||||
<n-text v-if="addedClasses.length" depth="3"> {{ addedClasses.map((name) => classLabel(name)).join('、') }}已加进班级列表 </n-text>
|
||||
<div class="spacer"></div>
|
||||
<n-button @click="download">下载名单(表格)</n-button>
|
||||
<n-button type="primary" @click="printSlips">打印账号条</n-button>
|
||||
@@ -219,51 +197,31 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
<label class="label" for="class-no">班级号</label>
|
||||
<div class="class-row">
|
||||
<span class="ks">ks</span>
|
||||
<n-input
|
||||
v-model:value="classNo"
|
||||
:input-props="{ id: 'class-no', inputmode: 'numeric' }"
|
||||
placeholder="如 2612"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-input v-model:value="classNo" :input-props="{ id: 'class-no', inputmode: 'numeric' }" placeholder="如 2612" style="width: 120px" />
|
||||
<span v-if="classNo.trim() && !classError" class="class-name">
|
||||
{{ classLabel(classNo.trim()) }}
|
||||
</span>
|
||||
</div>
|
||||
<label class="label" for="names">
|
||||
名单 <n-text depth="3" class="hint">从表格里整列复制过来,一行一个</n-text>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="rawInput"
|
||||
type="textarea"
|
||||
:input-props="{ id: 'names' }"
|
||||
:rows="isMobile ? 8 : 20"
|
||||
placeholder="一行一个名字"
|
||||
/>
|
||||
<label class="label" for="names"> 名单 <n-text depth="3" class="hint">从表格里整列复制过来,一行一个</n-text> </label>
|
||||
<n-input v-model:value="rawInput" type="textarea" :input-props="{ id: 'names' }" :rows="isMobile ? 8 : 20" placeholder="一行一个名字" />
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<template v-if="rows.length">
|
||||
<div class="head">
|
||||
<b>会建这 {{ rows.length }} 个账号</b>
|
||||
<n-tag v-if="!issueCount && !checking && prefix" type="success" :bordered="false" round>
|
||||
没有重名
|
||||
</n-tag>
|
||||
<n-tag v-if="issueCount" type="warning" :bordered="false" round>
|
||||
有 {{ issueCount }} 处要先改
|
||||
</n-tag>
|
||||
<n-tag v-if="!issueCount && !checking && prefix" type="success" :bordered="false" round> 没有重名 </n-tag>
|
||||
<n-tag v-if="issueCount" type="warning" :bordered="false" round> 有 {{ issueCount }} 处要先改 </n-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="issueCount" class="issues">
|
||||
<div v-if="classError">{{ classError }}</div>
|
||||
<div v-if="tooMany">一次最多 500 个,分两批建</div>
|
||||
<div v-for="dup in duplicates" :key="dup.line">
|
||||
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 ——
|
||||
同班同名的,名字后面加个 1、2 区分
|
||||
第 {{ dup.line }} 行「{{ dup.name }}」和第 {{ dup.firstLine }} 行重名 —— 同班同名的,名字后面加个 1、2 区分
|
||||
</div>
|
||||
<div v-for="it in existing" :key="it.username">
|
||||
{{ it.username }} 已经有这个账号了<template v-if="it.createTime"
|
||||
>({{ parseTime(it.createTime, "YYYY-MM-DD") }} 建的)</template
|
||||
>
|
||||
{{ it.username }} 已经有这个账号了<template v-if="it.createTime">({{ parseTime(it.createTime, 'YYYY-MM-DD') }} 建的)</template>
|
||||
—— 删掉这一行,或者换个名字
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,25 +246,13 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<n-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!canSubmit"
|
||||
:loading="saving || checking"
|
||||
@click="submit"
|
||||
>
|
||||
<n-button type="primary" size="large" :disabled="!canSubmit" :loading="saving || checking" @click="submit">
|
||||
建好这 {{ rows.length }} 个账号
|
||||
</n-button>
|
||||
<n-text v-if="!prefix" depth="3" class="hint" style="margin-left: 12px">
|
||||
先填班级号
|
||||
</n-text>
|
||||
<n-text v-if="!prefix" depth="3" class="hint" style="margin-left: 12px"> 先填班级号 </n-text>
|
||||
</div>
|
||||
</template>
|
||||
<n-empty
|
||||
v-else
|
||||
:description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`"
|
||||
:style="{ marginTop: isMobile ? '24px' : '80px' }"
|
||||
/>
|
||||
<n-empty v-else :description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`" :style="{ marginTop: isMobile ? '24px' : '80px' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+100
-171
@@ -1,24 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { DataTableRowKey, SelectOption, useThemeVars } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminUserForm } from "utils/types"
|
||||
import { deleteUsers, editUser, getUserList, importUsers, resetPassword } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import UserNameCell from "./components/UserNameCell.vue"
|
||||
import PasswordCell from "./components/PasswordCell.vue"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from "@oj2/contract"
|
||||
import { downloadAccountsCsv } from "./accountsCsv"
|
||||
import { classLabel, groupClassOptions } from "utils/classes"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { DataTableRowKey, SelectOption, useThemeVars } from 'naive-ui'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { usePagination } from 'shared/composables/pagination'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { AdminUserForm } from 'utils/types'
|
||||
import { deleteUsers, editUser, getUserList, importUsers, resetPassword } from '../api'
|
||||
import Actions from './components/Actions.vue'
|
||||
import UserNameCell from './components/UserNameCell.vue'
|
||||
import PasswordCell from './components/PasswordCell.vue'
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from 'utils/constants'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { NO_CLASS, STUDENT_ROLES, type ImportUsersRequest } from '@oj2/contract'
|
||||
import { downloadAccountsCsv } from './accountsCsv'
|
||||
import { classLabel, groupClassOptions } from 'utils/classes'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
/**
|
||||
* 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、
|
||||
@@ -39,14 +39,14 @@ interface UserQuery {
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<UserQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
type: useRouteQuery("type", "").value,
|
||||
orderBy: useRouteQuery("orderBy", "").value,
|
||||
className: useRouteQuery("className", "").value,
|
||||
keyword: useRouteQuery('keyword', '').value,
|
||||
type: useRouteQuery('type', '').value,
|
||||
orderBy: useRouteQuery('orderBy', '').value,
|
||||
className: useRouteQuery('className', '').value,
|
||||
})
|
||||
|
||||
const classOptions = computed(() => [
|
||||
{ label: "全部班级", value: "" },
|
||||
{ label: '全部班级', value: '' },
|
||||
...groupClassOptions(configStore.config?.classList ?? []),
|
||||
{ label: classLabel(NO_CLASS), value: NO_CLASS },
|
||||
])
|
||||
@@ -57,21 +57,21 @@ const users = ref<AdminUserForm[]>([])
|
||||
const userEditing = ref<AdminUserForm | null>(null)
|
||||
|
||||
const adminOptions = [
|
||||
{ label: "全部角色", value: "" },
|
||||
{ label: "学生", value: USER_TYPE.REGULAR_USER },
|
||||
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
{ label: '全部角色', value: '' },
|
||||
{ label: '学生', value: USER_TYPE.REGULAR_USER },
|
||||
{ label: '学生管理员', value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: '教师管理员', value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: '超级管理员', value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "最近建号", value: "" },
|
||||
{ label: "按用户名", value: "username" },
|
||||
{ label: "最近登录", value: "-last_login" },
|
||||
{ label: "在线优先", value: "-online" },
|
||||
{ label: '最近建号', value: '' },
|
||||
{ label: '按用户名', value: 'username' },
|
||||
{ label: '最近登录', value: '-last_login' },
|
||||
{ label: '在线优先', value: '-online' },
|
||||
]
|
||||
const [create, toggleCreate] = useToggle(false)
|
||||
const password = ref("")
|
||||
const password = ref('')
|
||||
// 已经点开的管理员密码,按 user id 记。同一个按钮再点一下收回去;listUsers() 里清空,
|
||||
// 所以翻页/搜索/换筛选之后也一律回到打码状态 —— 不做定时自动隐藏,也不记进 localStorage。
|
||||
const revealedPasswords = ref(new Set<number>())
|
||||
@@ -81,17 +81,13 @@ const rowKey = (row: AdminUserForm) => row.id
|
||||
|
||||
/** 上次登录:今天的写「今天 13:02」,在线的前面一个绿点;今年的写到月日;从没登录的写出来 */
|
||||
function lastLoginText(row: AdminUserForm) {
|
||||
if (!row.lastLogin) return h("span", { class: "muted" }, "从没登录")
|
||||
const day = parseTime(row.lastLogin, "YYYY-MM-DD")
|
||||
const today = parseTime(new Date().toISOString(), "YYYY-MM-DD")
|
||||
if (!row.lastLogin) return h('span', { class: 'muted' }, '从没登录')
|
||||
const day = parseTime(row.lastLogin, 'YYYY-MM-DD')
|
||||
const today = parseTime(new Date().toISOString(), 'YYYY-MM-DD')
|
||||
const text =
|
||||
day === today
|
||||
? `今天 ${parseTime(row.lastLogin, "HH:mm")}`
|
||||
: day.slice(0, 4) === today.slice(0, 4)
|
||||
? parseTime(row.lastLogin, "MM-DD HH:mm")
|
||||
: day
|
||||
return h("span", { class: "num", title: parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") }, [
|
||||
row.isOnline ? h("span", { class: "online", title: "在线" }) : null,
|
||||
day === today ? `今天 ${parseTime(row.lastLogin, 'HH:mm')}` : day.slice(0, 4) === today.slice(0, 4) ? parseTime(row.lastLogin, 'MM-DD HH:mm') : day
|
||||
return h('span', { class: 'num', title: parseTime(row.lastLogin, 'YYYY-MM-DD HH:mm:ss') }, [
|
||||
row.isOnline ? h('span', { class: 'online', title: '在线' }) : null,
|
||||
text,
|
||||
])
|
||||
}
|
||||
@@ -121,67 +117,52 @@ function renderActions(row: AdminUserForm) {
|
||||
* 密码单独一行(学生忘了密码是最常见的事,要一眼找到),操作按钮放不下就折行
|
||||
*/
|
||||
function renderMobileCard(row: AdminUserForm) {
|
||||
return h("div", { class: "m-card" }, [
|
||||
return h('div', { class: 'm-card' }, [
|
||||
h(UserNameCell, { user: row }),
|
||||
h("div", { class: "m-meta muted" }, [
|
||||
h("span", { class: "m-item" }, row.className ? classLabel(row.className) : "没填班级"),
|
||||
h("span", { class: "m-item" }, [" · ", lastLoginText(row)]),
|
||||
row.createTime
|
||||
? h("span", { class: "m-item" }, ` · 建号 ${parseTime(row.createTime, "YYYY-MM-DD")}`)
|
||||
: null,
|
||||
h('div', { class: 'm-meta muted' }, [
|
||||
h('span', { class: 'm-item' }, row.className ? classLabel(row.className) : '没填班级'),
|
||||
h('span', { class: 'm-item' }, [' · ', lastLoginText(row)]),
|
||||
row.createTime ? h('span', { class: 'm-item' }, ` · 建号 ${parseTime(row.createTime, 'YYYY-MM-DD')}`) : null,
|
||||
]),
|
||||
row.rawPassword
|
||||
? h("div", { class: "m-password" }, [
|
||||
h("span", { class: "muted" }, "密码"),
|
||||
renderPassword(row),
|
||||
])
|
||||
: null,
|
||||
row.rawPassword ? h('div', { class: 'm-password' }, [h('span', { class: 'muted' }, '密码'), renderPassword(row)]) : null,
|
||||
renderActions(row),
|
||||
])
|
||||
}
|
||||
|
||||
const mobileColumns: DataTableColumn<AdminUserForm>[] = [
|
||||
{ type: "selection" },
|
||||
{ title: "用户", key: "username", render: renderMobileCard },
|
||||
]
|
||||
const mobileColumns: DataTableColumn<AdminUserForm>[] = [{ type: 'selection' }, { title: '用户', key: 'username', render: renderMobileCard }]
|
||||
|
||||
const desktopColumns: DataTableColumn<AdminUserForm>[] = [
|
||||
{ type: "selection" },
|
||||
{ type: 'selection' },
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
title: '用户名',
|
||||
key: 'username',
|
||||
// 定宽:用户名一般就 ks + 班号 + 两三个字,原来 minWidth 让它把整行剩下的空都吃掉了(用户嫌空太多)。
|
||||
// 剩下的宽度给最后的操作列,操作直接摆出来
|
||||
width: 230,
|
||||
render: (row) => h(UserNameCell, { user: row }),
|
||||
},
|
||||
{
|
||||
title: "班级",
|
||||
key: "className",
|
||||
title: '班级',
|
||||
key: 'className',
|
||||
width: 130,
|
||||
render: (row) => h("span", { class: "muted" }, row.className ? classLabel(row.className) : "—"),
|
||||
render: (row) => h('span', { class: 'muted' }, row.className ? classLabel(row.className) : '—'),
|
||||
},
|
||||
{
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
title: '密码',
|
||||
key: 'raw_password',
|
||||
width: 150,
|
||||
render: renderPassword,
|
||||
},
|
||||
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
|
||||
{ title: '上次登录', key: 'last_login', width: 150, render: lastLoginText },
|
||||
{
|
||||
title: "建号",
|
||||
key: "create_time",
|
||||
title: '建号',
|
||||
key: 'create_time',
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ class: "num muted" },
|
||||
row.createTime ? parseTime(row.createTime, "YYYY-MM-DD") : "",
|
||||
),
|
||||
render: (row) => h('span', { class: 'num muted' }, row.createTime ? parseTime(row.createTime, 'YYYY-MM-DD') : ''),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "",
|
||||
key: 'actions',
|
||||
title: '',
|
||||
minWidth: 300,
|
||||
render: renderActions,
|
||||
},
|
||||
@@ -190,16 +171,16 @@ const desktopColumns: DataTableColumn<AdminUserForm>[] = [
|
||||
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
||||
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
{ label: '普通', value: USER_TYPE.REGULAR_USER },
|
||||
{ label: '学生管理员', value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: '教师管理员', value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: '超级管理员', value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
const problemPermissionOptions: SelectOption[] = [
|
||||
{ label: "无权限", value: PROBLEM_PERMISSION.NONE },
|
||||
{ label: "仅管理自己创建", value: PROBLEM_PERMISSION.OWN },
|
||||
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
||||
{ label: '无权限', value: PROBLEM_PERMISSION.NONE },
|
||||
{ label: '仅管理自己创建', value: PROBLEM_PERMISSION.OWN },
|
||||
{ label: '管理全部题目', value: PROBLEM_PERMISSION.ALL },
|
||||
]
|
||||
// 老师不给「无权限」:比赛题的接口要出题权限,设了就是比赛题目页全报权限不足,后端也会兜成「仅自己」
|
||||
const teacherPermissionOptions = problemPermissionOptions.slice(1)
|
||||
@@ -208,9 +189,7 @@ const { loading, track } = useListLoader()
|
||||
async function listUsers() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await track(
|
||||
getUserList(offset, query.limit, query.type, query.keyword, query.orderBy, query.className),
|
||||
)
|
||||
const res = await track(getUserList(offset, query.limit, query.type, query.keyword, query.orderBy, query.className))
|
||||
if (!res) return
|
||||
total.value = res.total
|
||||
neverLoggedIn.value = res.neverLoggedIn
|
||||
@@ -229,19 +208,17 @@ async function downloadClassAccounts() {
|
||||
try {
|
||||
const rows: AdminUserForm[] = []
|
||||
for (let offset = 0; ; offset += 250) {
|
||||
const res = await getUserList(offset, 250, "", "", "username", className)
|
||||
const res = await getUserList(offset, 250, '', '', 'username', className)
|
||||
rows.push(...res.results)
|
||||
if (!res.results.length || rows.length >= res.total) break
|
||||
}
|
||||
const students = rows.filter((it) =>
|
||||
(STUDENT_ROLES as readonly string[]).includes(it.adminType),
|
||||
)
|
||||
const students = rows.filter((it) => (STUDENT_ROLES as readonly string[]).includes(it.adminType))
|
||||
if (!students.length) {
|
||||
message.warning("这个班没有学生")
|
||||
message.warning('这个班没有学生')
|
||||
return
|
||||
}
|
||||
downloadAccountsCsv(
|
||||
students.map((it) => ({ username: it.username, password: it.rawPassword ?? "" })),
|
||||
students.map((it) => ({ username: it.username, password: it.rawPassword ?? '' })),
|
||||
`${classLabel(className)}账号`,
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -258,7 +235,7 @@ function chooseUsers(rowKeys: DataTableRowKey[]) {
|
||||
async function onDeleteUsers(ids: DataTableRowKey[] | Ref<number[]>) {
|
||||
try {
|
||||
await deleteUsers(toRaw(ids) as number[])
|
||||
message.success("删掉了")
|
||||
message.success('删掉了')
|
||||
userIDs.value = []
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
@@ -290,34 +267,34 @@ function createNewUser() {
|
||||
toggleCreate(true)
|
||||
userEditing.value = {
|
||||
id: 0,
|
||||
username: "",
|
||||
realName: "",
|
||||
username: '',
|
||||
realName: '',
|
||||
// 预填一个唯一的占位邮箱:这条路走的是导入接口,后端要求邮箱合法且不重复,
|
||||
// 留空会直接被打回。想填真邮箱就改掉它
|
||||
email: `user.${Math.random().toString(36).slice(2, 10)}@example.com`,
|
||||
// 导入接口写死建成普通用户(后端 POST /admin/users),这里不摆一个建不出来的
|
||||
// 角色。要给管理员权限,建完再用「编辑」改
|
||||
adminType: "Regular User",
|
||||
problemPermission: "None",
|
||||
adminType: 'Regular User',
|
||||
problemPermission: 'None',
|
||||
createTime: null,
|
||||
lastLogin: null,
|
||||
isDisabled: false,
|
||||
isOnline: false,
|
||||
rawPassword: null,
|
||||
className: null,
|
||||
password: "",
|
||||
password: '',
|
||||
}
|
||||
password.value = ""
|
||||
password.value = ''
|
||||
}
|
||||
|
||||
function onOpenEditModal(user: AdminUserForm) {
|
||||
userEditing.value = user
|
||||
password.value = ""
|
||||
password.value = ''
|
||||
}
|
||||
|
||||
function onCloseEditModal() {
|
||||
userEditing.value = null
|
||||
password.value = ""
|
||||
password.value = ''
|
||||
toggleCreate(false)
|
||||
}
|
||||
|
||||
@@ -325,24 +302,19 @@ async function handleEditUser() {
|
||||
if (!userEditing.value) return
|
||||
// 新建走的是导入接口,密码是必填的(后端 min(1)),留空只会换来一句 400
|
||||
if (create.value && !password.value) {
|
||||
message.error("新建用户必须填写密码")
|
||||
message.error('新建用户必须填写密码')
|
||||
return
|
||||
}
|
||||
if (password.value && password.value.length < 6) {
|
||||
message.error("密码长度不得小于 6")
|
||||
message.error('密码长度不得小于 6')
|
||||
return
|
||||
}
|
||||
// http 拦截器只对 login-required / permission-denied 自动弹提示,
|
||||
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
|
||||
try {
|
||||
if (create.value) {
|
||||
const newUser: ImportUsersRequest["users"] = [
|
||||
[
|
||||
userEditing.value.username,
|
||||
password.value,
|
||||
userEditing.value.email ?? "",
|
||||
userEditing.value.realName ?? "",
|
||||
],
|
||||
const newUser: ImportUsersRequest['users'] = [
|
||||
[userEditing.value.username, password.value, userEditing.value.email ?? '', userEditing.value.realName ?? ''],
|
||||
]
|
||||
await importUsers(newUser)
|
||||
listUsers()
|
||||
@@ -353,11 +325,11 @@ async function handleEditUser() {
|
||||
await editUser(user)
|
||||
}
|
||||
} catch (err) {
|
||||
message.error("保存失败:" + errorMessage(err))
|
||||
message.error('保存失败:' + errorMessage(err))
|
||||
return
|
||||
}
|
||||
userEditing.value = null
|
||||
password.value = ""
|
||||
password.value = ''
|
||||
toggleCreate(false)
|
||||
}
|
||||
|
||||
@@ -380,45 +352,17 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
||||
删掉就找不回来了。交过题的账号删不掉,那种请改为禁用。
|
||||
</n-popconfirm>
|
||||
<n-button @click="createNewUser">新建一个</n-button>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin user generate' })">
|
||||
批量建号
|
||||
</n-button>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin user generate' })"> 批量建号 </n-button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<n-input
|
||||
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||
v-model:value="query.keyword"
|
||||
placeholder="用户名或名字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.className"
|
||||
:options="classOptions"
|
||||
filterable
|
||||
aria-label="班级"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<n-button
|
||||
v-if="query.className && query.className !== NO_CLASS"
|
||||
:loading="downloadingClass"
|
||||
@click="downloadClassAccounts"
|
||||
>
|
||||
<n-input :style="{ width: isMobile ? '100%' : '220px' }" v-model:value="query.keyword" placeholder="用户名或名字" clearable @clear="clearQuery" />
|
||||
<n-select v-model:value="query.className" :options="classOptions" filterable aria-label="班级" style="width: 150px" />
|
||||
<n-button v-if="query.className && query.className !== NO_CLASS" :loading="downloadingClass" @click="downloadClassAccounts">
|
||||
<template #icon><Icon icon="ph:download-simple" /></template>
|
||||
下载这个班的账号
|
||||
</n-button>
|
||||
<n-select
|
||||
v-model:value="query.type"
|
||||
:options="adminOptions"
|
||||
aria-label="角色"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.orderBy"
|
||||
:options="sortOptions"
|
||||
aria-label="排序"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-select v-model:value="query.type" :options="adminOptions" aria-label="角色" style="width: 120px" />
|
||||
<n-select v-model:value="query.orderBy" :options="sortOptions" aria-label="排序" style="width: 120px" />
|
||||
<n-text depth="3">
|
||||
{{ total }} 人<template v-if="neverLoggedIn"> · {{ neverLoggedIn }} 人从没登录</template>
|
||||
</n-text>
|
||||
@@ -435,14 +379,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
||||
@update:checked-row-keys="chooseUsers"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
<n-modal
|
||||
:mask-closable="false"
|
||||
:show="!!userEditing"
|
||||
preset="card"
|
||||
:title="create ? '新建用户' : '编辑用户'"
|
||||
style="width: 700px"
|
||||
@close="onCloseEditModal"
|
||||
>
|
||||
<n-modal :mask-closable="false" :show="!!userEditing" preset="card" :title="create ? '新建用户' : '编辑用户'" style="width: 700px" @close="onCloseEditModal">
|
||||
<n-form label-placement="left" v-if="userEditing">
|
||||
<n-grid :cols="isMobile ? 1 : 2" :x-gap="16">
|
||||
<n-form-item-gi :span="1" label="用户">
|
||||
@@ -466,21 +403,13 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
||||
<n-input v-model:value="password" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="
|
||||
!create &&
|
||||
(userEditing.adminType === USER_TYPE.STUDENT_ADMIN ||
|
||||
userEditing.adminType === USER_TYPE.TEACHER_ADMIN)
|
||||
"
|
||||
v-if="!create && (userEditing.adminType === USER_TYPE.STUDENT_ADMIN || userEditing.adminType === USER_TYPE.TEACHER_ADMIN)"
|
||||
:span="1"
|
||||
label="出题权限"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="userEditing.problemPermission"
|
||||
:options="
|
||||
userEditing.adminType === USER_TYPE.TEACHER_ADMIN
|
||||
? teacherPermissionOptions
|
||||
: problemPermissionOptions
|
||||
"
|
||||
:options="userEditing.adminType === USER_TYPE.TEACHER_ADMIN ? teacherPermissionOptions : problemPermissionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
|
||||
@@ -498,7 +427,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
||||
|
||||
<style scoped>
|
||||
.user-table :deep(.muted) {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.user-table :deep(.m-card) {
|
||||
@@ -535,7 +464,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
||||
height: 7px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
background: v-bind("theme.successColor");
|
||||
background: v-bind('theme.successColor');
|
||||
vertical-align: 1px;
|
||||
}
|
||||
</style>
|
||||
+20
-29
@@ -1,12 +1,12 @@
|
||||
import { addAPIProvider } from "@iconify/vue"
|
||||
import { createPinia } from "pinia"
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
import { addAPIProvider } from '@iconify/vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import { STORAGE_KEY } from 'utils/constants'
|
||||
import storage from 'utils/storage'
|
||||
|
||||
import App from "./App.vue"
|
||||
import { admins, ojs } from "./routes"
|
||||
import App from './App.vue'
|
||||
import { admins, ojs } from './routes'
|
||||
|
||||
/**
|
||||
* iPhone 上点进字号小于 16px 的输入框(Naive 的输入框是 14px、代码编辑器 15px),
|
||||
@@ -14,12 +14,10 @@ import { admins, ojs } from "./routes"
|
||||
* maximum-scale=1 能关掉这个自动放大;iOS 10 起照样允许双指缩放,所以只给 iOS 加 ——
|
||||
* 安卓上这么写会真的禁掉双指放大,看题面图片、代码就没法放大了。
|
||||
*/
|
||||
const isIOS =
|
||||
/iPhone|iPad|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
|
||||
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
if (isIOS) {
|
||||
const viewport = document.querySelector('meta[name="viewport"]')
|
||||
viewport?.setAttribute("content", "width=device-width, initial-scale=1.0, maximum-scale=1")
|
||||
viewport?.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1')
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
@@ -35,8 +33,8 @@ app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// 现在可以安全地使用 Store
|
||||
import { useAuthModalStore } from "./shared/store/authModal"
|
||||
import { useUserStore } from "./shared/store/user"
|
||||
import { useAuthModalStore } from './shared/store/authModal'
|
||||
import { useUserStore } from './shared/store/user'
|
||||
|
||||
const authStore = useAuthModalStore()
|
||||
|
||||
@@ -46,22 +44,15 @@ router.beforeEach(async (to) => {
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
authStore.openLoginModal()
|
||||
return "/"
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (
|
||||
to.matched.some(
|
||||
(record) =>
|
||||
record.meta.requiresSuperAdmin ||
|
||||
record.meta.requiresTeacherAdmin ||
|
||||
record.meta.requiresProblemPermission,
|
||||
)
|
||||
) {
|
||||
if (to.matched.some((record) => record.meta.requiresSuperAdmin || record.meta.requiresTeacherAdmin || record.meta.requiresProblemPermission)) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
authStore.openLoginModal()
|
||||
return "/"
|
||||
return '/'
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -69,29 +60,29 @@ router.beforeEach(async (to) => {
|
||||
try {
|
||||
await userStore.getMyProfile()
|
||||
} catch {
|
||||
return "/"
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
if (to.matched.some((record) => record.meta.requiresSuperAdmin)) {
|
||||
if (!userStore.isSuperAdmin) {
|
||||
return "/"
|
||||
return '/'
|
||||
}
|
||||
} else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
|
||||
if (!userStore.isTeacherOrAbove) {
|
||||
return "/"
|
||||
return '/'
|
||||
}
|
||||
} else if (to.matched.some((record) => record.meta.requiresProblemPermission)) {
|
||||
if (!userStore.hasProblemPermission) {
|
||||
return "/"
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.mount("#app")
|
||||
app.mount('#app')
|
||||
|
||||
if (import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||
addAPIProvider("", {
|
||||
addAPIProvider('', {
|
||||
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import api from "utils/api"
|
||||
import type { AchievementList } from "utils/types"
|
||||
import api from 'utils/api'
|
||||
import type { AchievementList } from 'utils/types'
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return api.get<AchievementList>("achievements", {
|
||||
return api.get<AchievementList>('achievements', {
|
||||
params: name ? { username: name } : {},
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Achievement, AchievementClassBoard } from "utils/types"
|
||||
import Medal from "./Medal.vue"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { Achievement, AchievementClassBoard } from 'utils/types'
|
||||
import Medal from './Medal.vue'
|
||||
|
||||
/**
|
||||
* 「本班」:最近谁拿到了什么、成就最多的前五名(自己不在里面就钉在底下)。
|
||||
@@ -26,8 +26,7 @@ const recent = computed(() => {
|
||||
const item = byId.value.get(row.achievementId)
|
||||
if (!item) continue
|
||||
const last = rows.at(-1)
|
||||
if (last && last.username === row.username && last.time === row.unlockTime)
|
||||
last.items.push(item)
|
||||
if (last && last.username === row.username && last.time === row.unlockTime) last.items.push(item)
|
||||
else
|
||||
rows.push({
|
||||
key: `${row.username}-${row.unlockTime}-${row.achievementId}`,
|
||||
@@ -42,8 +41,8 @@ const recent = computed(() => {
|
||||
function what(items: Achievement[]) {
|
||||
const known = items.filter((item) => item.metric !== null).map((item) => item.name)
|
||||
const eggs = items.length - known.length
|
||||
if (eggs) known.push(eggs === 1 ? "一个彩蛋" : `${eggs} 个彩蛋`)
|
||||
return known.join("、")
|
||||
if (eggs) known.push(eggs === 1 ? '一个彩蛋' : `${eggs} 个彩蛋`)
|
||||
return known.join('、')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -54,29 +53,19 @@ function what(items: Achievement[]) {
|
||||
<div class="col">
|
||||
<span class="sub">最近拿到</span>
|
||||
<div v-for="row in recent" :key="row.key" class="line">
|
||||
<Medal
|
||||
:icon="row.items[0]!.icon"
|
||||
:rarity="row.items[0]!.rarity"
|
||||
:size="28"
|
||||
:state="row.items[0]!.metric === null ? 'unknown' : 'on'"
|
||||
/>
|
||||
<Medal :icon="row.items[0]!.icon" :rarity="row.items[0]!.rarity" :size="28" :state="row.items[0]!.metric === null ? 'unknown' : 'on'" />
|
||||
<span class="what">
|
||||
<UserName :username="row.username" />
|
||||
<span class="muted">拿到</span>
|
||||
{{ what(row.items) }}
|
||||
</span>
|
||||
<span class="date">{{ parseTime(row.time, "M月D日") }}</span>
|
||||
<span class="date">{{ parseTime(row.time, 'M月D日') }}</span>
|
||||
</div>
|
||||
<span v-if="!recent.length" class="empty">这学期还没人拿到</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<span class="sub">成就最多</span>
|
||||
<div
|
||||
v-for="row in board.most"
|
||||
:key="row.username"
|
||||
class="line rank"
|
||||
:class="{ mine: row.username === me }"
|
||||
>
|
||||
<div v-for="row in board.most" :key="row.username" class="line rank" :class="{ mine: row.username === me }">
|
||||
<span class="no">{{ row.rank }}</span>
|
||||
<UserName :username="row.username" />
|
||||
<b>{{ row.count }}</b>
|
||||
@@ -94,9 +83,9 @@ function what(items: Achievement[]) {
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 14px 20px 12px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -120,7 +109,7 @@ h3 {
|
||||
.sub {
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.line {
|
||||
@@ -143,7 +132,7 @@ h3 {
|
||||
.date,
|
||||
.no,
|
||||
.empty {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.muted {
|
||||
@@ -178,7 +167,7 @@ h3 {
|
||||
}
|
||||
|
||||
.pinned {
|
||||
border-top: 1px dashed v-bind("theme.borderColor");
|
||||
border-top: 1px dashed v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.compact {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import type { UserBadge } from "utils/types"
|
||||
import type { Group } from "../series"
|
||||
import Medal from "./Medal.vue"
|
||||
import SeriesRow from "./SeriesRow.vue"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import AchievementIcon from 'shared/components/AchievementIcon.vue'
|
||||
import type { UserBadge } from 'utils/types'
|
||||
import type { Group } from '../series'
|
||||
import Medal from './Medal.vue'
|
||||
import SeriesRow from './SeriesRow.vue'
|
||||
|
||||
/** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「题单和比赛」组末尾是拿到的题单奖章 */
|
||||
const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const showBadges = computed(() => props.group.name === "题单和比赛" && !!props.badges?.length)
|
||||
const showBadges = computed(() => props.group.name === '题单和比赛' && !!props.badges?.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,10 +31,7 @@ const showBadges = computed(() => props.group.name === "题单和比赛" && !!pr
|
||||
<div class="badge-list">
|
||||
<n-tooltip v-for="b in badges" :key="b.id" :delay="150">
|
||||
<template #trigger>
|
||||
<router-link
|
||||
:to="{ name: 'problemset', params: { problemSetId: b.problemSet.id } }"
|
||||
class="badge"
|
||||
>
|
||||
<router-link :to="{ name: 'problemset', params: { problemSetId: b.problemSet.id } }" class="badge">
|
||||
<AchievementIcon :icon="b.badge.icon" :size="24" />
|
||||
<span>{{ b.badge.name }}</span>
|
||||
</router-link>
|
||||
@@ -49,9 +46,9 @@ const showBadges = computed(() => props.group.name === "题单和比赛" && !!pr
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 14px 20px 20px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -65,7 +62,7 @@ h3 {
|
||||
h3 small {
|
||||
font-size: var(--oj-fs-meta);
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -79,7 +76,7 @@ h3 small {
|
||||
|
||||
.masked-title {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.masked-list,
|
||||
@@ -99,8 +96,8 @@ h3 small {
|
||||
border-radius: 6px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor1");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind('theme.textColor1');
|
||||
background: v-bind('theme.actionColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { RARITY_COLOR } from "utils/constants"
|
||||
import type { AchievementRarity } from "utils/types"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import AchievementIcon from 'shared/components/AchievementIcon.vue'
|
||||
import { RARITY_COLOR } from 'utils/constants'
|
||||
import type { AchievementRarity } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 一枚徽章:拿到的是稀有度色的圈,没拿到的灰掉,还没找到的隐藏成就是虚线问号。
|
||||
@@ -13,10 +13,10 @@ const props = withDefaults(
|
||||
icon?: string
|
||||
rarity?: AchievementRarity
|
||||
size?: number
|
||||
state?: "on" | "off" | "unknown"
|
||||
state?: 'on' | 'off' | 'unknown'
|
||||
ring?: number | null
|
||||
}>(),
|
||||
{ icon: "", rarity: "bronze", size: 40, state: "on", ring: null },
|
||||
{ icon: '', rarity: 'bronze', size: 40, state: 'on', ring: null },
|
||||
)
|
||||
|
||||
const theme = useThemeVars()
|
||||
@@ -25,25 +25,13 @@ const r = computed(() => props.size / 2 + 3)
|
||||
const box = computed(() => props.size + 8)
|
||||
const circumference = computed(() => 2 * Math.PI * r.value)
|
||||
// 底色都是半透明的,下面垫一层卡片底色 —— 不然成就线的进度线会从徽章中间透出来
|
||||
const onBg = computed(
|
||||
() => `linear-gradient(${color.value}26, ${color.value}26), ${theme.value.cardColor}`,
|
||||
)
|
||||
const offBg = computed(
|
||||
() =>
|
||||
`linear-gradient(${theme.value.actionColor}, ${theme.value.actionColor}), ${theme.value.cardColor}`,
|
||||
)
|
||||
const onBg = computed(() => `linear-gradient(${color.value}26, ${color.value}26), ${theme.value.cardColor}`)
|
||||
const offBg = computed(() => `linear-gradient(${theme.value.actionColor}, ${theme.value.actionColor}), ${theme.value.cardColor}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="medal" :style="{ width: `${size}px`, height: `${size}px` }">
|
||||
<svg
|
||||
v-if="ring !== null && state !== 'unknown'"
|
||||
class="arc"
|
||||
:width="box"
|
||||
:height="box"
|
||||
:viewBox="`0 0 ${box} ${box}`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg v-if="ring !== null && state !== 'unknown'" class="arc" :width="box" :height="box" :viewBox="`0 0 ${box} ${box}`" aria-hidden="true">
|
||||
<circle :cx="box / 2" :cy="box / 2" :r="r" fill="none" class="arc-track" stroke-width="3" />
|
||||
<circle
|
||||
:cx="box / 2"
|
||||
@@ -56,15 +44,8 @@ const offBg = computed(
|
||||
:stroke-dasharray="`${circumference * Math.min(1, ring)} ${circumference}`"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="state === 'unknown'" class="disc unknown" :style="{ fontSize: `${size * 0.42}px` }">
|
||||
?
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="disc"
|
||||
:class="state"
|
||||
:style="state === 'on' ? { borderColor: color, background: onBg } : undefined"
|
||||
>
|
||||
<span v-if="state === 'unknown'" class="disc unknown" :style="{ fontSize: `${size * 0.42}px` }"> ? </span>
|
||||
<span v-else class="disc" :class="state" :style="state === 'on' ? { borderColor: color, background: onBg } : undefined">
|
||||
<AchievementIcon :icon="icon" :size="Math.round(size * 0.56)" />
|
||||
</span>
|
||||
</span>
|
||||
@@ -85,11 +66,11 @@ const offBg = computed(
|
||||
}
|
||||
|
||||
.arc-track {
|
||||
stroke: v-bind("theme.dividerColor");
|
||||
stroke: v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.arc-fill {
|
||||
stroke: v-bind("theme.primaryColor");
|
||||
stroke: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.disc {
|
||||
@@ -105,7 +86,7 @@ const offBg = computed(
|
||||
}
|
||||
|
||||
.disc.off {
|
||||
border-color: v-bind("theme.borderColor");
|
||||
border-color: v-bind('theme.borderColor');
|
||||
background: v-bind(offBg);
|
||||
}
|
||||
|
||||
@@ -117,9 +98,9 @@ const offBg = computed(
|
||||
}
|
||||
|
||||
.disc.unknown {
|
||||
border: 1.5px dashed v-bind("theme.borderColor");
|
||||
border: 1.5px dashed v-bind('theme.borderColor');
|
||||
background: v-bind(offBg);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import type { Near } from "../series"
|
||||
import Medal from "./Medal.vue"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { RARITY_LABEL } from 'utils/constants'
|
||||
import { useRarityColor } from 'shared/composables/rarity'
|
||||
import type { Near } from '../series'
|
||||
import Medal from './Medal.vue'
|
||||
|
||||
/**
|
||||
* 「快拿到了」:每个只说还差什么,进度条底下是现在几 / 要几、本班几人有。
|
||||
@@ -16,9 +16,7 @@ const rarityText = useRarityColor()
|
||||
|
||||
function now(item: Near) {
|
||||
const { series, achievement } = item
|
||||
return series.streak
|
||||
? `现在连着 ${series.value} ${series.unit}`
|
||||
: `${series.value} / ${achievement.threshold} ${series.unit}`
|
||||
return series.streak ? `现在连着 ${series.value} ${series.unit}` : `${series.value} / ${achievement.threshold} ${series.unit}`
|
||||
}
|
||||
|
||||
const percent = (item: Near) => Math.round((item.series.value / item.achievement.threshold!) * 100)
|
||||
@@ -55,9 +53,7 @@ const percent = (item: Near) => Math.round((item.series.value / item.achievement
|
||||
/>
|
||||
<div class="foot">
|
||||
<span>{{ now(item) }}</span>
|
||||
<span v-if="item.achievement.classHolders !== null">
|
||||
本班 {{ item.achievement.classHolders }} 人有
|
||||
</span>
|
||||
<span v-if="item.achievement.classHolders !== null"> 本班 {{ item.achievement.classHolders }} 人有 </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,9 +64,9 @@ const percent = (item: Near) => Math.round((item.series.value / item.achievement
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 14px 20px 6px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -83,7 +79,7 @@ h3 {
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 0;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.body {
|
||||
@@ -109,7 +105,7 @@ h3 {
|
||||
margin-left: auto;
|
||||
font-size: var(--oj-fs-sec);
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
color: v-bind('theme.primaryColorPressed');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -117,7 +113,7 @@ h3 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -132,7 +128,7 @@ h3 {
|
||||
}
|
||||
|
||||
.wide .item {
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
/* 手机:「还差什么」换到名字下面一行 */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Achievement } from "utils/types"
|
||||
import type { Series } from "../series"
|
||||
import Medal from "./Medal.vue"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { RARITY_LABEL } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { Achievement } from 'utils/types'
|
||||
import type { Series } from '../series'
|
||||
import Medal from './Medal.vue'
|
||||
|
||||
/**
|
||||
* 一条成就线:左边线名和现在的数,右边几档按门槛排在一根进度线上,下一档头上标「差几」。
|
||||
@@ -53,8 +53,8 @@ function gap(item: Achievement) {
|
||||
function when(item: Achievement) {
|
||||
if (!item.unlocked) return `现在 ${props.series.value} ${props.series.unit}`
|
||||
// 补发的不写日期:一次补发会给几百人盖上同一个时间戳
|
||||
if (item.backfilled || !item.unlockTime) return "已拿到"
|
||||
return `${parseTime(item.unlockTime, "M月D日")}拿到`
|
||||
if (item.backfilled || !item.unlockTime) return '已拿到'
|
||||
return `${parseTime(item.unlockTime, 'M月D日')}拿到`
|
||||
}
|
||||
|
||||
function holders(item: Achievement) {
|
||||
@@ -68,35 +68,19 @@ function holders(item: Achievement) {
|
||||
<div class="label">
|
||||
<span class="name">{{ series.label }}</span>
|
||||
<span class="value">
|
||||
<b>{{ series.streak ? "现在 " : "" }}{{ series.value }}</b>
|
||||
<b>{{ series.streak ? '现在 ' : '' }}{{ series.value }}</b>
|
||||
<small>{{ series.unit }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="track" :style="{ height: `${size + 36}px` }">
|
||||
<span
|
||||
class="line"
|
||||
:style="{ top: `${size / 2 - 2}px`, width: `${center(series.items.length - 1)}%` }"
|
||||
></span>
|
||||
<span class="line" :style="{ top: `${size / 2 - 2}px`, width: `${center(series.items.length - 1)}%` }"></span>
|
||||
<span class="line done" :style="{ top: `${size / 2 - 2}px`, width: `${fill}%` }"></span>
|
||||
<div
|
||||
v-for="(item, i) in series.items"
|
||||
:key="item.id"
|
||||
class="tier"
|
||||
:class="{ on: item.unlocked }"
|
||||
:style="{ left: `${center(i)}%` }"
|
||||
>
|
||||
<div v-for="(item, i) in series.items" :key="item.id" class="tier" :class="{ on: item.unlocked }" :style="{ left: `${center(i)}%` }">
|
||||
<n-tooltip :delay="150">
|
||||
<template #trigger>
|
||||
<span class="trigger">
|
||||
<span v-if="gap(item)" class="gap" :style="{ bottom: `${size + 4}px` }">
|
||||
差 {{ gap(item) }}
|
||||
</span>
|
||||
<Medal
|
||||
:icon="item.icon"
|
||||
:rarity="item.rarity"
|
||||
:size="size"
|
||||
:state="item.unlocked ? 'on' : 'off'"
|
||||
/>
|
||||
<span v-if="gap(item)" class="gap" :style="{ bottom: `${size + 4}px` }"> 差 {{ gap(item) }} </span>
|
||||
<Medal :icon="item.icon" :rarity="item.rarity" :size="size" :state="item.unlocked ? 'on' : 'off'" />
|
||||
</span>
|
||||
</template>
|
||||
<div class="tip">
|
||||
@@ -129,7 +113,7 @@ function holders(item: Achievement) {
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -142,7 +126,7 @@ function holders(item: Achievement) {
|
||||
.value small {
|
||||
margin-left: 3px;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.track {
|
||||
@@ -154,11 +138,11 @@ function holders(item: Achievement) {
|
||||
left: 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
background: v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.line.done {
|
||||
background: v-bind("theme.primaryColor");
|
||||
background: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.tier {
|
||||
@@ -188,7 +172,7 @@ function holders(item: Achievement) {
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
color: v-bind('theme.primaryColorPressed');
|
||||
background: v-bind(gapBg);
|
||||
}
|
||||
|
||||
@@ -197,18 +181,18 @@ function holders(item: Achievement) {
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.tier.on .tier-name {
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tier-at {
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getAchievements } from "oj/achievement/api"
|
||||
import { getUserBadges } from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import AdminLink from "shared/components/AdminLink.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
|
||||
import type { AchievementList, AchievementRarity, UserBadge } from "utils/types"
|
||||
import ClassCard from "./components/ClassCard.vue"
|
||||
import GroupCard from "./components/GroupCard.vue"
|
||||
import NearCard from "./components/NearCard.vue"
|
||||
import { buildGroups, nearest, type GroupName } from "./series"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getAchievements } from 'oj/achievement/api'
|
||||
import { getUserBadges } from 'oj/api'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { RARITY_COLOR, RARITY_LABEL } from 'utils/constants'
|
||||
import type { AchievementList, AchievementRarity, UserBadge } from 'utils/types'
|
||||
import ClassCard from './components/ClassCard.vue'
|
||||
import GroupCard from './components/GroupCard.vue'
|
||||
import NearCard from './components/NearCard.vue'
|
||||
import { buildGroups, nearest, type GroupName } from './series'
|
||||
|
||||
/**
|
||||
* 我的成就(设计稿「我的成就重设计」定稿,画布 page ach):
|
||||
@@ -34,23 +34,19 @@ const other = computed(() => !!data.value && data.value.username !== userStore.u
|
||||
const groups = computed(() => (data.value ? buildGroups(data.value.achievements) : []))
|
||||
const near = computed(() => (other.value ? [] : nearest(groups.value)))
|
||||
const got = computed(() => data.value?.achievements.filter((item) => item.unlocked) ?? [])
|
||||
const rarities: AchievementRarity[] = ["platinum", "gold", "silver", "bronze"]
|
||||
const countOf = (rarity: AchievementRarity) =>
|
||||
got.value.filter((item) => item.rarity === rarity).length
|
||||
const rarities: AchievementRarity[] = ['platinum', 'gold', 'silver', 'bronze']
|
||||
const countOf = (rarity: AchievementRarity) => got.value.filter((item) => item.rarity === rarity).length
|
||||
|
||||
// 桌面两栏:左边做题、其他、彩蛋,右边坚持、题单和比赛 —— 两边差不多高
|
||||
const LEFT: GroupName[] = ["做题", "其他", "彩蛋"]
|
||||
const RIGHT: GroupName[] = ["坚持", "题单和比赛"]
|
||||
const LEFT: GroupName[] = ['做题', '其他', '彩蛋']
|
||||
const RIGHT: GroupName[] = ['坚持', '题单和比赛']
|
||||
const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
notFound.value = false
|
||||
try {
|
||||
const [list, badgeRes] = await Promise.all([
|
||||
getAchievements(name.value),
|
||||
getUserBadges(name.value).catch(() => []),
|
||||
])
|
||||
const [list, badgeRes] = await Promise.all([getAchievements(name.value), getUserBadges(name.value).catch(() => [])])
|
||||
data.value = list
|
||||
badges.value = badgeRes ?? []
|
||||
} catch {
|
||||
@@ -91,19 +87,8 @@ watch(name, load)
|
||||
|
||||
<!-- 没有班级(老师、自己注册的号)就没有「本班」,「快拿到了」三个横排占满整宽 -->
|
||||
<div v-if="near.length || data.classBoard" class="top" :class="{ alone: !data.classBoard }">
|
||||
<NearCard
|
||||
v-if="near.length"
|
||||
:items="near"
|
||||
:compact="!isDesktop"
|
||||
:wide="isDesktop && !data.classBoard"
|
||||
/>
|
||||
<ClassCard
|
||||
v-if="data.classBoard"
|
||||
:board="data.classBoard"
|
||||
:achievements="data.achievements"
|
||||
:me="data.username"
|
||||
:compact="!isDesktop"
|
||||
/>
|
||||
<NearCard v-if="near.length" :items="near" :compact="!isDesktop" :wide="isDesktop && !data.classBoard" />
|
||||
<ClassCard v-if="data.classBoard" :board="data.classBoard" :achievements="data.achievements" :me="data.username" :compact="!isDesktop" />
|
||||
</div>
|
||||
|
||||
<div v-if="isDesktop" class="columns">
|
||||
@@ -138,12 +123,12 @@ h2 {
|
||||
|
||||
.count {
|
||||
font-size: var(--oj-fs-body);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.count b {
|
||||
font-size: 18px;
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -158,7 +143,7 @@ h2 {
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.rarity i {
|
||||
@@ -168,7 +153,7 @@ h2 {
|
||||
}
|
||||
|
||||
.rarity b {
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -185,7 +170,7 @@ h2 {
|
||||
.mine {
|
||||
margin-left: auto;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Achievement } from "utils/types"
|
||||
import type { Achievement } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 成就页按「一条条线」摆:同一个指标的几档是一条线(做对 1 / 10 / 50 / 100 / 200 道),
|
||||
@@ -7,7 +7,7 @@ import type { Achievement } from "utils/types"
|
||||
* 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看,
|
||||
* 在后台加了新指标记得回来补一行。
|
||||
*/
|
||||
export const GROUPS = ["做题", "坚持", "题单和比赛", "其他", "彩蛋"] as const
|
||||
export const GROUPS = ['做题', '坚持', '题单和比赛', '其他', '彩蛋'] as const
|
||||
export type GroupName = (typeof GROUPS)[number]
|
||||
|
||||
interface MetricInfo {
|
||||
@@ -21,109 +21,109 @@ interface MetricInfo {
|
||||
}
|
||||
|
||||
const METRICS: Record<string, MetricInfo> = {
|
||||
accepted_count: { group: "做题", label: "做对的题", unit: "道", remain: (r) => `再做对 ${r} 道` },
|
||||
accepted_count: { group: '做题', label: '做对的题', unit: '道', remain: (r) => `再做对 ${r} 道` },
|
||||
mid_ac_count: {
|
||||
group: "做题",
|
||||
label: "中等题",
|
||||
unit: "道",
|
||||
group: '做题',
|
||||
label: '中等题',
|
||||
unit: '道',
|
||||
remain: (r) => `再做对 ${r} 道中等题`,
|
||||
},
|
||||
hard_ac_count: {
|
||||
group: "做题",
|
||||
label: "困难题",
|
||||
unit: "道",
|
||||
group: '做题',
|
||||
label: '困难题',
|
||||
unit: '道',
|
||||
remain: (r) => `再做对 ${r} 道困难题`,
|
||||
},
|
||||
max_ac_week_streak: {
|
||||
group: "坚持",
|
||||
label: "连着几周有做对",
|
||||
unit: "周",
|
||||
group: '坚持',
|
||||
label: '连着几周有做对',
|
||||
unit: '周',
|
||||
remain: (r) => `再连着 ${r} 周都有做对`,
|
||||
streak: true,
|
||||
},
|
||||
max_ac_streak_days: {
|
||||
group: "坚持",
|
||||
label: "连着几天有做对",
|
||||
unit: "天",
|
||||
group: '坚持',
|
||||
label: '连着几天有做对',
|
||||
unit: '天',
|
||||
remain: (r) => `再连着 ${r} 天都有做对`,
|
||||
},
|
||||
active_days: {
|
||||
group: "坚持",
|
||||
label: "交过题的日子",
|
||||
unit: "天",
|
||||
group: '坚持',
|
||||
label: '交过题的日子',
|
||||
unit: '天',
|
||||
remain: (r) => `再有 ${r} 天交题`,
|
||||
},
|
||||
submission_count: { group: "坚持", label: "交了几次", unit: "次", remain: (r) => `再交 ${r} 次` },
|
||||
submission_count: { group: '坚持', label: '交了几次', unit: '次', remain: (r) => `再交 ${r} 次` },
|
||||
problemset_completed: {
|
||||
group: "题单和比赛",
|
||||
label: "做完的题单",
|
||||
unit: "个",
|
||||
group: '题单和比赛',
|
||||
label: '做完的题单',
|
||||
unit: '个',
|
||||
remain: (r) => `再做完 ${r} 个题单`,
|
||||
},
|
||||
badge_count: {
|
||||
group: "题单和比赛",
|
||||
label: "题单奖章",
|
||||
unit: "枚",
|
||||
group: '题单和比赛',
|
||||
label: '题单奖章',
|
||||
unit: '枚',
|
||||
remain: (r) => `再拿 ${r} 枚题单奖章`,
|
||||
},
|
||||
contest_joined: {
|
||||
group: "题单和比赛",
|
||||
label: "参加的比赛",
|
||||
unit: "场",
|
||||
group: '题单和比赛',
|
||||
label: '参加的比赛',
|
||||
unit: '场',
|
||||
remain: (r) => `再参加 ${r} 场比赛`,
|
||||
},
|
||||
languages_used: {
|
||||
group: "其他",
|
||||
label: "用过的语言",
|
||||
unit: "种",
|
||||
group: '其他',
|
||||
label: '用过的语言',
|
||||
unit: '种',
|
||||
remain: (r) => `再用 ${r} 种语言`,
|
||||
},
|
||||
achievement_unlocked_count: {
|
||||
group: "其他",
|
||||
label: "拿到的成就",
|
||||
unit: "个",
|
||||
group: '其他',
|
||||
label: '拿到的成就',
|
||||
unit: '个',
|
||||
remain: (r) => `再拿 ${r} 个成就`,
|
||||
},
|
||||
first_try_ac_count: {
|
||||
group: "彩蛋",
|
||||
label: "一交就对",
|
||||
unit: "道",
|
||||
group: '彩蛋',
|
||||
label: '一交就对',
|
||||
unit: '道',
|
||||
remain: (r) => `再有 ${r} 道一交就对`,
|
||||
},
|
||||
max_ac_in_one_day: {
|
||||
group: "彩蛋",
|
||||
label: "一天做对",
|
||||
unit: "道",
|
||||
group: '彩蛋',
|
||||
label: '一天做对',
|
||||
unit: '道',
|
||||
remain: (r) => `一天做对 ${r} 道`,
|
||||
},
|
||||
midnight_submissions: {
|
||||
group: "彩蛋",
|
||||
label: "凌晨交题",
|
||||
unit: "次",
|
||||
group: '彩蛋',
|
||||
label: '凌晨交题',
|
||||
unit: '次',
|
||||
remain: (r) => `再交 ${r} 次`,
|
||||
},
|
||||
early_bird_submissions: {
|
||||
group: "彩蛋",
|
||||
label: "早上交题",
|
||||
unit: "次",
|
||||
group: '彩蛋',
|
||||
label: '早上交题',
|
||||
unit: '次',
|
||||
remain: (r) => `再交 ${r} 次`,
|
||||
},
|
||||
compile_error_count: {
|
||||
group: "彩蛋",
|
||||
label: "编译失败",
|
||||
unit: "次",
|
||||
group: '彩蛋',
|
||||
label: '编译失败',
|
||||
unit: '次',
|
||||
remain: (r) => `还差 ${r} 次`,
|
||||
},
|
||||
max_wa_before_ac: {
|
||||
group: "彩蛋",
|
||||
label: "错了好多次才做对",
|
||||
unit: "次",
|
||||
group: '彩蛋',
|
||||
label: '错了好多次才做对',
|
||||
unit: '次',
|
||||
remain: (r) => `还差 ${r} 次`,
|
||||
},
|
||||
max_code_lines: {
|
||||
group: "彩蛋",
|
||||
label: "做对的代码最长",
|
||||
unit: "行",
|
||||
group: '彩蛋',
|
||||
label: '做对的代码最长',
|
||||
unit: '行',
|
||||
remain: (r) => `还差 ${r} 行`,
|
||||
},
|
||||
}
|
||||
@@ -178,9 +178,9 @@ export function buildGroups(achievements: Achievement[]): Group[] {
|
||||
const info = METRICS[metric]
|
||||
return {
|
||||
metric,
|
||||
group: info?.group ?? "其他",
|
||||
group: info?.group ?? '其他',
|
||||
label: info?.label ?? items[0]!.name,
|
||||
unit: info?.unit ?? "",
|
||||
unit: info?.unit ?? '',
|
||||
streak: info?.streak ?? false,
|
||||
value: items[0]!.progress ?? 0,
|
||||
items,
|
||||
@@ -189,13 +189,13 @@ export function buildGroups(achievements: Achievement[]): Group[] {
|
||||
})
|
||||
return GROUPS.map((name) => {
|
||||
const own = series.filter((item) => item.group === name)
|
||||
const extra = name === "彩蛋" ? masked : 0
|
||||
const extra = name === '彩蛋' ? masked : 0
|
||||
const items = own.flatMap((item) => item.items)
|
||||
return {
|
||||
name,
|
||||
series: own,
|
||||
masked: extra,
|
||||
got: items.filter((item) => item.unlocked).length + (name === "彩蛋" ? maskedGot : 0),
|
||||
got: items.filter((item) => item.unlocked).length + (name === '彩蛋' ? maskedGot : 0),
|
||||
total: items.length + extra,
|
||||
}
|
||||
})
|
||||
@@ -221,7 +221,7 @@ export function nearest(groups: Group[], limit = 3): Near[] {
|
||||
for (const group of groups) {
|
||||
for (const series of group.series) {
|
||||
const next = series.next
|
||||
if (!next || next.operator !== "gte" || !next.threshold) continue
|
||||
if (!next || next.operator !== 'gte' || !next.threshold) continue
|
||||
const r = next.threshold - series.value
|
||||
if (r <= 0) continue
|
||||
const info = METRICS[series.metric]
|
||||
@@ -233,7 +233,5 @@ export function nearest(groups: Group[], limit = 3): Near[] {
|
||||
}
|
||||
}
|
||||
const ratio = (item: Near) => item.series.value / item.achievement.threshold!
|
||||
return candidates
|
||||
.sort((a, b) => ratio(b) - ratio(a) || rankOf(a.series.metric) - rankOf(b.series.metric))
|
||||
.slice(0, limit)
|
||||
return candidates.sort((a, b) => ratio(b) - ratio(a) || rankOf(a.series.metric) - rankOf(b.series.metric)).slice(0, limit)
|
||||
}
|
||||
+47
-102
@@ -1,44 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LEARNING_PERIOD_LABELS,
|
||||
learningPeriodSchema,
|
||||
type LearningPeriod,
|
||||
type LearningReport,
|
||||
} from "@oj2/contract"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import AdminLink from "shared/components/AdminLink.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getLearningReport } from "../api"
|
||||
import AiSummary from "./components/AiSummary.vue"
|
||||
import ErrorsCard from "./components/ErrorsCard.vue"
|
||||
import KnowledgeCard from "./components/KnowledgeCard.vue"
|
||||
import LessonDays from "./components/LessonDays.vue"
|
||||
import StatStrip from "./components/StatStrip.vue"
|
||||
import UnsolvedCard from "./components/UnsolvedCard.vue"
|
||||
import { dayLabel, isDone } from "./utils"
|
||||
import { LEARNING_PERIOD_LABELS, learningPeriodSchema, type LearningPeriod, type LearningReport } from '@oj2/contract'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import { getLearningReport } from '../api'
|
||||
import AiSummary from './components/AiSummary.vue'
|
||||
import ErrorsCard from './components/ErrorsCard.vue'
|
||||
import KnowledgeCard from './components/KnowledgeCard.vue'
|
||||
import LessonDays from './components/LessonDays.vue'
|
||||
import StatStrip from './components/StatStrip.vue'
|
||||
import UnsolvedCard from './components/UnsolvedCard.vue'
|
||||
import { dayLabel, isDone } from './utils'
|
||||
|
||||
const theme = useThemeVars()
|
||||
const userStore = useUserStore()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const urlUsername = useRouteQuery<string>("username", "")
|
||||
const urlPeriod = useRouteQuery<string>("period", "term")
|
||||
const urlUsername = useRouteQuery<string>('username', '')
|
||||
const urlPeriod = useRouteQuery<string>('period', 'term')
|
||||
const period = computed<LearningPeriod>(() => {
|
||||
const parsed = learningPeriodSchema.safeParse(urlPeriod.value)
|
||||
return parsed.success ? parsed.data : "term"
|
||||
return parsed.success ? parsed.data : 'term'
|
||||
})
|
||||
const PERIODS = Object.entries(LEARNING_PERIOD_LABELS) as [LearningPeriod, string][]
|
||||
|
||||
/** 老师看别人。学生在地址栏写了别人的名字,后端也只会给他自己的 */
|
||||
const viewing = computed(() =>
|
||||
userStore.isTeacherOrAbove && urlUsername.value && urlUsername.value !== userStore.user?.username
|
||||
? urlUsername.value
|
||||
: undefined,
|
||||
userStore.isTeacherOrAbove && urlUsername.value && urlUsername.value !== userStore.user?.username ? urlUsername.value : undefined,
|
||||
)
|
||||
|
||||
const report = ref<LearningReport | null>(null)
|
||||
@@ -56,8 +49,7 @@ async function load() {
|
||||
const data = await getLearningReport(want.period, want.username)
|
||||
if (want.period !== period.value || want.username !== viewing.value) return
|
||||
report.value = data
|
||||
if (!data.days.length && want.period !== "all")
|
||||
before.value = await getLearningReport("all", want.username)
|
||||
if (!data.days.length && want.period !== 'all') before.value = await getLearningReport('all', want.username)
|
||||
} catch {
|
||||
report.value = null
|
||||
notFound.value = true
|
||||
@@ -70,23 +62,23 @@ watch([period, viewing], load, { immediate: true })
|
||||
|
||||
const span = computed(() => {
|
||||
const r = report.value
|
||||
if (!r) return ""
|
||||
if (!r.start) return ""
|
||||
const from = parseTime(r.start, "M月D日")
|
||||
const to = parseTime(r.end, "M月D日")
|
||||
if (!r) return ''
|
||||
if (!r.start) return ''
|
||||
const from = parseTime(r.start, 'M月D日')
|
||||
const to = parseTime(r.end, 'M月D日')
|
||||
return from === to ? from : `${from} – ${to}`
|
||||
})
|
||||
|
||||
const emptyHint = computed(() => {
|
||||
const last = before.value?.days.find((day) => day.lesson.length)
|
||||
if (!last) return ""
|
||||
if (!last) return ''
|
||||
const { date, weekday } = dayLabel(last.day)
|
||||
return `上一节课是 ${date} ${weekday},做对了 ${last.lesson.filter(isDone).length} / ${last.lesson.length} 道`
|
||||
})
|
||||
const longer = computed(() => PERIODS.slice(PERIODS.findIndex(([key]) => key === period.value) + 1))
|
||||
|
||||
const searchName = ref("")
|
||||
watch(viewing, (value) => (searchName.value = value ?? ""), { immediate: true })
|
||||
const searchName = ref('')
|
||||
watch(viewing, (value) => (searchName.value = value ?? ''), { immediate: true })
|
||||
function goStudent(username: string | null | undefined) {
|
||||
if (username) urlUsername.value = username
|
||||
}
|
||||
@@ -97,52 +89,22 @@ function goStudent(username: string | null | undefined) {
|
||||
<div class="toolbar" :class="{ single: !isDesktop }">
|
||||
<h2>智能分析</h2>
|
||||
<div v-if="viewing" class="who">
|
||||
<n-button
|
||||
:disabled="!report?.neighbors?.prev"
|
||||
aria-label="上一个学生"
|
||||
@click="goStudent(report?.neighbors?.prev)"
|
||||
>
|
||||
‹
|
||||
</n-button>
|
||||
<n-input
|
||||
v-model:value="searchName"
|
||||
class="who-input"
|
||||
placeholder="用户名"
|
||||
@keyup.enter="goStudent(searchName.trim())"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="!report?.neighbors?.next"
|
||||
aria-label="下一个学生"
|
||||
@click="goStudent(report?.neighbors?.next)"
|
||||
>
|
||||
›
|
||||
</n-button>
|
||||
<n-button :disabled="!report?.neighbors?.prev" aria-label="上一个学生" @click="goStudent(report?.neighbors?.prev)"> ‹ </n-button>
|
||||
<n-input v-model:value="searchName" class="who-input" placeholder="用户名" @keyup.enter="goStudent(searchName.trim())" />
|
||||
<n-button :disabled="!report?.neighbors?.next" aria-label="下一个学生" @click="goStudent(report?.neighbors?.next)"> › </n-button>
|
||||
<span v-if="report?.className" class="muted">{{ classLabel(report.className) }}</span>
|
||||
</div>
|
||||
<div class="seg" role="group" aria-label="时间">
|
||||
<button
|
||||
v-for="[key, label] in PERIODS"
|
||||
:key="key"
|
||||
:class="{ on: period === key }"
|
||||
@click="urlPeriod = key"
|
||||
>
|
||||
<button v-for="[key, label] in PERIODS" :key="key" :class="{ on: period === key }" @click="urlPeriod = key">
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="isDesktop && span" class="muted">{{ span }}</span>
|
||||
<span class="grow"></span>
|
||||
<router-link
|
||||
v-if="viewing"
|
||||
:to="{ path: '/submission', query: { username: viewing, exactUsername: '1' } }"
|
||||
class="link"
|
||||
>
|
||||
<router-link v-if="viewing" :to="{ path: '/submission', query: { username: viewing, exactUsername: '1' } }" class="link">
|
||||
看<UserName :username="viewing" />的提交 ›
|
||||
</router-link>
|
||||
<AdminLink
|
||||
v-if="userStore.isTeacherOrAbove && isDesktop"
|
||||
:to="{ path: '/admin/ai/reports', query: viewing ? { username: viewing } : {} }"
|
||||
size="small"
|
||||
>
|
||||
<AdminLink v-if="userStore.isTeacherOrAbove && isDesktop" :to="{ path: '/admin/ai/reports', query: viewing ? { username: viewing } : {} }" size="small">
|
||||
AI 报告
|
||||
</AdminLink>
|
||||
</div>
|
||||
@@ -155,16 +117,12 @@ function goStudent(username: string | null | undefined) {
|
||||
<b>{{ LEARNING_PERIOD_LABELS[period] }}还没交过题</b>
|
||||
<span v-if="emptyHint" class="muted">{{ emptyHint }}</span>
|
||||
<div class="actions">
|
||||
<n-button v-for="[key, label] in longer" :key="key" @click="urlPeriod = key">
|
||||
看{{ label }}
|
||||
</n-button>
|
||||
<n-button v-for="[key, label] in longer" :key="key" @click="urlPeriod = key"> 看{{ label }} </n-button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="!viewing && before?.unsolved.length" class="card hint">
|
||||
<b>还有 {{ before.unsolved.length }} 道差一点的</b>
|
||||
<span class="muted names">{{
|
||||
before.unsolved.map((item) => item.title).join("、")
|
||||
}}</span>
|
||||
<span class="muted names">{{ before.unsolved.map((item) => item.title).join('、') }}</span>
|
||||
<a href="#" class="link" @click.prevent="urlPeriod = 'all'">去看看 ›</a>
|
||||
</section>
|
||||
</template>
|
||||
@@ -177,27 +135,14 @@ function goStudent(username: string | null | undefined) {
|
||||
<AiSummary :period="period" :username="viewing" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<UnsolvedCard
|
||||
v-if="report.unsolved.length"
|
||||
:items="report.unsolved"
|
||||
:username="viewing"
|
||||
/>
|
||||
<UnsolvedCard v-if="report.unsolved.length" :items="report.unsolved" :username="viewing" />
|
||||
<ErrorsCard v-if="report.errors.length" :errors="report.errors" />
|
||||
<KnowledgeCard :knowledge="report.knowledge" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<UnsolvedCard
|
||||
v-if="report.unsolved.length"
|
||||
:items="report.unsolved"
|
||||
:username="viewing"
|
||||
/>
|
||||
<LessonDays
|
||||
:days="report.days"
|
||||
:class-name="report.className"
|
||||
:username="viewing"
|
||||
compact
|
||||
/>
|
||||
<UnsolvedCard v-if="report.unsolved.length" :items="report.unsolved" :username="viewing" />
|
||||
<LessonDays :days="report.days" :class-name="report.className" :username="viewing" compact />
|
||||
<ErrorsCard v-if="report.errors.length" :errors="report.errors" />
|
||||
<KnowledgeCard :knowledge="report.knowledge" />
|
||||
<AiSummary :period="period" :username="viewing" compact />
|
||||
@@ -249,7 +194,7 @@ function goStudent(username: string | null | undefined) {
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
height: var(--oj-ctrl-h);
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -257,9 +202,9 @@ function goStudent(username: string | null | undefined) {
|
||||
.seg button {
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-left: 1px solid v-bind("theme.borderColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor2");
|
||||
border-left: 1px solid v-bind('theme.borderColor');
|
||||
background: v-bind('theme.cardColor');
|
||||
color: v-bind('theme.textColor2');
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
cursor: pointer;
|
||||
@@ -282,7 +227,7 @@ function goStudent(username: string | null | undefined) {
|
||||
|
||||
.muted {
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.link {
|
||||
@@ -307,9 +252,9 @@ function goStudent(username: string | null | undefined) {
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.blank {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { LearningPeriod } from "@oj2/contract"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { streamAI } from "utils/stream"
|
||||
import { getLatestAnalysis } from "../../api"
|
||||
import type { LearningPeriod } from '@oj2/contract'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import { streamAI } from 'utils/stream'
|
||||
import { getLatestAnalysis } from '../../api'
|
||||
|
||||
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
|
||||
const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue"))
|
||||
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
|
||||
|
||||
/**
|
||||
* AI 小结。进来先拿这个时间段上次生成的那份(老师钉住的优先),没有才出按钮 ——
|
||||
@@ -21,26 +21,26 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const content = ref("")
|
||||
const createdAt = ref("")
|
||||
const content = ref('')
|
||||
const createdAt = ref('')
|
||||
const pinned = ref(false)
|
||||
const generating = ref(false)
|
||||
const waiting = ref(false)
|
||||
const failed = ref("")
|
||||
const failed = ref('')
|
||||
const expanded = ref(false)
|
||||
let controller: AbortController | null = null
|
||||
|
||||
async function load() {
|
||||
controller?.abort()
|
||||
generating.value = false
|
||||
failed.value = ""
|
||||
failed.value = ''
|
||||
expanded.value = false
|
||||
const period = props.period
|
||||
const username = props.username
|
||||
const record = await getLatestAnalysis(period, username).catch(() => null)
|
||||
if (period !== props.period || username !== props.username) return
|
||||
content.value = record?.analysis ?? ""
|
||||
createdAt.value = record?.createTime ?? ""
|
||||
content.value = record?.analysis ?? ''
|
||||
createdAt.value = record?.createTime ?? ''
|
||||
pinned.value = record?.isPinned ?? false
|
||||
}
|
||||
|
||||
@@ -53,13 +53,13 @@ async function generate() {
|
||||
controller = current
|
||||
generating.value = true
|
||||
waiting.value = true
|
||||
failed.value = ""
|
||||
content.value = ""
|
||||
failed.value = ''
|
||||
content.value = ''
|
||||
pinned.value = false
|
||||
expanded.value = true
|
||||
try {
|
||||
await streamAI(
|
||||
"ai/analysis",
|
||||
'ai/analysis',
|
||||
{ period: props.period, username: props.username || undefined },
|
||||
{
|
||||
signal: current.signal,
|
||||
@@ -72,7 +72,7 @@ async function generate() {
|
||||
createdAt.value = new Date().toISOString()
|
||||
} catch (error) {
|
||||
if (current.signal.aborted) return
|
||||
failed.value = (error as Error).message || "AI 服务暂时不可用,请稍后再试"
|
||||
failed.value = (error as Error).message || 'AI 服务暂时不可用,请稍后再试'
|
||||
} finally {
|
||||
if (controller === current) {
|
||||
generating.value = false
|
||||
@@ -93,17 +93,15 @@ const folded = computed(() => props.compact && !expanded.value && !generating.va
|
||||
</div>
|
||||
<div v-if="!generating" class="foot">
|
||||
<a v-if="folded" href="#" @click.prevent="expanded = true">展开全文 ▾</a>
|
||||
<span v-if="createdAt"
|
||||
>{{ parseTime(createdAt, "M月D日") }} 生成{{ pinned ? " · 老师置顶" : "" }}</span
|
||||
>
|
||||
<span v-if="createdAt">{{ parseTime(createdAt, 'M月D日') }} 生成{{ pinned ? ' · 老师置顶' : '' }}</span>
|
||||
<a href="#" @click.prevent="generate">重新分析</a>
|
||||
</div>
|
||||
</template>
|
||||
<n-spin v-else-if="waiting" size="small">
|
||||
<div class="waiting">正在看{{ username ? "" : "你的" }}做题记录…</div>
|
||||
<div class="waiting">正在看{{ username ? '' : '你的' }}做题记录…</div>
|
||||
</n-spin>
|
||||
<div v-else class="empty">
|
||||
<p>让 AI 按这些数据,写一段{{ username ? "" : "给你的" }}小结和下一步建议</p>
|
||||
<p>让 AI 按这些数据,写一段{{ username ? '' : '给你的' }}小结和下一步建议</p>
|
||||
<n-button type="primary" @click="generate">
|
||||
<template #icon><Icon icon="ph:sparkle" /></template>
|
||||
AI 帮我分析
|
||||
@@ -121,7 +119,7 @@ const folded = computed(() => props.compact && !expanded.value && !generating.va
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(107, 71, 194, 0.3);
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -145,7 +143,7 @@ h3 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.foot a {
|
||||
@@ -168,10 +166,10 @@ h3 {
|
||||
.failed {
|
||||
margin: 0;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.failed {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { LearningReport } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { statusName } from "../utils"
|
||||
import type { LearningReport } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { statusName } from '../utils'
|
||||
|
||||
/** 没通过的提交按结果分;错得最多的那种下面给一个检查办法 */
|
||||
const props = defineProps<{ errors: LearningReport["errors"] }>()
|
||||
const props = defineProps<{ errors: LearningReport['errors'] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
@@ -12,7 +12,7 @@ const theme = useThemeVars()
|
||||
const rows = computed(() => {
|
||||
const merged = new Map<string, number>()
|
||||
for (const item of props.errors) {
|
||||
const name = statusName(item.result) || "其他"
|
||||
const name = statusName(item.result) || '其他'
|
||||
merged.set(name, (merged.get(name) ?? 0) + item.count)
|
||||
}
|
||||
return [...merged].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count }))
|
||||
@@ -20,15 +20,15 @@ const rows = computed(() => {
|
||||
const total = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0))
|
||||
const top = computed(() => rows.value[0]?.count ?? 1)
|
||||
|
||||
const COLORS: Record<string, string> = { 答案错误: "#d03050", 编译失败: "#e7a33e" }
|
||||
const COLORS: Record<string, string> = { 答案错误: '#d03050', 编译失败: '#e7a33e' }
|
||||
|
||||
const TIPS: Record<string, string> = {
|
||||
答案错误: "先拿题目里的例子自己跑一遍,输出要和例子一字不差",
|
||||
编译失败: "看报错指的是第几行,多半是括号、冒号、引号没配对",
|
||||
运行时错误: "看报错说的是第几行、什么错,再回到那一行",
|
||||
运行超时: "看看循环能不能停下来",
|
||||
答案错误: '先拿题目里的例子自己跑一遍,输出要和例子一字不差',
|
||||
编译失败: '看报错指的是第几行,多半是括号、冒号、引号没配对',
|
||||
运行时错误: '看报错说的是第几行、什么错,再回到那一行',
|
||||
运行超时: '看看循环能不能停下来',
|
||||
}
|
||||
const tip = computed(() => TIPS[rows.value[0]?.name ?? ""])
|
||||
const tip = computed(() => TIPS[rows.value[0]?.name ?? ''])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,9 +59,9 @@ const tip = computed(() => TIPS[rows.value[0]?.name ?? ""])
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -75,7 +75,7 @@ h3 {
|
||||
h3 small {
|
||||
font-size: var(--oj-fs-meta);
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.row {
|
||||
@@ -94,7 +94,7 @@ h3 small {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
border-radius: 2px 5px 5px 2px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ h3 small {
|
||||
.tip {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
font-size: var(--oj-fs-sec);
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { LearningReport } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { LearningReport } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
|
||||
/** 全部知识点:做对过的标绿写几道,做过没做对的写「还没做对」,没碰过的虚线。点进题目列表按它筛 */
|
||||
const props = defineProps<{ knowledge: LearningReport["knowledge"] }>()
|
||||
const props = defineProps<{ knowledge: LearningReport['knowledge'] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const touched = computed(() => props.knowledge.filter((item) => item.tried).length)
|
||||
@@ -24,7 +24,7 @@ const touched = computed(() => props.knowledge.filter((item) => item.tried).leng
|
||||
>
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="sub">
|
||||
{{ item.solved ? `做对 ${item.solved} 道` : item.tried ? "还没做对" : "还没做过" }}
|
||||
{{ item.solved ? `做对 ${item.solved} 道` : item.tried ? '还没做对' : '还没做过' }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -37,9 +37,9 @@ const touched = computed(() => props.knowledge.filter((item) => item.tried).leng
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -53,7 +53,7 @@ h3 {
|
||||
h3 small {
|
||||
font-size: var(--oj-fs-meta);
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -70,7 +70,7 @@ h3 small {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ h3 small {
|
||||
}
|
||||
|
||||
.untouched {
|
||||
border: 1px dashed v-bind("theme.borderColor");
|
||||
color: v-bind("theme.textColor3");
|
||||
border: 1px dashed v-bind('theme.borderColor');
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { LearningDay } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { classLabel } from "utils/classes"
|
||||
import ProblemChip from "./ProblemChip.vue"
|
||||
import { dayLabel, isDone } from "../utils"
|
||||
import type { LearningDay } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import ProblemChip from './ProblemChip.vue'
|
||||
import { dayLabel, isDone } from '../utils'
|
||||
|
||||
/** 一天一行,新的在前:先是班上这节课的题,再是这天自己练的 */
|
||||
const props = defineProps<{
|
||||
@@ -32,12 +32,8 @@ watch(
|
||||
)
|
||||
|
||||
const shown = computed(() => (showAll.value ? props.days : props.days.slice(0, DAY_CAP)))
|
||||
const you = computed(() => (props.username ? "" : "你"))
|
||||
const subtitle = computed(() =>
|
||||
props.className
|
||||
? `${classLabel(props.className)}课上做的题,加上${you.value}自己练的`
|
||||
: `${you.value}做过的题`,
|
||||
)
|
||||
const you = computed(() => (props.username ? '' : '你'))
|
||||
const subtitle = computed(() => (props.className ? `${classLabel(props.className)}课上做的题,加上${you.value}自己练的` : `${you.value}做过的题`))
|
||||
|
||||
function ownOf(day: LearningDay) {
|
||||
return openOwn.value.has(day.day) ? day.own : day.own.slice(0, OWN_CAP)
|
||||
@@ -57,56 +53,34 @@ function expand(day: string) {
|
||||
<div class="when">
|
||||
<b>{{ dayLabel(day.day).date }}</b>
|
||||
<span>{{ dayLabel(day.day).weekday }}</span>
|
||||
<span v-if="compact && day.lesson.length" class="done">
|
||||
做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }}
|
||||
</span>
|
||||
<span v-if="compact && day.lesson.length" class="done"> 做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }} </span>
|
||||
</div>
|
||||
<div class="groups">
|
||||
<div v-if="day.lesson.length" class="chips">
|
||||
<ProblemChip
|
||||
v-for="item in day.lesson"
|
||||
:key="item.displayId"
|
||||
:item="item"
|
||||
in-lesson
|
||||
:username="username"
|
||||
/>
|
||||
<ProblemChip v-for="item in day.lesson" :key="item.displayId" :item="item" in-lesson :username="username" />
|
||||
</div>
|
||||
<div v-if="day.own.length" class="chips">
|
||||
<span class="own">自己练</span>
|
||||
<ProblemChip
|
||||
v-for="item in ownOf(day)"
|
||||
:key="item.displayId"
|
||||
:item="item"
|
||||
:in-lesson="false"
|
||||
:username="username"
|
||||
/>
|
||||
<button
|
||||
v-if="day.own.length > OWN_CAP && !openOwn.has(day.day)"
|
||||
class="more"
|
||||
@click="expand(day.day)"
|
||||
>
|
||||
<ProblemChip v-for="item in ownOf(day)" :key="item.displayId" :item="item" :in-lesson="false" :username="username" />
|
||||
<button v-if="day.own.length > OWN_CAP && !openOwn.has(day.day)" class="more" @click="expand(day.day)">
|
||||
还有 {{ day.own.length - OWN_CAP }} 道 ▾
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!compact" class="done">
|
||||
<template v-if="day.lesson.length">
|
||||
做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }}
|
||||
</template>
|
||||
<template v-if="day.lesson.length"> 做对 {{ day.lesson.filter(isDone).length }}/{{ day.lesson.length }} </template>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!showAll && days.length > DAY_CAP" class="earlier" @click="showAll = true">
|
||||
更早的 {{ days.length - DAY_CAP }} 天 ▾
|
||||
</button>
|
||||
<button v-if="!showAll && days.length > DAY_CAP" class="earlier" @click="showAll = true">更早的 {{ days.length - DAY_CAP }} 天 ▾</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 14px 20px 6px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -120,14 +94,14 @@ h3 {
|
||||
h3 small {
|
||||
font-size: var(--oj-fs-meta);
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.day {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.when {
|
||||
@@ -144,7 +118,7 @@ h3 small {
|
||||
|
||||
.when span {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.groups {
|
||||
@@ -164,7 +138,7 @@ h3 small {
|
||||
|
||||
.own {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
@@ -174,7 +148,7 @@ h3 small {
|
||||
text-align: right;
|
||||
line-height: 30px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -182,7 +156,7 @@ h3 small {
|
||||
.earlier {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px dashed v-bind("theme.borderColor");
|
||||
border: 1px dashed v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #18a058;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { LearningItem } from "@oj2/contract"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { statusName } from "../utils"
|
||||
import type { LearningItem } from '@oj2/contract'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { statusName } from '../utils'
|
||||
|
||||
/**
|
||||
* 一道题一块:绿勾一次就对、黄色交几次才对、红色没做对、虚线没交。
|
||||
@@ -17,32 +17,24 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const you = computed(() => (props.username ? "" : "你"))
|
||||
const you = computed(() => (props.username ? '' : '你'))
|
||||
|
||||
const problemLink = computed(() => `/problem/${props.item.displayId}`)
|
||||
const submissionLink = computed(() => ({
|
||||
path: "/submission",
|
||||
query: props.username
|
||||
? { problem: props.item.displayId, username: props.username, exactUsername: "1" }
|
||||
: { problem: props.item.displayId, myself: "1" },
|
||||
path: '/submission',
|
||||
query: props.username ? { problem: props.item.displayId, username: props.username, exactUsername: '1' } : { problem: props.item.displayId, myself: '1' },
|
||||
}))
|
||||
|
||||
const detail = computed(() => {
|
||||
const item = props.item
|
||||
const rank =
|
||||
props.inLesson && item.rank && item.classSolved
|
||||
? `班上第 ${item.rank} 个做对(${item.classSolved} 人做对)`
|
||||
: ""
|
||||
if (item.status === "first") return ["一次就对", rank]
|
||||
if (item.status === "tries") return [`交了 ${item.attempts} 次才对`, rank]
|
||||
if (item.status === "fail") {
|
||||
const rank = props.inLesson && item.rank && item.classSolved ? `班上第 ${item.rank} 个做对(${item.classSolved} 人做对)` : ''
|
||||
if (item.status === 'first') return ['一次就对', rank]
|
||||
if (item.status === 'tries') return [`交了 ${item.attempts} 次才对`, rank]
|
||||
if (item.status === 'fail') {
|
||||
const last = statusName(item.lastResult)
|
||||
return [`交了 ${item.attempts} 次还没做对`, last ? `最后一次${last}` : ""]
|
||||
return [`交了 ${item.attempts} 次还没做对`, last ? `最后一次${last}` : '']
|
||||
}
|
||||
return [
|
||||
`这节课的题,${you.value}还没交`,
|
||||
item.classSolved ? `班上 ${item.classSolved} 人做对` : "",
|
||||
]
|
||||
return [`这节课的题,${you.value}还没交`, item.classSolved ? `班上 ${item.classSolved} 人做对` : '']
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -50,25 +42,16 @@ const detail = computed(() => {
|
||||
<n-popover trigger="hover" :delay="200" :show-arrow="false" placement="bottom-start">
|
||||
<template #trigger>
|
||||
<router-link :to="username ? submissionLink : problemLink" class="chip" :class="item.status">
|
||||
<Icon
|
||||
v-if="item.status === 'first'"
|
||||
icon="ph:check-bold"
|
||||
class="check"
|
||||
aria-label="一次就对"
|
||||
/>
|
||||
<Icon v-if="item.status === 'first'" icon="ph:check-bold" class="check" aria-label="一次就对" />
|
||||
<span class="title">{{ item.title }}</span>
|
||||
<span v-if="item.status === 'tries'" class="count">交 {{ item.attempts }} 次</span>
|
||||
<span v-else-if="item.status === 'fail'" class="count"
|
||||
>没做对 · {{ item.attempts }} 次</span
|
||||
>
|
||||
<span v-else-if="item.status === 'fail'" class="count">没做对 · {{ item.attempts }} 次</span>
|
||||
<span v-else-if="item.status === 'none'" class="count">没交</span>
|
||||
</router-link>
|
||||
</template>
|
||||
<div class="tip">
|
||||
<span>{{ detail.filter(Boolean).join(" · ") }}</span>
|
||||
<router-link v-if="item.status !== 'none'" :to="submissionLink"
|
||||
>{{ username ? "看提交" : "看你的提交" }} ›</router-link
|
||||
>
|
||||
<span>{{ detail.filter(Boolean).join(' · ') }}</span>
|
||||
<router-link v-if="item.status !== 'none'" :to="submissionLink">{{ username ? '看提交' : '看你的提交' }} ›</router-link>
|
||||
<router-link v-else-if="!username" :to="problemLink">去做 ›</router-link>
|
||||
</div>
|
||||
</n-popover>
|
||||
@@ -86,7 +69,7 @@ const detail = computed(() => {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -130,8 +113,8 @@ const detail = computed(() => {
|
||||
}
|
||||
|
||||
.none {
|
||||
border: 1px dashed v-bind("theme.borderColor");
|
||||
color: v-bind("theme.textColor3");
|
||||
border: 1px dashed v-bind('theme.borderColor');
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LearningReport } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { LearningReport } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
|
||||
/** 顶上一排数:做对几道、一次就对、上了几节课、差一点的;老师看时最前面多一格「班上第几」 */
|
||||
const props = defineProps<{ report: LearningReport; teacher: boolean; compact?: boolean }>()
|
||||
@@ -23,20 +23,18 @@ const cells = computed(() => {
|
||||
// 数和「道」之间用不换行空格:手机上两列挤,单个「道」会被甩到下一行
|
||||
{
|
||||
big: r.solved,
|
||||
unit: "道",
|
||||
sub: c ? `做对 · 班上中间那位 ${c.medianSolved}\u00a0道` : "做对",
|
||||
unit: '道',
|
||||
sub: c ? `做对 · 班上中间那位 ${c.medianSolved}\u00a0道` : '做对',
|
||||
},
|
||||
{
|
||||
big: r.firstTry,
|
||||
unit: "道",
|
||||
unit: '道',
|
||||
sub: c && !props.compact ? `${firstSub},班上中间那位 ${c.medianFirstPercent}%` : firstSub,
|
||||
},
|
||||
{ big: r.lessonCount, unit: "节课", sub: `交了 ${r.submissionCount} 次` },
|
||||
{ big: r.lessonCount, unit: '节课', sub: `交了 ${r.submissionCount} 次` },
|
||||
]
|
||||
if (r.unsolved.length)
|
||||
list.push({ big: r.unsolved.length, unit: "道", sub: "差一点 · 交过还没做对", warn: true })
|
||||
if (props.teacher && c)
|
||||
list.unshift({ big: c.rank, unit: `/ ${c.size}`, sub: "班上按做对题数排第几" })
|
||||
if (r.unsolved.length) list.push({ big: r.unsolved.length, unit: '道', sub: '差一点 · 交过还没做对', warn: true })
|
||||
if (props.teacher && c) list.unshift({ big: c.rank, unit: `/ ${c.size}`, sub: '班上按做对题数排第几' })
|
||||
return list
|
||||
})
|
||||
</script>
|
||||
@@ -56,16 +54,16 @@ const cells = computed(() => {
|
||||
<style scoped>
|
||||
.strip {
|
||||
display: flex;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px 20px;
|
||||
border-left: 1px solid v-bind("theme.dividerColor");
|
||||
border-left: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.cell:first-child {
|
||||
@@ -94,7 +92,7 @@ const cells = computed(() => {
|
||||
|
||||
.sub {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.compact {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { LearningItem } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { statusName } from "../utils"
|
||||
import type { LearningItem } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { statusName } from '../utils'
|
||||
|
||||
/** 交过还没做对的题。学生「接着做」,老师「看提交」 */
|
||||
const props = defineProps<{ items: LearningItem[]; username?: string }>()
|
||||
@@ -11,8 +11,8 @@ const router = useRouter()
|
||||
|
||||
function submissions(item: LearningItem) {
|
||||
return {
|
||||
path: "/submission",
|
||||
query: { problem: item.displayId, username: props.username, exactUsername: "1" },
|
||||
path: '/submission',
|
||||
query: { problem: item.displayId, username: props.username, exactUsername: '1' },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -27,20 +27,11 @@ function submissions(item: LearningItem) {
|
||||
>{{ item.title }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
交了 {{ item.attempts }} 次<template v-if="statusName(item.lastResult)">
|
||||
· 最后一次{{ statusName(item.lastResult) }}</template
|
||||
>
|
||||
交了 {{ item.attempts }} 次<template v-if="statusName(item.lastResult)"> · 最后一次{{ statusName(item.lastResult) }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<router-link v-if="username" :to="submissions(item)" class="link">看提交 ›</router-link>
|
||||
<n-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="router.push(`/problem/${item.displayId}`)"
|
||||
>
|
||||
接着做
|
||||
</n-button>
|
||||
<n-button v-else type="primary" size="small" @click="router.push(`/problem/${item.displayId}`)"> 接着做 </n-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -81,13 +72,13 @@ h3 {
|
||||
.pid {
|
||||
margin-right: 6px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.link {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { LearningItem } from "@oj2/contract"
|
||||
import { JUDGE_STATUS } from "utils/constants"
|
||||
import type { LearningItem } from '@oj2/contract'
|
||||
import { JUDGE_STATUS } from 'utils/constants'
|
||||
|
||||
const WEEKDAY = "日一二三四五六"
|
||||
const WEEKDAY = '日一二三四五六'
|
||||
|
||||
/** `2026-09-18` → 9月18日 / 周五。日历日本身就是东八区的,直接拆字符串,不经 Date */
|
||||
export function dayLabel(day: string) {
|
||||
const [year, month, date] = day.split("-").map(Number) as [number, number, number]
|
||||
const [year, month, date] = day.split('-').map(Number) as [number, number, number]
|
||||
const weekday = new Date(Date.UTC(year, month - 1, date)).getUTCDay()
|
||||
return { date: `${month}月${date}日`, weekday: `周${WEEKDAY[weekday]}` }
|
||||
}
|
||||
|
||||
export function statusName(result: number | null) {
|
||||
if (result === null) return ""
|
||||
return JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? ""
|
||||
if (result === null) return ''
|
||||
return JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? ''
|
||||
}
|
||||
|
||||
export function isDone(item: LearningItem) {
|
||||
return item.status === "first" || item.status === "tries"
|
||||
return item.status === 'first' || item.status === 'tries'
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||
import AdminLink from "shared/components/AdminLink.vue"
|
||||
import NotFound from "shared/components/NotFound.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { errorCode } from "utils/api"
|
||||
import { parseTime, zonedParts, zonedYear } from "utils/functions"
|
||||
import type { Announcement, AnnouncementListItem } from "utils/types"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getAnnouncement, getAnnouncementList } from 'oj/api'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import NotFound from 'shared/components/NotFound.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { errorCode } from 'utils/api'
|
||||
import { parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||
import type { Announcement, AnnouncementListItem } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 公告:左边列表、右边全文,和后台 AI 报告一个排法。原来是一张四列的表、正文开弹框 ——
|
||||
@@ -39,9 +39,7 @@ const queryId = computed(() => {
|
||||
})
|
||||
|
||||
/** 桌面上没带 id 就看第一条(置顶的排最前);手机上没带 id 就只看列表 */
|
||||
const selectedId = computed(
|
||||
() => queryId.value ?? (isDesktop.value ? (announcements.value[0]?.id ?? null) : null),
|
||||
)
|
||||
const selectedId = computed(() => queryId.value ?? (isDesktop.value ? (announcements.value[0]?.id ?? null) : null))
|
||||
|
||||
function select(id: number) {
|
||||
router.replace({ query: { ...route.query, id } })
|
||||
@@ -53,42 +51,40 @@ function backToList() {
|
||||
}
|
||||
|
||||
const detail = ref<Announcement | null>(null)
|
||||
const detailState = ref<"idle" | "loading" | "ok" | "missing" | "failed">("idle")
|
||||
const detailState = ref<'idle' | 'loading' | 'ok' | 'missing' | 'failed'>('idle')
|
||||
let seq = 0
|
||||
|
||||
async function loadDetail(id: number | null) {
|
||||
const mine = ++seq
|
||||
if (id === null) {
|
||||
detail.value = null
|
||||
detailState.value = "idle"
|
||||
detailState.value = 'idle'
|
||||
return
|
||||
}
|
||||
detailState.value = "loading"
|
||||
detailState.value = 'loading'
|
||||
try {
|
||||
const res = await getAnnouncement(id)
|
||||
if (mine !== seq) return
|
||||
detail.value = res
|
||||
detailState.value = "ok"
|
||||
detailState.value = 'ok'
|
||||
} catch (err) {
|
||||
if (mine !== seq) return
|
||||
detail.value = null
|
||||
detailState.value = errorCode(err) === "announcement-not-found" ? "missing" : "failed"
|
||||
detailState.value = errorCode(err) === 'announcement-not-found' ? 'missing' : 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedId, loadDetail, { immediate: true })
|
||||
|
||||
function dateText(value: string) {
|
||||
return parseTime(value, zonedParts(value)!.year === zonedYear() ? "M月D日" : "YYYY年M月D日")
|
||||
return parseTime(value, zonedParts(value)!.year === zonedYear() ? 'M月D日' : 'YYYY年M月D日')
|
||||
}
|
||||
|
||||
/** 「2026年9月19日发布 · 9月23日改过」:同一天改的不写 */
|
||||
function metaText(item: Announcement) {
|
||||
const created = parseTime(item.createTime, "YYYY年M月D日")
|
||||
const updated = parseTime(item.lastUpdateTime, "YYYY年M月D日")
|
||||
return updated === created
|
||||
? `${created}发布`
|
||||
: `${created}发布 · ${dateText(item.lastUpdateTime)}改过`
|
||||
const created = parseTime(item.createTime, 'YYYY年M月D日')
|
||||
const updated = parseTime(item.lastUpdateTime, 'YYYY年M月D日')
|
||||
return updated === created ? `${created}发布` : `${created}发布 · ${dateText(item.lastUpdateTime)}改过`
|
||||
}
|
||||
|
||||
/** 点正文里的图放大看:截图常常是整屏缩进正文栏的,字看不清 */
|
||||
@@ -98,7 +94,7 @@ function onContentClick(e: MouseEvent) {
|
||||
if (e.target instanceof HTMLImageElement) zoomSrc.value = e.target.src
|
||||
}
|
||||
|
||||
onKeyStroke("Escape", () => {
|
||||
onKeyStroke('Escape', () => {
|
||||
zoomSrc.value = null
|
||||
})
|
||||
|
||||
@@ -112,12 +108,7 @@ const showReader = computed(() => isDesktop.value || queryId.value !== null)
|
||||
<h2>公告</h2>
|
||||
<template v-if="userStore.isSuperAdmin">
|
||||
<span class="grow"></span>
|
||||
<AdminLink
|
||||
v-if="showReader && detail"
|
||||
:to="{ name: 'admin announcement edit', params: { announcementID: detail.id } }"
|
||||
>
|
||||
编辑这条
|
||||
</AdminLink>
|
||||
<AdminLink v-if="showReader && detail" :to="{ name: 'admin announcement edit', params: { announcementID: detail.id } }"> 编辑这条 </AdminLink>
|
||||
<AdminLink to="/admin/announcement/create">发公告</AdminLink>
|
||||
</template>
|
||||
</div>
|
||||
@@ -138,19 +129,13 @@ const showReader = computed(() => isDesktop.value || queryId.value !== null)
|
||||
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
|
||||
<span class="ellipsis">{{ item.title }}</span>
|
||||
</span>
|
||||
<span class="muted num">{{ item.tag || "公告" }} · {{ dateText(item.createTime) }}</span>
|
||||
<span class="muted num">{{ item.tag || '公告' }} · {{ dateText(item.createTime) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<article v-if="showReader" class="reader">
|
||||
<a v-if="!isDesktop" class="back" @click="backToList">‹ 全部公告</a>
|
||||
<NotFound
|
||||
v-if="detailState === 'missing'"
|
||||
title="这条公告不存在"
|
||||
hint="可能已经撤下了"
|
||||
to="/announcement"
|
||||
to-label="看全部公告"
|
||||
/>
|
||||
<NotFound v-if="detailState === 'missing'" title="这条公告不存在" hint="可能已经撤下了" to="/announcement" to-label="看全部公告" />
|
||||
<NotFound v-else-if="detailState === 'failed'" icon="ph:wifi-slash" title="公告没拉下来">
|
||||
<n-button type="primary" @click="loadDetail(selectedId)">再试一次</n-button>
|
||||
</NotFound>
|
||||
@@ -158,12 +143,10 @@ const showReader = computed(() => isDesktop.value || queryId.value !== null)
|
||||
<template v-if="detail">
|
||||
<header class="reader-head">
|
||||
<h3>
|
||||
<n-tag v-if="detail.top" type="warning" size="small" :bordered="false">
|
||||
置顶
|
||||
</n-tag>
|
||||
<n-tag v-if="detail.top" type="warning" size="small" :bordered="false"> 置顶 </n-tag>
|
||||
{{ detail.title }}
|
||||
</h3>
|
||||
<span class="muted num">{{ detail.tag || "公告" }} · {{ metaText(detail) }}</span>
|
||||
<span class="muted num">{{ detail.tag || '公告' }} · {{ metaText(detail) }}</span>
|
||||
</header>
|
||||
<div class="content" v-html="detail.content" @click="onContentClick"></div>
|
||||
</template>
|
||||
@@ -215,7 +198,7 @@ h2 {
|
||||
.list {
|
||||
width: 340px;
|
||||
flex: none;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: var(--oj-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -227,7 +210,7 @@ h2 {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
@@ -240,12 +223,12 @@ h2 {
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.item.on {
|
||||
background: v-bind("theme.hoverColor");
|
||||
box-shadow: inset 3px 0 0 v-bind("theme.primaryColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
box-shadow: inset 3px 0 0 v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.item-title {
|
||||
@@ -265,7 +248,7 @@ h2 {
|
||||
|
||||
.muted {
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.num {
|
||||
@@ -277,14 +260,14 @@ h2 {
|
||||
min-width: 0;
|
||||
padding: 20px var(--oj-pad-x) 28px;
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -293,7 +276,7 @@ h2 {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -325,7 +308,7 @@ h3 {
|
||||
}
|
||||
|
||||
.content :deep(a) {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.zoom {
|
||||
|
||||
+79
-113
@@ -9,7 +9,7 @@ import type {
|
||||
RankHiddenRequest,
|
||||
SetReactionRequest,
|
||||
UpdateProfileRequest,
|
||||
} from "@oj2/contract"
|
||||
} from '@oj2/contract'
|
||||
import {
|
||||
type AiAnalysisRecord,
|
||||
type LearningPeriod,
|
||||
@@ -59,11 +59,11 @@ import {
|
||||
type LessonLanguage,
|
||||
type SubmissionStatisticsGrid,
|
||||
type TodaySubmissionStatistics,
|
||||
} from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import { authFetch } from "utils/auth"
|
||||
import { contract } from "utils/contract"
|
||||
import { toProblemRow } from "utils/transforms"
|
||||
} from '@oj2/contract'
|
||||
import api from 'utils/api'
|
||||
import { authFetch } from 'utils/auth'
|
||||
import { contract } from 'utils/contract'
|
||||
import { toProblemRow } from 'utils/transforms'
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementListItem,
|
||||
@@ -77,7 +77,7 @@ import type {
|
||||
SubmissionListPayload,
|
||||
SubmitCodePayload,
|
||||
TutorialProgress,
|
||||
} from "utils/types"
|
||||
} from 'utils/types'
|
||||
|
||||
/**
|
||||
* 题目详情 / 提交详情的 schema 按需加载,和请求并行发出。静态 import 会把 zod
|
||||
@@ -85,11 +85,11 @@ import type {
|
||||
* 闸门本身仍是同步的(等 schema 到了再返回),submissionDetailSchema 有 `.catch()`,
|
||||
* 放行原文和解析结果不等价,所以不能像 /me 那样改成 contractDeferred。
|
||||
*/
|
||||
const loadDetailSchemas = () => import("./detailSchemas")
|
||||
const loadDetailSchemas = () => import('./detailSchemas')
|
||||
|
||||
/** 题目列表顶上那行和左栏的进度;没登录是 null */
|
||||
export function getProblemProgress() {
|
||||
return api.get<ProblemProgress | null>("problems/progress")
|
||||
return api.get<ProblemProgress | null>('problems/progress')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,23 +97,20 @@ export function getProblemProgress() {
|
||||
* 返回题号。空串的参数 utils/api 会去掉
|
||||
*/
|
||||
export function getRandomProblem(params: { tag: string; type: string; language: string }) {
|
||||
return api.get<string>("problems/random", { params })
|
||||
return api.get<string>('problems/random', { params })
|
||||
}
|
||||
|
||||
export async function getProblem(problemID: string, contestID: string) {
|
||||
const endpoint = contestID
|
||||
? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}`
|
||||
: `problems/${encodeURIComponent(problemID)}`
|
||||
const [{ problemDetailSchema }, response] = await Promise.all([
|
||||
loadDetailSchemas(),
|
||||
api.get<unknown>(endpoint),
|
||||
])
|
||||
const [{ problemDetailSchema }, response] = await Promise.all([loadDetailSchemas(), api.get<unknown>(endpoint)])
|
||||
// 形状即契约 —— 之前这里手抄了一份 camel→snake 的键名映射,抄漏一个字段就是
|
||||
// 静默 undefined。走 `contract()` 而不是裸 `parse()`:原来是
|
||||
// `problemDetailSchema.parse(v) as ProblemDetail`,`as` 把校验结果又断言回去、
|
||||
// 等于没校验,而 `parse` 抛错会让整个题目页白屏。现在形状不符时记一条控制台
|
||||
// 分歧再放行原始数据。
|
||||
const problem: ProblemDetail = contract("GET /problems/:id", problemDetailSchema, response)
|
||||
const problem: ProblemDetail = contract('GET /problems/:id', problemDetailSchema, response)
|
||||
return problem
|
||||
}
|
||||
|
||||
@@ -127,23 +124,23 @@ export async function getSubmission(id: string, attempts = false): Promise<Submi
|
||||
const [{ submissionDetailSchema }, response] = await Promise.all([
|
||||
loadDetailSchemas(),
|
||||
api.get<unknown>(`submissions/${encodeURIComponent(id)}`, {
|
||||
params: attempts ? { attempts: "1" } : undefined,
|
||||
params: attempts ? { attempts: '1' } : undefined,
|
||||
}),
|
||||
])
|
||||
return contract("GET /submissions/:id", submissionDetailSchema, response)
|
||||
return contract('GET /submissions/:id', submissionDetailSchema, response)
|
||||
}
|
||||
|
||||
export function submitCode(data: SubmitCodePayload) {
|
||||
return api.post<CreateSubmissionResponse>("submissions", data satisfies CreateSubmissionRequest)
|
||||
return api.post<CreateSubmissionResponse>('submissions', data satisfies CreateSubmissionRequest)
|
||||
}
|
||||
|
||||
/** 后端格式化认的四种(ruff / clang-format / sqlparse),别的语言提交前不格式化 */
|
||||
export function isFormatLanguage(value: string): value is FormatCodeRequest["language"] {
|
||||
return ["python", "c", "cpp", "sql"].includes(value)
|
||||
export function isFormatLanguage(value: string): value is FormatCodeRequest['language'] {
|
||||
return ['python', 'c', 'cpp', 'sql'].includes(value)
|
||||
}
|
||||
|
||||
export function formatCode(data: FormatCodeRequest & { problemId: number }) {
|
||||
return api.post<FormatCodeResponse>("code/format", {
|
||||
return api.post<FormatCodeResponse>('code/format', {
|
||||
code: data.code,
|
||||
language: data.language,
|
||||
problemId: data.problemId,
|
||||
@@ -151,18 +148,13 @@ export function formatCode(data: FormatCodeRequest & { problemId: number }) {
|
||||
}
|
||||
|
||||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
const endpoint = params.contestId
|
||||
? `contests/${encodeURIComponent(params.contestId)}/submissions`
|
||||
: "submissions"
|
||||
const endpoint = params.contestId ? `contests/${encodeURIComponent(params.contestId)}/submissions` : 'submissions'
|
||||
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
|
||||
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
|
||||
return api.get<{ results: SubmissionListItem[]; total: number; unknownProblems: string[] }>(
|
||||
endpoint,
|
||||
{
|
||||
// contestId 走的是路径,page 只有前端分页器用
|
||||
params: { ...params, contestId: undefined, page: undefined },
|
||||
},
|
||||
)
|
||||
return api.get<{ results: SubmissionListItem[]; total: number; unknownProblems: string[] }>(endpoint, {
|
||||
// contestId 走的是路径,page 只有前端分页器用
|
||||
params: { ...params, contestId: undefined, page: undefined },
|
||||
})
|
||||
}
|
||||
|
||||
export function getRankOfProblem(problemId: string) {
|
||||
@@ -170,12 +162,12 @@ export function getRankOfProblem(problemId: string) {
|
||||
}
|
||||
|
||||
export function getTodaySubmissionCount(language?: string) {
|
||||
return api.get<number>("submissions/today-count", { params: { language } })
|
||||
return api.get<number>('submissions/today-count', { params: { language } })
|
||||
}
|
||||
|
||||
/** 「今日提交数」标签点开的统计。公开接口,口径同那颗标签:今天 + 非比赛提交 */
|
||||
export function getTodaySubmissionStatistics() {
|
||||
return api.get<TodaySubmissionStatistics>("submissions/today-statistics")
|
||||
return api.get<TodaySubmissionStatistics>('submissions/today-statistics')
|
||||
}
|
||||
|
||||
export function adminRejudge(id: string) {
|
||||
@@ -189,26 +181,21 @@ export function getSubmissionStatistics(
|
||||
/** 班级(user.class_name)精确匹配。别再拼 `ks231` 塞进 username —— 会连带 2311、2312 班 */
|
||||
className?: string,
|
||||
) {
|
||||
return api.get<SubmissionStatistics>("submissions/statistics", {
|
||||
return api.get<SubmissionStatistics>('submissions/statistics', {
|
||||
params: { ...duration, problemDisplayId, username, className },
|
||||
})
|
||||
}
|
||||
|
||||
/** 统计「一行一节课」的总览(没选班、没填学生和题号时)。limit 是列几节课,「再往前看」加它 */
|
||||
export function getSubmissionLessons(duration: { start?: string; end: string }, limit?: number) {
|
||||
return api.get<SubmissionLessons>("submissions/statistics/lessons", {
|
||||
return api.get<SubmissionLessons>('submissions/statistics/lessons', {
|
||||
params: { ...duration, limit },
|
||||
})
|
||||
}
|
||||
|
||||
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
|
||||
export function getSubmissionStatisticsGrid(
|
||||
duration: { start?: string; end: string },
|
||||
problemDisplayId?: string,
|
||||
username?: string,
|
||||
className?: string,
|
||||
) {
|
||||
return api.get<SubmissionStatisticsGrid>("submissions/statistics/grid", {
|
||||
export function getSubmissionStatisticsGrid(duration: { start?: string; end: string }, problemDisplayId?: string, username?: string, className?: string) {
|
||||
return api.get<SubmissionStatisticsGrid>('submissions/statistics/grid', {
|
||||
params: { ...duration, problemDisplayId, username, className },
|
||||
})
|
||||
}
|
||||
@@ -217,12 +204,8 @@ export function getSubmissionStatisticsGrid(
|
||||
* 排名页的榜:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部。学生看自己的班和年级,
|
||||
* 老师用 `className` 选班(不给就是最近上课的班)。`full` 把本年级 / 全服中间折起来的那段也要回来
|
||||
*/
|
||||
export function getRankBoard(
|
||||
scope: RankScope,
|
||||
period: RankPeriod,
|
||||
options: { className?: string; full?: boolean } = {},
|
||||
) {
|
||||
return api.get<RankBoard>("rankings/board", {
|
||||
export function getRankBoard(scope: RankScope, period: RankPeriod, options: { className?: string; full?: boolean } = {}) {
|
||||
return api.get<RankBoard>('rankings/board', {
|
||||
params: {
|
||||
scope,
|
||||
period,
|
||||
@@ -234,17 +217,17 @@ export function getRankBoard(
|
||||
|
||||
/** 班级对抗:全服各班这学期人均做对,外加这周人均涨了多少 */
|
||||
export function getClassBattle() {
|
||||
return api.get<ClassBattleItem[]>("rankings/classes")
|
||||
return api.get<ClassBattleItem[]>('rankings/classes')
|
||||
}
|
||||
|
||||
/** 班级详情(抽屉):谁都能看,「要多关心的同学」只有老师拿得到 */
|
||||
export function getClassDetail(className: string) {
|
||||
return api.get<ClassDetail>("rankings/class-detail", { params: { className } })
|
||||
return api.get<ClassDetail>('rankings/class-detail', { params: { className } })
|
||||
}
|
||||
|
||||
/** 本班最近几周的每周冠军 */
|
||||
export function getWeeklyChampions(className?: string) {
|
||||
return api.get<WeeklyChampion[]>("rankings/champions", {
|
||||
return api.get<WeeklyChampion[]>('rankings/champions', {
|
||||
params: { className: className || undefined },
|
||||
})
|
||||
}
|
||||
@@ -263,27 +246,23 @@ export function clearMood(userId: number) {
|
||||
* 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
|
||||
* 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。
|
||||
*/
|
||||
export function getWeeklyRank(scope: "global" | "class") {
|
||||
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
|
||||
export function getWeeklyRank(scope: 'global' | 'class') {
|
||||
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
|
||||
}
|
||||
|
||||
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */
|
||||
export function getClassBoard(className?: string) {
|
||||
return api.get<ClassBoard>("classroom/board", { params: { className } })
|
||||
return api.get<ClassBoard>('classroom/board', { params: { className } })
|
||||
}
|
||||
|
||||
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */
|
||||
export function getClassLesson(className?: string) {
|
||||
return api.get<ClassLesson>("classroom/lesson", { params: { className } })
|
||||
return api.get<ClassLesson>('classroom/lesson', { params: { className } })
|
||||
}
|
||||
|
||||
/** 给这个班布置今天的题,空数组 = 清掉 */
|
||||
export function setClassLesson(
|
||||
className: string,
|
||||
problemDisplayIds: string[],
|
||||
language: LessonLanguage | null,
|
||||
) {
|
||||
return api.put<null>("classroom/lesson", {
|
||||
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null) {
|
||||
return api.put<null>('classroom/lesson', {
|
||||
className,
|
||||
problemDisplayIds,
|
||||
language,
|
||||
@@ -292,23 +271,23 @@ export function setClassLesson(
|
||||
|
||||
/** 我的知识点地图(只有自己的,不能查别人) */
|
||||
export function getKnowledgeMap() {
|
||||
return api.get<KnowledgeMap>("me/knowledge")
|
||||
return api.get<KnowledgeMap>('me/knowledge')
|
||||
}
|
||||
|
||||
/** 老师首页「今天的课」:今天在上课的每个班 */
|
||||
export function getClassToday() {
|
||||
return api.get<ClassToday>("classroom/today")
|
||||
return api.get<ClassToday>('classroom/today')
|
||||
}
|
||||
|
||||
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
|
||||
export function getLastVisit() {
|
||||
return api.get<LastVisit>("me/last-visit")
|
||||
return api.get<LastVisit>('me/last-visit')
|
||||
}
|
||||
|
||||
/** 班级 PK:不给班就用自己的班(老师用默认的班),只给一个班就配一个同年级的对手 */
|
||||
export function getClassPk(classes: string[], period: ClassPkPeriod) {
|
||||
return api.get<ClassPk>("rankings/pk", {
|
||||
params: { period, ...(classes.length ? { classes: classes.join(",") } : {}) },
|
||||
return api.get<ClassPk>('rankings/pk', {
|
||||
params: { period, ...(classes.length ? { classes: classes.join(',') } : {}) },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -322,8 +301,8 @@ export function getContestList(query: {
|
||||
/** 只看我交过题的 */
|
||||
joined?: boolean
|
||||
}) {
|
||||
return api.get<ContestList>("contests", {
|
||||
params: { ...query, joined: query.joined ? "1" : undefined },
|
||||
return api.get<ContestList>('contests', {
|
||||
params: { ...query, joined: query.joined ? '1' : undefined },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -357,16 +336,16 @@ export function getContestClassView(contestID: string) {
|
||||
|
||||
export function uploadAvatar(file: File) {
|
||||
const form = new window.FormData()
|
||||
form.append("image", file)
|
||||
return api.post("me/avatar", form)
|
||||
form.append('image', file)
|
||||
return api.post('me/avatar', form)
|
||||
}
|
||||
|
||||
export function updateProfile(data: { realName?: string; mood?: string }) {
|
||||
return api.put<Profile>("me/profile", data satisfies UpdateProfileRequest)
|
||||
return api.put<Profile>('me/profile', data satisfies UpdateProfileRequest)
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>('announcements', {
|
||||
params: { limit, offset },
|
||||
})
|
||||
}
|
||||
@@ -392,27 +371,27 @@ export function getProfileOverview(username: string) {
|
||||
|
||||
/** 不用图片了,退回默认头像(名字的字) */
|
||||
export function resetAvatar() {
|
||||
return api.delete<{ avatar: string }>("me/avatar")
|
||||
return api.delete<{ avatar: string }>('me/avatar')
|
||||
}
|
||||
|
||||
export function getTutorial(id: number) {
|
||||
return api.get<Tutorial>(`tutorials/${id}`)
|
||||
}
|
||||
|
||||
export function getTutorials(type: "python" | "c") {
|
||||
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
|
||||
export function getTutorials(type: 'python' | 'c') {
|
||||
return api.get<TutorialSummary[]>('tutorials', { params: { type } })
|
||||
}
|
||||
|
||||
/** 智能分析整页的数据。username 只有老师传才生效,学生传了也只会拿到自己 */
|
||||
export function getLearningReport(period: LearningPeriod, username?: string) {
|
||||
return api.get<LearningReport>("ai/report", {
|
||||
return api.get<LearningReport>('ai/report', {
|
||||
params: { period, username: username || undefined },
|
||||
})
|
||||
}
|
||||
|
||||
/** 这个时间段上次生成的 AI 小结(老师钉住的优先),没有为 null */
|
||||
export function getLatestAnalysis(period: LearningPeriod, username?: string) {
|
||||
return api.get<AiAnalysisRecord | null>("ai/analysis/latest", {
|
||||
return api.get<AiAnalysisRecord | null>('ai/analysis/latest', {
|
||||
params: { period, username: username || undefined },
|
||||
})
|
||||
}
|
||||
@@ -427,17 +406,15 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
|
||||
// ==================== 相似题目推荐 ====================
|
||||
|
||||
export function getSimilarProblems(problemId: string) {
|
||||
return api
|
||||
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
|
||||
.then((response) => response.map(toProblemRow))
|
||||
return api.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`).then((response) => response.map(toProblemRow))
|
||||
}
|
||||
|
||||
// ==================== 挑战 ====================
|
||||
|
||||
/** 几道题、某种语言下的挑战(说法、几人做到、我做到没有),见契约 problemChallengeGroupSchema */
|
||||
export function getChallenges(problemIds: string[], language: string) {
|
||||
return api.get<ChallengeList>("challenges", {
|
||||
params: { problems: problemIds.join(","), language },
|
||||
return api.get<ChallengeList>('challenges', {
|
||||
params: { problems: problemIds.join(','), language },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -448,7 +425,7 @@ export function submitFlowchart(data: {
|
||||
mermaidCode: string
|
||||
flowchartData: Record<string, unknown> // 压缩之后的,元数据太长了
|
||||
}) {
|
||||
return api.post<CreateFlowchartResponse>("flowcharts", data satisfies CreateFlowchartRequest)
|
||||
return api.post<CreateFlowchartResponse>('flowcharts', data satisfies CreateFlowchartRequest)
|
||||
}
|
||||
|
||||
export function getFlowchartSubmission(id: string) {
|
||||
@@ -458,7 +435,7 @@ export function getFlowchartSubmission(id: string) {
|
||||
export function getFlowchartSubmissions(params: {
|
||||
username?: string
|
||||
/** "1" = username 整名匹配,同代码提交列表 */
|
||||
exactUsername?: "1"
|
||||
exactUsername?: '1'
|
||||
className?: string
|
||||
problemDisplayId?: string
|
||||
myself?: string
|
||||
@@ -467,16 +444,11 @@ export function getFlowchartSubmissions(params: {
|
||||
today?: string
|
||||
grade?: string
|
||||
}) {
|
||||
return api.get<FlowchartList>("flowcharts", { params })
|
||||
return api.get<FlowchartList>('flowcharts', { params })
|
||||
}
|
||||
|
||||
export function getFlowchartStatistics(
|
||||
duration: { start?: string; end: string },
|
||||
problemDisplayId?: string,
|
||||
username?: string,
|
||||
className?: string,
|
||||
) {
|
||||
return api.get<FlowchartStatistics>("flowcharts/statistics", {
|
||||
export function getFlowchartStatistics(duration: { start?: string; end: string }, problemDisplayId?: string, username?: string, className?: string) {
|
||||
return api.get<FlowchartStatistics>('flowcharts/statistics', {
|
||||
params: { ...duration, problemDisplayId, username, className },
|
||||
})
|
||||
}
|
||||
@@ -492,8 +464,8 @@ export function getFlowchartScores(problemId: number) {
|
||||
|
||||
// ==================== 题单相关API ====================
|
||||
|
||||
export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
|
||||
return api.get<ProblemSetList>("problem-sets", { params: { offset, limit, keyword } })
|
||||
export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
|
||||
return api.get<ProblemSetList>('problem-sets', { params: { offset, limit, keyword } })
|
||||
}
|
||||
|
||||
export function getProblemSetDetail(id: number) {
|
||||
@@ -505,15 +477,15 @@ export function getProblemSetProblems(problemSetId: number) {
|
||||
}
|
||||
|
||||
export function joinProblemSet(problemSetId: number) {
|
||||
return api.post("problem-set-progress", { problemSetId } satisfies JoinProblemSetRequest)
|
||||
return api.post('problem-set-progress', { problemSetId } satisfies JoinProblemSetRequest)
|
||||
}
|
||||
|
||||
export function getUserBadges(username?: string) {
|
||||
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
|
||||
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? 'me')}/badges`)
|
||||
}
|
||||
|
||||
/** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */
|
||||
export function getProblemSetClassView(problemSetId: number, className = "") {
|
||||
export function getProblemSetClassView(problemSetId: number, className = '') {
|
||||
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
|
||||
params: className ? { className } : {},
|
||||
})
|
||||
@@ -521,8 +493,8 @@ export function getProblemSetClassView(problemSetId: number, className = "") {
|
||||
|
||||
/** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */
|
||||
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
|
||||
return api.get<ProblemSetLock[]>("problem-sets/locks", {
|
||||
params: "id" in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
|
||||
return api.get<ProblemSetLock[]>('problem-sets/locks', {
|
||||
params: 'id' in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -537,14 +509,11 @@ export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
* 截到 200 字符再发:后端契约卡的就是 200,填空题填了一整段的话,
|
||||
* 不截就是一个 400,而学生这边什么都看不见 —— 留痕失败得静悄悄的。
|
||||
*/
|
||||
export function reportExerciseAttempt(
|
||||
exerciseId: number,
|
||||
payload: { correct: boolean; answer?: string },
|
||||
) {
|
||||
export function reportExerciseAttempt(exerciseId: number, payload: { correct: boolean; answer?: string }) {
|
||||
return authFetch(`/api/exercises/${exerciseId}/attempts`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
correct: payload.correct,
|
||||
answer: payload.answer?.slice(0, 200),
|
||||
@@ -552,8 +521,8 @@ export function reportExerciseAttempt(
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function getLearnProgress(type: "python" | "c") {
|
||||
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
|
||||
export function getLearnProgress(type: 'python' | 'c') {
|
||||
return api.get<TutorialProgress[]>('learn/progress', { params: { type } })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,14 +532,11 @@ export function getLearnProgress(type: "python" | "c") {
|
||||
* 不带它会随页面卸载被浏览器掐掉,学生每节课的最后一段时长就永远丢了。
|
||||
* 失败一律吞掉 —— 留痕是旁路,不该让学生看到任何报错。
|
||||
*/
|
||||
export function reportLearnProgress(
|
||||
tutorialId: number,
|
||||
payload: { seconds: number; opened: boolean },
|
||||
) {
|
||||
export function reportLearnProgress(tutorialId: number, payload: { seconds: number; opened: boolean }) {
|
||||
return authFetch(`/api/tutorials/${tutorialId}/progress`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassPk, ClassPkCell } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { needToTie, pkColor, plateau, shortLabels, tint } from "../utils"
|
||||
import PkHist from "./PkHist.vue"
|
||||
import type { ClassPk, ClassPkCell } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import { needToTie, pkColor, plateau, shortLabels, tint } from '../utils'
|
||||
import PkHist from './PkHist.vue'
|
||||
|
||||
/**
|
||||
* 两个班对决(设计稿「班级 PK」B / 学生 · 两个班 / 手机 · 两个班):只比两个班都布置过的题,
|
||||
@@ -19,30 +19,23 @@ const left = computed(() => props.pk.classes[0]!)
|
||||
const right = computed(() => props.pk.classes[1]!)
|
||||
const colors = [pkColor(0), pkColor(1)] as const
|
||||
const shorts = computed(() => shortLabels(props.pk))
|
||||
const mineIndex = computed(() =>
|
||||
props.pk.classes.findIndex((item) => item.className === props.pk.mine),
|
||||
)
|
||||
const mineIndex = computed(() => props.pk.classes.findIndex((item) => item.className === props.pk.mine))
|
||||
|
||||
function nameOf(index: number) {
|
||||
return index === mineIndex.value ? "你们班" : shorts.value[index]!
|
||||
return index === mineIndex.value ? '你们班' : shorts.value[index]!
|
||||
}
|
||||
|
||||
type Side = "left" | "right" | "tie"
|
||||
type Side = 'left' | 'right' | 'tie'
|
||||
|
||||
const rows = computed(() =>
|
||||
props.pk.problems
|
||||
.map((problem) => {
|
||||
const [a, b] = problem.cells as [ClassPkCell, ClassPkCell]
|
||||
const winner: Side = a.best && b.best ? "tie" : a.best ? "left" : "right"
|
||||
const first: Side =
|
||||
a.firstPercent === b.firstPercent
|
||||
? "tie"
|
||||
: a.firstPercent > b.firstPercent
|
||||
? "left"
|
||||
: "right"
|
||||
const winner: Side = a.best && b.best ? 'tie' : a.best ? 'left' : 'right'
|
||||
const first: Side = a.firstPercent === b.firstPercent ? 'tie' : a.firstPercent > b.firstPercent ? 'left' : 'right'
|
||||
let tie: number | null = null
|
||||
const mine = mineIndex.value
|
||||
if (mine >= 0 && winner !== "tie" && winner !== (mine === 0 ? "left" : "right")) {
|
||||
if (mine >= 0 && winner !== 'tie' && winner !== (mine === 0 ? 'left' : 'right')) {
|
||||
const own = mine === 0 ? a : b
|
||||
const other = mine === 0 ? b : a
|
||||
tie = needToTie(own, props.pk.classes[mine]!.members, other.percent)
|
||||
@@ -53,47 +46,41 @@ const rows = computed(() =>
|
||||
)
|
||||
|
||||
const score = computed(() => {
|
||||
const count = (key: "winner" | "first", side: Side) =>
|
||||
rows.value.filter((row) => row[key] === side).length
|
||||
const count = (key: 'winner' | 'first', side: Side) => rows.value.filter((row) => row[key] === side).length
|
||||
return {
|
||||
left: count("winner", "left"),
|
||||
right: count("winner", "right"),
|
||||
tie: count("winner", "tie"),
|
||||
firstLeft: count("first", "left"),
|
||||
firstRight: count("first", "right"),
|
||||
left: count('winner', 'left'),
|
||||
right: count('winner', 'right'),
|
||||
tie: count('winner', 'tie'),
|
||||
firstLeft: count('first', 'left'),
|
||||
firstRight: count('first', 'right'),
|
||||
}
|
||||
})
|
||||
|
||||
const steadier = computed(() => {
|
||||
const { firstLeft, firstRight } = score.value
|
||||
if (firstLeft === firstRight) return "两个班一样稳"
|
||||
if (firstLeft === firstRight) return '两个班一样稳'
|
||||
return `${nameOf(firstLeft > firstRight ? 0 : 1)}更稳`
|
||||
})
|
||||
|
||||
const SHOW_MOBILE = 7
|
||||
const expanded = ref(false)
|
||||
const shownRows = computed(() =>
|
||||
props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value,
|
||||
)
|
||||
const shownRows = computed(() => (props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value))
|
||||
|
||||
/** 每周人均:最近 8 周,两班并排的柱 */
|
||||
const weeks = computed(() => {
|
||||
const from = Math.max(0, props.pk.weeks.length - 8)
|
||||
const list = props.pk.weeks.slice(from).map((week, k) => ({
|
||||
// 手机上八根柱挤在一行,「10月12日」只能用 10px 才放得下,太小;改短写法换大一号字
|
||||
label: parseTime(week, props.compact ? "M/D" : "M月D日"),
|
||||
label: parseTime(week, props.compact ? 'M/D' : 'M月D日'),
|
||||
values: [left.value.weekly[from + k] ?? 0, right.value.weekly[from + k] ?? 0],
|
||||
}))
|
||||
const max = Math.max(1, ...list.flatMap((week) => week.values))
|
||||
return { list, max }
|
||||
})
|
||||
|
||||
const plateaus = computed(() => [
|
||||
plateau(left.value.distribution),
|
||||
plateau(right.value.distribution),
|
||||
])
|
||||
const plateaus = computed(() => [plateau(left.value.distribution), plateau(right.value.distribution)])
|
||||
|
||||
const tab = ref<"weekly" | "dist">("weekly")
|
||||
const tab = ref<'weekly' | 'dist'>('weekly')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,12 +91,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
background: `linear-gradient(90deg, ${tint(colors[0], 0.12)} 0%, transparent 38%, transparent 62%, ${tint(colors[1], 0.12)} 100%)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in [left, right]"
|
||||
:key="item.className"
|
||||
class="side"
|
||||
:class="{ end: index === 1 }"
|
||||
>
|
||||
<div v-for="(item, index) in [left, right]" :key="item.className" class="side" :class="{ end: index === 1 }">
|
||||
<span class="side-name" :style="{ color: colors[index] }">
|
||||
<template v-if="index === mineIndex">
|
||||
你们班<small>{{ compact ? shorts[index] : classLabel(item.className) }}</small>
|
||||
@@ -118,9 +100,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
</span>
|
||||
<span class="side-sub">
|
||||
{{ item.members }} 人 · 人均做对 <b>{{ item.perCapita }}</b>
|
||||
<template v-if="!compact && item.firstPercent !== null">
|
||||
· 一次就对 {{ item.firstPercent }}%</template
|
||||
>
|
||||
<template v-if="!compact && item.firstPercent !== null"> · 一次就对 {{ item.firstPercent }}%</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mid">
|
||||
@@ -131,8 +111,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
<b :style="{ color: colors[1] }">{{ score.right }}</b>
|
||||
</span>
|
||||
<span class="mid-sub">
|
||||
平 {{ score.tie }} 道 · 比一次就对:
|
||||
<b :style="{ color: colors[0] }">{{ score.firstLeft }}</b> :
|
||||
平 {{ score.tie }} 道 · 比一次就对: <b :style="{ color: colors[0] }">{{ score.firstLeft }}</b> :
|
||||
<b :style="{ color: colors[1] }">{{ score.firstRight }}</b
|
||||
>,{{ steadier }}
|
||||
</span>
|
||||
@@ -148,19 +127,12 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
<div v-for="row in shownRows" :key="row.problem.problemId" class="row">
|
||||
<span class="info left">
|
||||
<span class="value" :class="{ win: row.winner === 'left' }">
|
||||
<svg
|
||||
v-if="row.winner === 'left'"
|
||||
class="crown"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="这道赢了"
|
||||
>
|
||||
<svg v-if="row.winner === 'left'" class="crown" viewBox="0 0 24 24" aria-label="这道赢了">
|
||||
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
|
||||
</svg>
|
||||
{{ row.a.percent }}%
|
||||
</span>
|
||||
<span class="sub">
|
||||
{{ row.a.solved }}/{{ left.members }} 人 · 一次就对 {{ row.a.firstPercent }}%
|
||||
</span>
|
||||
<span class="sub"> {{ row.a.solved }}/{{ left.members }} 人 · 一次就对 {{ row.a.firstPercent }}% </span>
|
||||
</span>
|
||||
<span class="bar-box left">
|
||||
<span
|
||||
@@ -173,16 +145,12 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
/>
|
||||
</span>
|
||||
<span class="title">
|
||||
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
|
||||
row.problem.title
|
||||
}}</router-link>
|
||||
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{ row.problem.title }}</router-link>
|
||||
<span class="tags">
|
||||
<span class="muted">#{{ row.problem.displayId }}</span>
|
||||
<span v-if="row.winner === 'tie'" class="muted">平</span>
|
||||
<span v-else-if="row === rows[0] && row.gap > 0" class="most">差得最多</span>
|
||||
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }">
|
||||
再 {{ row.tie }} 人做对就打平
|
||||
</span>
|
||||
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }"> 再 {{ row.tie }} 人做对就打平 </span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="bar-box">
|
||||
@@ -198,18 +166,11 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
<span class="info">
|
||||
<span class="value" :class="{ win: row.winner === 'right' }">
|
||||
{{ row.b.percent }}%
|
||||
<svg
|
||||
v-if="row.winner === 'right'"
|
||||
class="crown"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="这道赢了"
|
||||
>
|
||||
<svg v-if="row.winner === 'right'" class="crown" viewBox="0 0 24 24" aria-label="这道赢了">
|
||||
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sub">
|
||||
{{ row.b.solved }}/{{ right.members }} 人 · 一次就对 {{ row.b.firstPercent }}%
|
||||
</span>
|
||||
<span class="sub"> {{ row.b.solved }}/{{ right.members }} 人 · 一次就对 {{ row.b.firstPercent }}% </span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -217,12 +178,8 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
<template v-else>
|
||||
<div v-for="row in shownRows" :key="row.problem.problemId" class="m-row">
|
||||
<div class="m-title">
|
||||
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
|
||||
row.problem.title
|
||||
}}</router-link>
|
||||
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }"
|
||||
>再 {{ row.tie }} 人就平</span
|
||||
>
|
||||
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{ row.problem.title }}</router-link>
|
||||
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }">再 {{ row.tie }} 人就平</span>
|
||||
<span v-else-if="row.winner === 'tie'" class="muted">平</span>
|
||||
</div>
|
||||
<div v-for="(cell, index) in [row.a, row.b]" :key="index" class="m-line">
|
||||
@@ -233,38 +190,26 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
:style="{
|
||||
width: `${cell.percent}%`,
|
||||
background: colors[index],
|
||||
opacity:
|
||||
row.winner !== 'tie' && row.winner !== (index === 0 ? 'left' : 'right')
|
||||
? 0.45
|
||||
: 1,
|
||||
opacity: row.winner !== 'tie' && row.winner !== (index === 0 ? 'left' : 'right') ? 0.45 : 1,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span class="m-value" :class="{ win: cell.best && row.winner !== 'tie' }"
|
||||
>{{ cell.percent }}%</span
|
||||
>
|
||||
<svg
|
||||
v-if="cell.best && row.winner !== 'tie'"
|
||||
class="crown"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="这道赢了"
|
||||
>
|
||||
<span class="m-value" :class="{ win: cell.best && row.winner !== 'tie' }">{{ cell.percent }}%</span>
|
||||
<svg v-if="cell.best && row.winner !== 'tie'" class="crown" viewBox="0 0 24 24" aria-label="这道赢了">
|
||||
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
|
||||
</svg>
|
||||
<span v-else class="crown" />
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="rows.length > SHOW_MOBILE" class="more" @click="expanded = !expanded">
|
||||
{{ expanded ? "收起" : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
|
||||
{{ expanded ? '收起' : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="bottom" :class="{ compact }">
|
||||
<div v-if="compact" class="tabs" role="tablist">
|
||||
<button :class="{ on: tab === 'weekly' }" role="tab" @click="tab = 'weekly'">
|
||||
每周人均
|
||||
</button>
|
||||
<button :class="{ on: tab === 'weekly' }" role="tab" @click="tab = 'weekly'">每周人均</button>
|
||||
<button :class="{ on: tab === 'dist' }" role="tab" @click="tab = 'dist'">全班分布</button>
|
||||
</div>
|
||||
<div v-if="!compact || tab === 'weekly'" class="card pad">
|
||||
@@ -295,11 +240,8 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
</div>
|
||||
<div v-for="(item, index) in [left, right]" :key="item.className" class="dist-one">
|
||||
<span class="dist-name" :style="{ color: colors[index] }">
|
||||
{{ index === mineIndex ? "你们班" : classLabel(item.className) }} · 中间那位
|
||||
{{ item.median }} 道
|
||||
<template v-if="plateaus[index]">
|
||||
· {{ plateaus[index]!.count }} 个人停在 {{ plateaus[index]!.solved }} 道</template
|
||||
>
|
||||
{{ index === mineIndex ? '你们班' : classLabel(item.className) }} · 中间那位 {{ item.median }} 道
|
||||
<template v-if="plateaus[index]"> · {{ plateaus[index]!.count }} 个人停在 {{ plateaus[index]!.solved }} 道</template>
|
||||
</span>
|
||||
<PkHist :values="item.distribution" :median="item.median" :color="colors[index]" />
|
||||
</div>
|
||||
@@ -317,7 +259,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
|
||||
.score {
|
||||
padding: 24px 32px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
@@ -350,12 +292,12 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
.side-name small {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.side-sub {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.side-sub b {
|
||||
@@ -390,24 +332,24 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
font-style: normal;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.mid-sub {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: var(--oj-fs-meta);
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
padding: 16px var(--oj-pad-x) 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -422,7 +364,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -438,7 +380,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
grid-template-columns: 168px minmax(0, 1fr) 210px minmax(0, 1fr) 168px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
@@ -461,16 +403,16 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
font-size: var(--oj-fs-body);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.value.win {
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -487,7 +429,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
.bar-box {
|
||||
height: 16px;
|
||||
display: flex;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -513,7 +455,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
max-width: 100%;
|
||||
font-size: var(--oj-fs-sec);
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -549,7 +491,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.tie.close {
|
||||
@@ -571,7 +513,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
.weeks {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.week {
|
||||
@@ -610,7 +552,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
|
||||
.axis {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -673,7 +615,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
|
||||
.m-row {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -730,14 +672,14 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom.compact {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
@@ -750,7 +692,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
@@ -760,7 +702,7 @@ const tab = ref<"weekly" | "dist">("weekly")
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassPk } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { useRankPalette } from "oj/rank/palette"
|
||||
import { needToTie, pkColor, shortLabels, tint } from "../utils"
|
||||
import PkRace from "./PkRace.vue"
|
||||
import type { ClassPk } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { useRankPalette } from 'oj/rank/palette'
|
||||
import { needToTie, pkColor, shortLabels, tint } from '../utils'
|
||||
import PkRace from './PkRace.vue'
|
||||
|
||||
/**
|
||||
* 三个班以上(设计稿「班级 PK」C / 学生 · 三个班 / 手机 · 三个班以上):题目对照格,一行
|
||||
@@ -17,17 +17,14 @@ const theme = useThemeVars()
|
||||
const palette = useRankPalette()
|
||||
|
||||
const shorts = computed(() => shortLabels(props.pk))
|
||||
const mineIndex = computed(() =>
|
||||
props.pk.classes.findIndex((item) => item.className === props.pk.mine),
|
||||
)
|
||||
const mineIndex = computed(() => props.pk.classes.findIndex((item) => item.className === props.pk.mine))
|
||||
|
||||
const rows = computed(() =>
|
||||
props.pk.problems.map((problem) => {
|
||||
const top = Math.max(...problem.cells.map((cell) => cell?.percent ?? -1))
|
||||
const winner = problem.cells.findIndex((cell) => cell?.best)
|
||||
const own = mineIndex.value >= 0 ? problem.cells[mineIndex.value] : null
|
||||
const tie =
|
||||
own && !own.best ? needToTie(own, props.pk.classes[mineIndex.value]!.members, top) : null
|
||||
const tie = own && !own.best ? needToTie(own, props.pk.classes[mineIndex.value]!.members, top) : null
|
||||
return {
|
||||
problem,
|
||||
winner,
|
||||
@@ -48,16 +45,14 @@ const rows = computed(() =>
|
||||
)
|
||||
|
||||
const ranking = computed(() =>
|
||||
props.pk.classes
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => b.item.lead - a.item.lead || b.item.perCapita - a.item.perCapita),
|
||||
props.pk.classes.map((item, index) => ({ item, index })).sort((a, b) => b.item.lead - a.item.lead || b.item.perCapita - a.item.perCapita),
|
||||
)
|
||||
|
||||
/** 右边那一句:学生看离领先最近的那道,老师看差得最多的那道 */
|
||||
const insight = computed(() => {
|
||||
if (mineIndex.value >= 0) {
|
||||
const close = rows.value.filter((row) => row.tie !== null).sort((a, b) => a.tie! - b.tie!)[0]
|
||||
if (!close) return { mine: true, text: "同一批题你们班都做得最好" }
|
||||
if (!close) return { mine: true, text: '同一批题你们班都做得最好' }
|
||||
return {
|
||||
mine: true,
|
||||
text: `离领先最近:「${close.problem.title}」再 ${close.tie} 人做对就和${shorts.value[close.winner]}打平`,
|
||||
@@ -87,11 +82,9 @@ const insight = computed(() => {
|
||||
|
||||
const SHOW_MOBILE = 8
|
||||
const expanded = ref(false)
|
||||
const shownRows = computed(() =>
|
||||
props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value,
|
||||
)
|
||||
const shownRows = computed(() => (props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value))
|
||||
|
||||
const tab = ref<"race" | "lead">("race")
|
||||
const tab = ref<'race' | 'lead'>('race')
|
||||
const medal = (index: number) => palette.value.medal[index]
|
||||
</script>
|
||||
|
||||
@@ -112,36 +105,25 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
>
|
||||
<span class="head-name" :style="{ color: pkColor(index) }">
|
||||
{{ compact ? shorts[index] : classLabel(item.className) }}
|
||||
<span v-if="index === mineIndex" class="mine-tag">{{
|
||||
compact ? "你们" : "你们班"
|
||||
}}</span>
|
||||
<span v-if="index === mineIndex" class="mine-tag">{{ compact ? '你们' : '你们班' }}</span>
|
||||
</span>
|
||||
<span class="head-lead">
|
||||
<span v-if="!compact" class="muted">领先</span>
|
||||
<b :style="{ color: pkColor(index) }">{{ item.lead }}</b>
|
||||
<span class="muted">{{ compact ? "道领先" : "道" }}</span>
|
||||
<span class="muted">{{ compact ? '道领先' : '道' }}</span>
|
||||
</span>
|
||||
<span v-if="!compact" class="muted per">人均做对 {{ item.perCapita }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in shownRows"
|
||||
:key="row.problem.problemId"
|
||||
class="line"
|
||||
:class="{ stacked: compact }"
|
||||
>
|
||||
<div v-for="row in shownRows" :key="row.problem.problemId" class="line" :class="{ stacked: compact }">
|
||||
<span class="name">
|
||||
<span v-if="!compact" class="muted id">#{{ row.problem.displayId }}</span>
|
||||
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
|
||||
row.problem.title
|
||||
}}</router-link>
|
||||
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }"
|
||||
>再 {{ row.tie }} 人就平</span
|
||||
>
|
||||
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{ row.problem.title }}</router-link>
|
||||
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }">再 {{ row.tie }} 人就平</span>
|
||||
</span>
|
||||
<div class="cells">
|
||||
<template v-for="(cell, index) in row.cells" :key="index">
|
||||
<span v-if="!cell" class="cell none">{{ compact ? "—" : "没布置" }}</span>
|
||||
<span v-if="!cell" class="cell none">{{ compact ? '—' : '没布置' }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="cell"
|
||||
@@ -152,49 +134,26 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
<svg v-if="cell.best" class="crown" viewBox="0 0 24 24" aria-label="这道做得最好">
|
||||
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
|
||||
</svg>
|
||||
<b>{{ cell.percent }}{{ compact ? "" : "%" }}</b>
|
||||
<span v-if="!compact && pk.classes.length < 5" class="first"
|
||||
>一次 {{ cell.firstPercent }}%</span
|
||||
>
|
||||
<b>{{ cell.percent }}{{ compact ? '' : '%' }}</b>
|
||||
<span v-if="!compact && pk.classes.length < 5" class="first">一次 {{ cell.firstPercent }}%</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="compact && rows.length > SHOW_MOBILE"
|
||||
class="more"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
{{ expanded ? "收起" : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
|
||||
<button v-if="compact && rows.length > SHOW_MOBILE" class="more" @click="expanded = !expanded">
|
||||
{{ expanded ? '收起' : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="side" :class="{ compact }">
|
||||
<div v-if="compact" class="tabs" role="tablist">
|
||||
<button :class="{ on: tab === 'race' }" role="tab" @click="tab = 'race'">赛跑</button>
|
||||
<button :class="{ on: tab === 'lead' }" role="tab" @click="tab = 'lead'">
|
||||
谁领先的题多
|
||||
</button>
|
||||
<button :class="{ on: tab === 'lead' }" role="tab" @click="tab = 'lead'">谁领先的题多</button>
|
||||
</div>
|
||||
<div v-if="!compact || tab === 'lead'" class="card pad">
|
||||
<div v-if="!compact" class="card-title">
|
||||
<b>谁领先的题多</b><span class="muted">同一批题里</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="({ item, index }, place) in ranking"
|
||||
:key="item.className"
|
||||
class="lead-row"
|
||||
:class="{ mine: index === mineIndex }"
|
||||
>
|
||||
<span
|
||||
class="medal"
|
||||
:style="
|
||||
medal(place)
|
||||
? { color: medal(place)!.color, background: medal(place)!.background }
|
||||
: undefined
|
||||
"
|
||||
>{{ place + 1 }}</span
|
||||
>
|
||||
<div v-if="!compact" class="card-title"><b>谁领先的题多</b><span class="muted">同一批题里</span></div>
|
||||
<div v-for="({ item, index }, place) in ranking" :key="item.className" class="lead-row" :class="{ mine: index === mineIndex }">
|
||||
<span class="medal" :style="medal(place) ? { color: medal(place)!.color, background: medal(place)!.background } : undefined">{{ place + 1 }}</span>
|
||||
<span class="lead-name" :style="{ color: pkColor(index) }">
|
||||
{{ classLabel(item.className) }}
|
||||
<span v-if="index === mineIndex" class="mine-tag">你们班</span>
|
||||
@@ -207,9 +166,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
<div v-if="insight" class="insight" :class="{ mine: insight.mine }">{{ insight.text }}</div>
|
||||
</div>
|
||||
<div v-if="!compact || tab === 'race'" class="card pad">
|
||||
<div v-if="!compact" class="card-title">
|
||||
<b>赛跑</b><span class="muted">这学期累计人均做对</span>
|
||||
</div>
|
||||
<div v-if="!compact" class="card-title"><b>赛跑</b><span class="muted">这学期累计人均做对</span></div>
|
||||
<PkRace :pk="pk" :width="420" :height="compact ? 220 : 230" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,9 +187,9 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -258,7 +215,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
.muted {
|
||||
font-size: var(--oj-fs-meta);
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.line {
|
||||
@@ -289,7 +246,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
.name a {
|
||||
font-size: var(--oj-fs-sec);
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -383,7 +340,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -412,12 +369,8 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
|
||||
.cell.none {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColorDisabled");
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
v-bind("theme.actionColor") 0 6px,
|
||||
v-bind("theme.hoverColor") 6px 12px
|
||||
);
|
||||
color: v-bind('theme.textColorDisabled');
|
||||
background: repeating-linear-gradient(135deg, v-bind('theme.actionColor') 0 6px, v-bind('theme.hoverColor') 6px 12px);
|
||||
}
|
||||
|
||||
.crown {
|
||||
@@ -440,7 +393,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.tie.close {
|
||||
@@ -482,7 +435,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
font-weight: 700;
|
||||
font-size: var(--oj-fs-sec);
|
||||
flex-shrink: 0;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.lead-name {
|
||||
@@ -501,9 +454,9 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
.insight {
|
||||
margin-top: 10px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
padding: 10px 12px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -521,7 +474,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -559,7 +512,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.line.stacked .name {
|
||||
@@ -587,7 +540,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
|
||||
.side.compact {
|
||||
gap: 0;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
@@ -600,7 +553,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
@@ -610,7 +563,7 @@ const medal = (index: number) => palette.value.medal[index]
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
|
||||
/**
|
||||
* 全班分布:横轴做对几道(0–30,再多的堆在最右一档),一根柱一档,虚线是中间那位同学。
|
||||
* 人少的档(不到 3 个)淡一点,一眼看到大部分人停在哪。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{ values: number[]; median: number; color: string; height?: number }>(),
|
||||
{ height: 44 },
|
||||
)
|
||||
const props = withDefaults(defineProps<{ values: number[]; median: number; color: string; height?: number }>(), { height: 44 })
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
@@ -35,12 +32,7 @@ const bars = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="hist"
|
||||
:viewBox="`0 0 ${WIDTH} ${height + 12}`"
|
||||
role="img"
|
||||
aria-label="全班做对几道的分布"
|
||||
>
|
||||
<svg class="hist" :viewBox="`0 0 ${WIDTH} ${height + 12}`" role="img" aria-label="全班做对几道的分布">
|
||||
<line :x1="0" :x2="WIDTH" :y1="height + 0.5" :y2="height + 0.5" :stroke="theme.dividerColor" />
|
||||
<rect
|
||||
v-for="bar in bars.list"
|
||||
@@ -53,29 +45,12 @@ const bars = computed(() => {
|
||||
:fill="color"
|
||||
:opacity="bar.n >= 3 ? 0.9 : 0.55"
|
||||
>
|
||||
<title>做对 {{ bar.value }}{{ bar.value === CAP ? " 道以上" : " 道" }}:{{ bar.n }} 人</title>
|
||||
<title>做对 {{ bar.value }}{{ bar.value === CAP ? ' 道以上' : ' 道' }}:{{ bar.n }} 人</title>
|
||||
</rect>
|
||||
<line
|
||||
:x1="bars.median"
|
||||
:x2="bars.median"
|
||||
y1="0"
|
||||
:y2="height"
|
||||
:stroke="theme.textColor2"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<line :x1="bars.median" :x2="bars.median" y1="0" :y2="height" :stroke="theme.textColor2" stroke-dasharray="2 2" />
|
||||
<text x="0" :y="height + 11" font-size="9" :fill="theme.textColor3">0</text>
|
||||
<text
|
||||
:x="15 * bars.width"
|
||||
:y="height + 11"
|
||||
font-size="9"
|
||||
:fill="theme.textColor3"
|
||||
text-anchor="middle"
|
||||
>
|
||||
15
|
||||
</text>
|
||||
<text :x="WIDTH" :y="height + 11" font-size="9" :fill="theme.textColor3" text-anchor="end">
|
||||
30+
|
||||
</text>
|
||||
<text :x="15 * bars.width" :y="height + 11" font-size="9" :fill="theme.textColor3" text-anchor="middle">15</text>
|
||||
<text :x="WIDTH" :y="height + 11" font-size="9" :fill="theme.textColor3" text-anchor="end">30+</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassPk } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { pkColor, shortLabels } from "../utils"
|
||||
import type { ClassPk } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import { pkColor, shortLabels } from '../utils'
|
||||
|
||||
/**
|
||||
* 赛跑:每个班这学期累计人均做对,一周一个点,线尾写班名和到现在的人均。
|
||||
@@ -34,8 +34,7 @@ const chart = computed(() => {
|
||||
const step = top > 30 ? 10 : top > 12 ? 6 : top > 5 ? 2 : 1
|
||||
const max = Math.ceil(top / step) * step
|
||||
const count = props.pk.weeks.length
|
||||
const x = (index: number) =>
|
||||
PAD.left + (count > 1 ? (inner.w * index) / (count - 1) : inner.w / 2)
|
||||
const x = (index: number) => PAD.left + (count > 1 ? (inner.w * index) / (count - 1) : inner.w / 2)
|
||||
const y = (value: number) => PAD.top + inner.h * (1 - value / max)
|
||||
|
||||
const ticks = Array.from({ length: max / step + 1 }, (_, k) => ({
|
||||
@@ -45,20 +44,19 @@ const chart = computed(() => {
|
||||
// 周多了横轴字会挤:最多标 8 个,最后一周一定标
|
||||
const every = Math.max(1, Math.ceil(count / 8))
|
||||
const labels = props.pk.weeks
|
||||
.map((week, index) => ({ index, x: x(index), text: parseTime(week, "M月D日") }))
|
||||
.map((week, index) => ({ index, x: x(index), text: parseTime(week, 'M月D日') }))
|
||||
.filter(({ index }) => index === count - 1 || (count - 1 - index) % every === 0)
|
||||
|
||||
const labelsShort = shortLabels(props.pk)
|
||||
const lines = series.value.map((points, index) => ({
|
||||
color: pkColor(index),
|
||||
path: points.map((value, k) => `${x(k).toFixed(1)},${y(value).toFixed(1)}`).join(" "),
|
||||
path: points.map((value, k) => `${x(k).toFixed(1)},${y(value).toFixed(1)}`).join(' '),
|
||||
dots: points.map((value, k) => ({ cx: x(k), cy: y(value) })),
|
||||
end: { y: y(points.at(-1) ?? 0), text: `${labelsShort[index]} ${points.at(-1) ?? 0}` },
|
||||
}))
|
||||
// 线尾的字别叠在一起:按高低排,挨得太近的往下推
|
||||
const ends = lines.map((line, index) => ({ index, y: line.end.y })).sort((a, b) => a.y - b.y)
|
||||
for (let k = 1; k < ends.length; k++)
|
||||
if (ends[k]!.y - ends[k - 1]!.y < 14) ends[k]!.y = ends[k - 1]!.y + 14
|
||||
for (let k = 1; k < ends.length; k++) if (ends[k]!.y - ends[k - 1]!.y < 14) ends[k]!.y = ends[k - 1]!.y + 14
|
||||
for (const end of ends) lines[end.index]!.end.y = end.y
|
||||
|
||||
return { ticks, labels, lines, endX: x(count - 1) + 10, right: PAD.left + inner.w }
|
||||
@@ -66,60 +64,19 @@ const chart = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="race"
|
||||
:viewBox="`0 0 ${width} ${height}`"
|
||||
role="img"
|
||||
aria-label="各班这学期累计人均做对,一周一个点"
|
||||
>
|
||||
<svg class="race" :viewBox="`0 0 ${width} ${height}`" role="img" aria-label="各班这学期累计人均做对,一周一个点">
|
||||
<g v-for="tick in chart.ticks" :key="tick.value">
|
||||
<line
|
||||
:x1="PAD.left"
|
||||
:x2="chart.right"
|
||||
:y1="tick.y"
|
||||
:y2="tick.y"
|
||||
:stroke="theme.dividerColor"
|
||||
/>
|
||||
<text
|
||||
:x="PAD.left - 8"
|
||||
:y="tick.y + 4"
|
||||
font-size="11"
|
||||
:fill="theme.textColor3"
|
||||
text-anchor="end"
|
||||
>
|
||||
<line :x1="PAD.left" :x2="chart.right" :y1="tick.y" :y2="tick.y" :stroke="theme.dividerColor" />
|
||||
<text :x="PAD.left - 8" :y="tick.y + 4" font-size="11" :fill="theme.textColor3" text-anchor="end">
|
||||
{{ tick.value }}
|
||||
</text>
|
||||
</g>
|
||||
<text
|
||||
v-for="label in chart.labels"
|
||||
:key="label.index"
|
||||
:x="label.x"
|
||||
:y="height - 6"
|
||||
font-size="11"
|
||||
:fill="theme.textColor3"
|
||||
text-anchor="middle"
|
||||
>
|
||||
<text v-for="label in chart.labels" :key="label.index" :x="label.x" :y="height - 6" font-size="11" :fill="theme.textColor3" text-anchor="middle">
|
||||
{{ label.text }}
|
||||
</text>
|
||||
<g v-for="(line, index) in chart.lines" :key="index">
|
||||
<polyline
|
||||
:points="line.path"
|
||||
fill="none"
|
||||
:stroke="line.color"
|
||||
stroke-width="2.5"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle
|
||||
v-for="(dot, k) in line.dots"
|
||||
:key="k"
|
||||
:cx="dot.cx"
|
||||
:cy="dot.cy"
|
||||
r="3.2"
|
||||
:fill="theme.cardColor"
|
||||
:stroke="line.color"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<polyline :points="line.path" fill="none" :stroke="line.color" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
<circle v-for="(dot, k) in line.dots" :key="k" :cx="dot.cx" :cy="dot.cy" r="3.2" :fill="theme.cardColor" :stroke="line.color" stroke-width="2" />
|
||||
<text :x="chart.endX" :y="line.end.y + 4" font-size="12" font-weight="700" :fill="line.color">
|
||||
{{ line.end.text }}
|
||||
</text>
|
||||
|
||||
+53
-111
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassBattleItem, ClassPk, ClassPkPeriod } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getClassBattle, getClassPk } from "oj/api"
|
||||
import { classLabel, groupClassOptions } from "utils/classes"
|
||||
import { useAIStream } from "shared/composables/aiStream"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import PkDuel from "./components/PkDuel.vue"
|
||||
import PkGrid from "./components/PkGrid.vue"
|
||||
import PkRace from "./components/PkRace.vue"
|
||||
import { pkColor } from "./utils"
|
||||
import type { ClassBattleItem, ClassPk, ClassPkPeriod } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getClassBattle, getClassPk } from 'oj/api'
|
||||
import { classLabel, groupClassOptions } from 'utils/classes'
|
||||
import { useAIStream } from 'shared/composables/aiStream'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import PkDuel from './components/PkDuel.vue'
|
||||
import PkGrid from './components/PkGrid.vue'
|
||||
import PkRace from './components/PkRace.vue'
|
||||
import { pkColor } from './utils'
|
||||
|
||||
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
|
||||
const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue"))
|
||||
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
|
||||
|
||||
/**
|
||||
* 班级 PK(设计稿「班级 PK 重设计」定稿)。用户定的:两个班按「同一批题」对决(B),
|
||||
@@ -37,9 +37,9 @@ const battle = ref<ClassBattleItem[]>([])
|
||||
|
||||
function queryClasses() {
|
||||
const raw = route.query.classes
|
||||
return typeof raw === "string" && raw ? raw.split(",").filter(Boolean) : []
|
||||
return typeof raw === 'string' && raw ? raw.split(',').filter(Boolean) : []
|
||||
}
|
||||
const period = computed<ClassPkPeriod>(() => (route.query.period === "week" ? "week" : "term"))
|
||||
const period = computed<ClassPkPeriod>(() => (route.query.period === 'week' ? 'week' : 'term'))
|
||||
|
||||
let seq = 0
|
||||
async function load() {
|
||||
@@ -50,9 +50,8 @@ async function load() {
|
||||
if (mine !== seq) return
|
||||
pk.value = data
|
||||
// 后端配好的班写回地址栏:刷新、分享出去看到的是同一组
|
||||
const classes = data.classes.map((item) => item.className).join(",")
|
||||
if (classes && classes !== route.query.classes)
|
||||
router.replace({ query: { ...route.query, classes } })
|
||||
const classes = data.classes.map((item) => item.className).join(',')
|
||||
if (classes && classes !== route.query.classes) router.replace({ query: { ...route.query, classes } })
|
||||
} catch {
|
||||
if (mine === seq) pk.value = null
|
||||
} finally {
|
||||
@@ -73,9 +72,9 @@ onMounted(async () => {
|
||||
const selected = computed(() => pk.value?.classes.map((item) => item.className) ?? [])
|
||||
|
||||
function setClasses(classes: string[]) {
|
||||
report.value = ""
|
||||
report.value = ''
|
||||
ai.abort()
|
||||
router.push({ query: { ...route.query, classes: classes.join(",") || undefined } })
|
||||
router.push({ query: { ...route.query, classes: classes.join(',') || undefined } })
|
||||
}
|
||||
|
||||
function remove(className: string) {
|
||||
@@ -97,9 +96,9 @@ function add(className: string) {
|
||||
}
|
||||
|
||||
function setPeriod(value: ClassPkPeriod) {
|
||||
report.value = ""
|
||||
report.value = ''
|
||||
ai.abort()
|
||||
router.push({ query: { ...route.query, period: value === "term" ? undefined : value } })
|
||||
router.push({ query: { ...route.query, period: value === 'term' ? undefined : value } })
|
||||
}
|
||||
|
||||
/** 「+ 加一个班」:这学期在用的班(班级对抗里有的),按年级分组,自己年级放最前 */
|
||||
@@ -112,31 +111,23 @@ const addOptions = computed(() =>
|
||||
)
|
||||
|
||||
const mode = computed(() => {
|
||||
if (!pk.value || pk.value.classes.length < 2) return "empty"
|
||||
return pk.value.classes.length === 2 ? "duel" : "grid"
|
||||
if (!pk.value || pk.value.classes.length < 2) return 'empty'
|
||||
return pk.value.classes.length === 2 ? 'duel' : 'grid'
|
||||
})
|
||||
|
||||
const ruleText = computed(() =>
|
||||
mode.value === "grid"
|
||||
? "三个班以上按题对照 · 过半的人交过算这个班布置过"
|
||||
: "两个班只比都布置过的题 · 过半的人交过算布置过",
|
||||
)
|
||||
const ruleText = computed(() => (mode.value === 'grid' ? '三个班以上按题对照 · 过半的人交过算这个班布置过' : '两个班只比都布置过的题 · 过半的人交过算布置过'))
|
||||
|
||||
// ---------- AI 分析(只给老师) ----------
|
||||
const ai = useAIStream()
|
||||
const report = ref("")
|
||||
const report = ref('')
|
||||
const showReport = ref(false)
|
||||
|
||||
async function analyze() {
|
||||
if (selected.value.length < 2) return
|
||||
showReport.value = true
|
||||
report.value = ""
|
||||
report.value = ''
|
||||
try {
|
||||
await ai.run(
|
||||
"ai/class-pk-analysis",
|
||||
{ classNames: selected.value, period: period.value },
|
||||
{ onDelta: (content) => (report.value += content) },
|
||||
)
|
||||
await ai.run('ai/class-pk-analysis', { classNames: selected.value, period: period.value }, { onDelta: (content) => (report.value += content) })
|
||||
} catch (error) {
|
||||
if (!report.value) showReport.value = false
|
||||
message.error((error as Error).message)
|
||||
@@ -156,24 +147,9 @@ async function analyze() {
|
||||
<button :class="{ on: period === 'week' }" @click="setPeriod('week')">这周</button>
|
||||
<button :class="{ on: period === 'term' }" @click="setPeriod('term')">这学期</button>
|
||||
</div>
|
||||
<n-button
|
||||
v-if="teacher && isDesktop"
|
||||
secondary
|
||||
type="info"
|
||||
:disabled="selected.length < 2"
|
||||
:loading="ai.waiting.value"
|
||||
@click="analyze"
|
||||
>
|
||||
<n-button v-if="teacher && isDesktop" secondary type="info" :disabled="selected.length < 2" :loading="ai.waiting.value" @click="analyze">
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -187,33 +163,14 @@ async function analyze() {
|
||||
<i :style="{ background: pkColor(index) }" />
|
||||
{{ chipLabel(className) }}
|
||||
<span v-if="className === pk?.mine" class="mine-tag">你们班</span>
|
||||
<button
|
||||
v-else
|
||||
class="x"
|
||||
:aria-label="`去掉${classLabel(className)}`"
|
||||
@click="remove(className)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<button v-else class="x" :aria-label="`去掉${classLabel(className)}`" @click="remove(className)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<!-- 只剩两个班时不能删(删成一个班后端会再配一个),点它换成别的班 -->
|
||||
<n-popselect
|
||||
v-else
|
||||
:options="addOptions"
|
||||
scrollable
|
||||
trigger="click"
|
||||
:value="null"
|
||||
@update:value="(value: string) => replace(className, value)"
|
||||
>
|
||||
<n-popselect v-else :options="addOptions" scrollable trigger="click" :value="null" @update:value="(value: string) => replace(className, value)">
|
||||
<button class="chip" title="换一个班">
|
||||
<i :style="{ background: pkColor(index) }" />
|
||||
{{ chipLabel(className) }}
|
||||
@@ -232,14 +189,7 @@ async function analyze() {
|
||||
</button>
|
||||
</n-popselect>
|
||||
</template>
|
||||
<n-popselect
|
||||
v-if="selected.length < MAX_CLASSES"
|
||||
:options="addOptions"
|
||||
scrollable
|
||||
trigger="click"
|
||||
:value="null"
|
||||
@update:value="add"
|
||||
>
|
||||
<n-popselect v-if="selected.length < MAX_CLASSES" :options="addOptions" scrollable trigger="click" :value="null" @update:value="add">
|
||||
<button class="chip add">+ 加一个班</button>
|
||||
</n-popselect>
|
||||
</div>
|
||||
@@ -254,19 +204,13 @@ async function analyze() {
|
||||
<div v-if="mode === 'empty'" class="card empty">
|
||||
<b>再加一个班来比</b>
|
||||
<span class="muted">
|
||||
{{
|
||||
pk.classes.length
|
||||
? "同年级没找到别的在用的班,点上面「+ 加一个班」挑一个"
|
||||
: "你还没有班级,点上面「+ 加一个班」挑两个班来比"
|
||||
}}
|
||||
{{ pk.classes.length ? '同年级没找到别的在用的班,点上面「+ 加一个班」挑一个' : '你还没有班级,点上面「+ 加一个班」挑两个班来比' }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="!pk.problems.length" class="card empty">
|
||||
<b>这几个班{{ period === "week" ? "这周" : "这学期" }}还没有一起布置过的题</b>
|
||||
<span class="muted"
|
||||
>一个班过半的人交过这道题才算布置过,至少两个班都布置过才能按题比</span
|
||||
>
|
||||
<b>这几个班{{ period === 'week' ? '这周' : '这学期' }}还没有一起布置过的题</b>
|
||||
<span class="muted">一个班过半的人交过这道题才算布置过,至少两个班都布置过才能按题比</span>
|
||||
<PkRace v-if="period === 'term'" :pk="pk" :height="240" class="empty-race" />
|
||||
</div>
|
||||
<PkDuel v-else-if="mode === 'duel'" :pk="pk" :compact="!isDesktop" />
|
||||
@@ -296,9 +240,7 @@ async function analyze() {
|
||||
<Markdown v-if="report" :content="report" />
|
||||
</section>
|
||||
</template>
|
||||
<div v-else-if="!loading" class="card empty">
|
||||
<b>没取到数据</b><span class="muted">刷新一下试试</span>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="card empty"><b>没取到数据</b><span class="muted">刷新一下试试</span></div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
@@ -332,7 +274,7 @@ async function analyze() {
|
||||
|
||||
.back {
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -342,13 +284,13 @@ async function analyze() {
|
||||
|
||||
.muted {
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
height: var(--oj-ctrl-h);
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -356,9 +298,9 @@ async function analyze() {
|
||||
.seg button {
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-left: 1px solid v-bind("theme.borderColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor2");
|
||||
border-left: 1px solid v-bind('theme.borderColor');
|
||||
background: v-bind('theme.cardColor');
|
||||
color: v-bind('theme.textColor2');
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
cursor: pointer;
|
||||
@@ -390,10 +332,10 @@ async function analyze() {
|
||||
height: var(--oj-ctrl-h);
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px 0 12px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 17px;
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor1");
|
||||
background: v-bind('theme.cardColor');
|
||||
color: v-bind('theme.textColor1');
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
font-weight: 600;
|
||||
@@ -414,7 +356,7 @@ async function analyze() {
|
||||
padding: 0 14px;
|
||||
border-style: dashed;
|
||||
font-weight: 400;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -424,7 +366,7 @@ async function analyze() {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -443,18 +385,18 @@ button.chip {
|
||||
.chev {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.x:hover {
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.vs {
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
font-style: italic;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.mine-tag {
|
||||
@@ -470,9 +412,9 @@ button.chip {
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -511,7 +453,7 @@ button.chip {
|
||||
}
|
||||
|
||||
.ai-head b {
|
||||
color: v-bind("theme.textColor1");
|
||||
color: v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.ai-head .muted {
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import type { ClassPk, ClassPkCell } from "@oj2/contract"
|
||||
import { classLabel } from "utils/classes"
|
||||
import type { ClassPk, ClassPkCell } from '@oj2/contract'
|
||||
import { classLabel } from 'utils/classes'
|
||||
|
||||
/**
|
||||
* 每个班一种颜色,按选班的顺序给。蓝 / 橙打头:两个班对决时最常见,这两个色盲也分得开;
|
||||
* 后面几个亮度错开,对照格的深浅才看得出来。
|
||||
*/
|
||||
const COLORS = [
|
||||
"#2f6fd0",
|
||||
"#e0702f",
|
||||
"#18a058",
|
||||
"#8e5bd0",
|
||||
"#b8901a",
|
||||
"#d0457a",
|
||||
"#0f8f9f",
|
||||
"#5f6b7a",
|
||||
]
|
||||
const COLORS = ['#2f6fd0', '#e0702f', '#18a058', '#8e5bd0', '#b8901a', '#d0457a', '#0f8f9f', '#5f6b7a']
|
||||
|
||||
export function pkColor(index: number) {
|
||||
return COLORS[index % COLORS.length]!
|
||||
@@ -31,9 +22,7 @@ export function tint(hex: string, alpha: number) {
|
||||
/** 同一个年级的几个班只说「3班」,跨年级说全名 */
|
||||
export function shortLabels(pk: ClassPk) {
|
||||
const grades = new Set(pk.classes.map((item) => item.className.slice(0, 2)))
|
||||
return pk.classes.map((item) =>
|
||||
grades.size === 1 ? `${item.className.slice(2)}班` : classLabel(item.className),
|
||||
)
|
||||
return pk.classes.map((item) => (grades.size === 1 ? `${item.className.slice(2)}班` : classLabel(item.className)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,11 +31,7 @@ export function shortLabels(pk: ClassPk) {
|
||||
*/
|
||||
export function needToTie(cell: ClassPkCell, members: number, target: number) {
|
||||
let more = 0
|
||||
while (
|
||||
Math.round(((cell.solved + more) / members) * 100) < target &&
|
||||
cell.solved + more < members
|
||||
)
|
||||
more++
|
||||
while (Math.round(((cell.solved + more) / members) * 100) < target && cell.solved + more < members) more++
|
||||
return more
|
||||
}
|
||||
|
||||
|
||||
+122
-231
@@ -1,30 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getClassBoard } from "oj/api"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { groupClassOptions } from "utils/classes"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useLeaveStudents } from "shared/composables/leaveStudents"
|
||||
import { useTeacherHelp } from "shared/composables/teacherHelp"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from "utils/types"
|
||||
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
|
||||
import LeaveModal from "./components/LeaveModal.vue"
|
||||
import LessonForm from "./components/LessonForm.vue"
|
||||
import TrailPanel from "./components/TrailPanel.vue"
|
||||
import { errorKind, type ErrorKind } from "utils/errorKind"
|
||||
import {
|
||||
cellStyle as cellStyleOf,
|
||||
cellText,
|
||||
cellTitle as cellTitleOf,
|
||||
STUCK_ATTEMPTS,
|
||||
trailSummary,
|
||||
} from "./boardCells"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getClassBoard } from 'oj/api'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useLeaveStudents } from 'shared/composables/leaveStudents'
|
||||
import { useTeacherHelp } from 'shared/composables/teacherHelp'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from 'utils/types'
|
||||
import ExplainModal, { type ExplainPerson } from './components/ExplainModal.vue'
|
||||
import LeaveModal from './components/LeaveModal.vue'
|
||||
import LessonForm from './components/LessonForm.vue'
|
||||
import TrailPanel from './components/TrailPanel.vue'
|
||||
import { errorKind, type ErrorKind } from 'utils/errorKind'
|
||||
import { cellStyle as cellStyleOf, cellText, cellTitle as cellTitleOf, STUCK_ATTEMPTS, trailSummary } from './boardCells'
|
||||
|
||||
/**
|
||||
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
||||
@@ -56,7 +50,7 @@ const board = ref<ClassBoard | null>(null)
|
||||
* 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班,
|
||||
* 否则第一次由后端猜
|
||||
*/
|
||||
const queryText = (value: unknown) => (typeof value === "string" && value ? value : null)
|
||||
const queryText = (value: unknown) => (typeof value === 'string' && value ? value : null)
|
||||
const className = ref<string | null>(queryText(route.query.className))
|
||||
/** ?problem= 带来的题号:加载完选中那道题,下面展开它的错法(「讲这道题」) */
|
||||
let wantedProblem = queryText(route.query.problem)
|
||||
@@ -110,7 +104,7 @@ watch(
|
||||
const now = useNow({ scheduler: (cb) => useIntervalFn(cb, 1000) })
|
||||
function waitedText(createdAt: number) {
|
||||
const seconds = Math.max(0, Math.floor((now.value.getTime() - createdAt) / 1000))
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
const { pause } = useIntervalFn(load, REFRESH_MS)
|
||||
onUnmounted(pause)
|
||||
@@ -130,7 +124,7 @@ async function lessonSaved() {
|
||||
|
||||
// ---------- 分组与排序 ----------
|
||||
|
||||
type Group = "help" | "idle" | "stuck" | "working" | "done" | "leave"
|
||||
type Group = 'help' | 'idle' | 'stuck' | 'working' | 'done' | 'leave'
|
||||
|
||||
/**
|
||||
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
|
||||
@@ -165,18 +159,17 @@ function seenText(seenAt: string) {
|
||||
}
|
||||
|
||||
function doneCount(student: ClassBoardStudent) {
|
||||
return student.cells.filter((cell) => cell.status === "accepted").length
|
||||
return student.cells.filter((cell) => cell.status === 'accepted').length
|
||||
}
|
||||
|
||||
function groupOf(student: ClassBoardStudent): Group {
|
||||
if (absent(student)) return "leave"
|
||||
if (helpOf(student)) return "help"
|
||||
if (absent(student)) return 'leave'
|
||||
if (helpOf(student)) return 'help'
|
||||
const cells = student.cells
|
||||
if (cells.length && cells.every((cell) => cell.status === "accepted")) return "done"
|
||||
if (!student.lastSubmitAt) return "idle"
|
||||
if (cells.some((cell) => cell.status === "tried" && cell.attempts >= STUCK_ATTEMPTS))
|
||||
return "stuck"
|
||||
return "working"
|
||||
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
|
||||
if (!student.lastSubmitAt) return 'idle'
|
||||
if (cells.some((cell) => cell.status === 'tried' && cell.attempts >= STUCK_ATTEMPTS)) return 'stuck'
|
||||
return 'working'
|
||||
}
|
||||
|
||||
const GROUP_ORDER: Record<Group, number> = {
|
||||
@@ -189,12 +182,12 @@ const GROUP_ORDER: Record<Group, number> = {
|
||||
}
|
||||
|
||||
const GROUP_LABEL: Record<Group, string> = {
|
||||
help: "举手了",
|
||||
idle: "还没交过",
|
||||
stuck: "卡住了",
|
||||
working: "在做",
|
||||
done: "全做完了",
|
||||
leave: "请假",
|
||||
help: '举手了',
|
||||
idle: '还没交过',
|
||||
stuck: '卡住了',
|
||||
working: '在做',
|
||||
done: '全做完了',
|
||||
leave: '请假',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,7 +198,7 @@ const GROUP_LABEL: Record<Group, string> = {
|
||||
* **只有今天还没交过的人能算请假**:请假的人一定在这里面。标过的人后来交了题、或者举手了,
|
||||
* 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手
|
||||
*/
|
||||
const { onLeave, setLeave, cancelLeave } = useLeaveStudents("oj_leave_students")
|
||||
const { onLeave, setLeave, cancelLeave } = useLeaveStudents('oj_leave_students')
|
||||
function canLeave(student: ClassBoardStudent) {
|
||||
return !student.lastSubmitAt && !helpOf(student)
|
||||
}
|
||||
@@ -217,9 +210,7 @@ const leaveStudents = computed(() => boardStudents.value.filter(absent).sort(byN
|
||||
|
||||
// 标过请假、后来交了题或举手的:来了,把标记清掉(不然名单里一直挂着一个其实在的人)
|
||||
watchEffect(() => {
|
||||
const arrived = boardStudents.value
|
||||
.filter((student) => !canLeave(student) && onLeave(student.username))
|
||||
.map((student) => student.username)
|
||||
const arrived = boardStudents.value.filter((student) => !canLeave(student) && onLeave(student.username)).map((student) => student.username)
|
||||
if (arrived.length) cancelLeave(arrived)
|
||||
})
|
||||
|
||||
@@ -234,7 +225,7 @@ function saveLeave(selected: string[]) {
|
||||
}
|
||||
|
||||
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
|
||||
return a.username.localeCompare(b.username, "zh-CN")
|
||||
return a.username.localeCompare(b.username, 'zh-CN')
|
||||
}
|
||||
|
||||
const students = computed(() =>
|
||||
@@ -245,12 +236,12 @@ const students = computed(() =>
|
||||
Number(unseen(b)) - Number(unseen(a)) ||
|
||||
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
|
||||
// 同样在做,做对得少的排前面(只做了一道的那几个)
|
||||
(groupOf(a) === "working" ? doneCount(a) - doneCount(b) : 0) ||
|
||||
(groupOf(a) === 'working' ? doneCount(a) - doneCount(b) : 0) ||
|
||||
byName(a, b),
|
||||
),
|
||||
)
|
||||
/** 到了的人:人数、卡片都按这些算 */
|
||||
const present = computed(() => students.value.filter((student) => groupOf(student) !== "leave"))
|
||||
const present = computed(() => students.value.filter((student) => groupOf(student) !== 'leave'))
|
||||
const groupCount = computed(() => {
|
||||
const count: Record<Group, number> = { help: 0, idle: 0, stuck: 0, working: 0, done: 0, leave: 0 }
|
||||
for (const student of students.value) count[groupOf(student)]++
|
||||
@@ -261,11 +252,7 @@ const groupCount = computed(() => {
|
||||
* 做完了又没在挑战的,就是空着的
|
||||
*/
|
||||
const challengingCount = computed(
|
||||
() =>
|
||||
students.value.filter(
|
||||
(student) =>
|
||||
groupOf(student) === "done" && student.cells.some((cell) => cell.challenge === "doing"),
|
||||
).length,
|
||||
() => students.value.filter((student) => groupOf(student) === 'done' && student.cells.some((cell) => cell.challenge === 'doing')).length,
|
||||
)
|
||||
/** 每组第一个人,在他上面插一行组名 */
|
||||
const groupStarts = computed(() => {
|
||||
@@ -294,33 +281,29 @@ interface WrongPerson {
|
||||
function wrongOf(index: number): WrongPerson[] {
|
||||
return present.value.flatMap((student) => {
|
||||
const cell = student.cells[index]
|
||||
if (!cell || cell.status !== "tried") return []
|
||||
if (!cell || cell.status !== 'tried') return []
|
||||
const kind: ErrorKind = cell.latest
|
||||
? errorKind(cell.latest)
|
||||
: cell.attempts
|
||||
? { key: "flowchart", label: "今天只画了流程图", line: null }
|
||||
: { key: "before", label: "以前交过,今天还没交", line: null }
|
||||
? { key: 'flowchart', label: '今天只画了流程图', line: null }
|
||||
: { key: 'before', label: '以前交过,今天还没交', line: null }
|
||||
return [{ student, cell, kind }]
|
||||
})
|
||||
}
|
||||
|
||||
const problemStats = computed(() =>
|
||||
(board.value?.problems ?? []).map((problem, i) => {
|
||||
const done = present.value.filter((student) => student.cells[i]?.status === "accepted").length
|
||||
const wrong = present.value.filter((student) => student.cells[i]?.status === "tried").length
|
||||
const done = present.value.filter((student) => student.cells[i]?.status === 'accepted').length
|
||||
const wrong = present.value.filter((student) => student.cells[i]?.status === 'tried').length
|
||||
const total = present.value.length
|
||||
// 挑战成功几人:只有配了挑战的题才写
|
||||
const challenged = problem.hasChallenges
|
||||
? present.value.filter((student) => student.cells[i]?.challenge === "done").length
|
||||
: null
|
||||
const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null
|
||||
return { problem, done, wrong, none: total - done - wrong, total, challenged }
|
||||
}),
|
||||
)
|
||||
|
||||
const selectedId = ref<number | null>(null)
|
||||
const selectedIndex = computed(() =>
|
||||
(board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value),
|
||||
)
|
||||
const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
|
||||
function toggleProblem(problemId: number) {
|
||||
selectedId.value = selectedId.value === problemId ? null : problemId
|
||||
}
|
||||
@@ -339,9 +322,7 @@ const wrongGroups = computed(() => {
|
||||
...group,
|
||||
people: group.people.sort((a, b) => b.cell.attempts - a.cell.attempts),
|
||||
// 一堆人报错指到同一行才写「第 N 行」
|
||||
line: group.people.every((p) => p.kind.line === group.people[0]!.kind.line)
|
||||
? group.kind.line
|
||||
: null,
|
||||
line: group.people.every((p) => p.kind.line === group.people[0]!.kind.line) ? group.kind.line : null,
|
||||
}))
|
||||
.sort((a, b) => b.people.length - a.people.length)
|
||||
})
|
||||
@@ -361,12 +342,7 @@ const explainPeople = computed<ExplainPerson[]>(() => {
|
||||
order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1)
|
||||
}
|
||||
return people
|
||||
.sort(
|
||||
(a, b) =>
|
||||
order.get(b.kind.key)! - order.get(a.kind.key)! ||
|
||||
a.kind.key.localeCompare(b.kind.key) ||
|
||||
b.cell.attempts - a.cell.attempts,
|
||||
)
|
||||
.sort((a, b) => order.get(b.kind.key)! - order.get(a.kind.key)! || a.kind.key.localeCompare(b.kind.key) || b.cell.attempts - a.cell.attempts)
|
||||
.map((person) => ({
|
||||
userId: person.student.userId,
|
||||
username: person.student.username,
|
||||
@@ -392,8 +368,7 @@ const cellColors = computed(() => ({
|
||||
actionColor: theme.value.actionColor,
|
||||
}))
|
||||
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value)
|
||||
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) =>
|
||||
cellTitleOf(cell, student, board.value?.day)
|
||||
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day)
|
||||
|
||||
/**
|
||||
* 点开着轨迹的那个格子(学生 id:第几道题)。做对了、今天交过代码的格子点了不再直接去提交列表,
|
||||
@@ -407,7 +382,7 @@ function toggleTrail(key: string) {
|
||||
|
||||
/** 点到别的格子时,那边自己会换过去;这里只管点到空白处收起 */
|
||||
function trailOutside(event: MouseEvent) {
|
||||
if ((event.target as Element | null)?.closest(".cell")) return
|
||||
if ((event.target as Element | null)?.closest('.cell')) return
|
||||
trailKey.value = null
|
||||
}
|
||||
|
||||
@@ -426,40 +401,33 @@ function submissionsHref(
|
||||
today = true,
|
||||
) {
|
||||
// SQL 题不受布置的语言限制,格子数的是 SQL 交的
|
||||
const language = problem?.isSql ? "SQL" : board.value?.language
|
||||
const language = problem?.isSql ? 'SQL' : board.value?.language
|
||||
return router.resolve({
|
||||
name: "submissions",
|
||||
name: 'submissions',
|
||||
query: {
|
||||
username: student.username,
|
||||
exactUsername: "1",
|
||||
...(today ? { today: "1" } : {}),
|
||||
exactUsername: '1',
|
||||
...(today ? { today: '1' } : {}),
|
||||
// 布置时选了语言的,代码那边只看这个语言交的(和格子里数的一致)
|
||||
...(flowchart ? { language: "Flowchart" } : language ? { language } : {}),
|
||||
...(flowchart ? { language: 'Flowchart' } : language ? { language } : {}),
|
||||
...(problem ? { problem: problem.problemDisplayId } : {}),
|
||||
},
|
||||
}).href
|
||||
}
|
||||
|
||||
function lastText(student: ClassBoardStudent) {
|
||||
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, "HH:mm")
|
||||
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
|
||||
if (oftenIdle(student)) {
|
||||
return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节`
|
||||
}
|
||||
return "—"
|
||||
return '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board">
|
||||
<div class="toolbar">
|
||||
<n-select
|
||||
:value="className"
|
||||
:options="classOptions"
|
||||
placeholder="选择班级"
|
||||
filterable
|
||||
class="class-select"
|
||||
@update:value="changeClass"
|
||||
/>
|
||||
<n-select :value="className" :options="classOptions" placeholder="选择班级" filterable class="class-select" @update:value="changeClass" />
|
||||
|
||||
<!-- 这节课的题:当场填,填完收成一排小块 -->
|
||||
<LessonForm
|
||||
@@ -472,55 +440,33 @@ function lastText(student: ClassBoardStudent) {
|
||||
@cancel="editing = false"
|
||||
/>
|
||||
<div v-else-if="board?.problems.length" class="lesson">
|
||||
<span class="lesson-label">{{ board.source === "teacher" ? "这节课" : "今天在做" }}</span>
|
||||
<span v-if="board.language" class="chip lang">{{
|
||||
LANGUAGE_SHOW_VALUE[board.language]
|
||||
}}</span>
|
||||
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
|
||||
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
|
||||
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
|
||||
{{ problem.problemDisplayId }}
|
||||
</span>
|
||||
<span v-if="board.assignedAt" class="assigned">
|
||||
{{ parseTime(board.assignedAt, "HH:mm") }} {{ board.assignedBy }} 布置
|
||||
</span>
|
||||
<span v-if="board.assignedAt" class="assigned"> {{ parseTime(board.assignedAt, 'HH:mm') }} {{ board.assignedBy }} 布置 </span>
|
||||
<n-button text type="primary" class="edit" @click="editing = true">
|
||||
<template #icon><Icon icon="ph:pencil-simple" /></template>
|
||||
{{ board.source === "teacher" ? "改" : "布置" }}
|
||||
{{ board.source === 'teacher' ? '改' : '布置' }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
<n-button
|
||||
v-if="board?.className"
|
||||
:type="leaveStudents.length ? 'warning' : 'default'"
|
||||
:secondary="!!leaveStudents.length"
|
||||
@click="leaveDialog = true"
|
||||
>
|
||||
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : "请假" }}
|
||||
<n-button v-if="board?.className" :type="leaveStudents.length ? 'warning' : 'default'" :secondary="!!leaveStudents.length" @click="leaveDialog = true">
|
||||
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }}
|
||||
</n-button>
|
||||
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||||
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })">
|
||||
回头看 →
|
||||
</n-button>
|
||||
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
|
||||
</div>
|
||||
|
||||
<n-empty
|
||||
v-if="board && !board.className"
|
||||
description="最近两小时没有哪个班在交题,先在上面选一个班"
|
||||
style="margin: 60px 0"
|
||||
/>
|
||||
<n-empty v-if="board && !board.className" description="最近两小时没有哪个班在交题,先在上面选一个班" style="margin: 60px 0" />
|
||||
|
||||
<template v-else-if="board">
|
||||
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
|
||||
<section v-if="needLesson" class="start">
|
||||
<div class="start-title">这节课做哪几道题?</div>
|
||||
<LessonForm
|
||||
v-if="className"
|
||||
:board="board"
|
||||
:class-name="className"
|
||||
variant="start"
|
||||
class="start-row"
|
||||
@saved="lessonSaved"
|
||||
/>
|
||||
<LessonForm v-if="className" :board="board" :class-name="className" variant="start" class="start-row" @saved="lessonSaved" />
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
@@ -547,9 +493,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
<span class="card-foot">
|
||||
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
|
||||
<span class="muted">没做 {{ stat.none }}</span>
|
||||
<span v-if="stat.challenged !== null" class="challenged">
|
||||
挑战成功 {{ stat.challenged }}
|
||||
</span>
|
||||
<span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -574,13 +518,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
</span>
|
||||
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
|
||||
<div class="spacer"></div>
|
||||
<n-button
|
||||
quaternary
|
||||
size="small"
|
||||
class="panel-close"
|
||||
aria-label="收起"
|
||||
@click="selectedId = null"
|
||||
>
|
||||
<n-button quaternary size="small" class="panel-close" aria-label="收起" @click="selectedId = null">
|
||||
<template #icon><Icon icon="ph:x-bold" /></template>
|
||||
</n-button>
|
||||
</div>
|
||||
@@ -598,29 +536,15 @@ function lastText(student: ClassBoardStudent) {
|
||||
</div>
|
||||
<div class="err-people">
|
||||
<template v-for="person in group.people" :key="person.student.userId">
|
||||
<button
|
||||
v-if="person.cell.latest"
|
||||
type="button"
|
||||
class="who"
|
||||
@click="openExplain(selectedIndex, person.student.userId)"
|
||||
>
|
||||
<button v-if="person.cell.latest" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
|
||||
<UserName :username="person.student.username" />
|
||||
<span class="mini" :style="cellStyle(person.cell)"
|
||||
>✗ {{ person.cell.attempts }}</span
|
||||
>
|
||||
<span class="mini" :style="cellStyle(person.cell)">✗ {{ person.cell.attempts }}</span>
|
||||
</button>
|
||||
<!-- 没有今天的代码可讲:去提交列表看他交过的 -->
|
||||
<a
|
||||
v-else
|
||||
class="who"
|
||||
:href="
|
||||
submissionsHref(
|
||||
person.student,
|
||||
board.problems[selectedIndex],
|
||||
!!person.cell.attempts,
|
||||
!!person.cell.attempts,
|
||||
)
|
||||
"
|
||||
:href="submissionsHref(person.student, board.problems[selectedIndex], !!person.cell.attempts, !!person.cell.attempts)"
|
||||
target="_blank"
|
||||
>
|
||||
<UserName :username="person.student.username" />
|
||||
@@ -653,9 +577,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
</div>
|
||||
<div class="col-count">{{ stat.done }}/{{ stat.total }}</div>
|
||||
<div class="bar">
|
||||
<i
|
||||
:style="{ width: stat.total ? `${(stat.done / stat.total) * 100}%` : '0' }"
|
||||
></i>
|
||||
<i :style="{ width: stat.total ? `${(stat.done / stat.total) * 100}%` : '0' }"></i>
|
||||
</div>
|
||||
</th>
|
||||
<th class="narrow-col">做对</th>
|
||||
@@ -669,9 +591,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
<span class="group-label">
|
||||
{{ GROUP_LABEL[groupOf(student)] }}
|
||||
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
|
||||
<span v-if="groupOf(student) === 'done' && challengingCount" class="challenged">
|
||||
在挑战 {{ challengingCount }}
|
||||
</span>
|
||||
<span v-if="groupOf(student) === 'done' && challengingCount" class="challenged"> 在挑战 {{ challengingCount }} </span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -687,25 +607,16 @@ function lastText(student: ClassBoardStudent) {
|
||||
<UserName :username="student.username" />
|
||||
</a>
|
||||
<UserName v-else :username="student.username" />
|
||||
<span v-if="helpOf(student)?.status === 'pending'" class="waited">
|
||||
举手 {{ waitedText(helpOf(student)!.createdAt) }}
|
||||
</span>
|
||||
<span v-if="helpOf(student)?.status === 'pending'" class="waited"> 举手 {{ waitedText(helpOf(student)!.createdAt) }} </span>
|
||||
</td>
|
||||
<!-- 在线:举手的「帮他」;别人在帮的写谁在帮;开着题目的点题号直接打开他正在写的
|
||||
代码(他没举手也行,他那边会显示「xxx 在看你的代码」);剩下的写在不在线、
|
||||
多久前在、今天没上过线 -->
|
||||
<td class="where-col">
|
||||
<n-button
|
||||
v-if="helpOf(student)?.status === 'pending'"
|
||||
type="primary"
|
||||
size="tiny"
|
||||
@click="help(student.userId)"
|
||||
>
|
||||
<n-button v-if="helpOf(student)?.status === 'pending'" type="primary" size="tiny" @click="help(student.userId)">
|
||||
去帮 · {{ helpOf(student)!.problemId }}
|
||||
</n-button>
|
||||
<span v-else-if="helpOf(student)" class="helping">
|
||||
{{ helpOf(student)!.teacherName }} 在帮
|
||||
</span>
|
||||
<span v-else-if="helpOf(student)" class="helping"> {{ helpOf(student)!.teacherName }} 在帮 </span>
|
||||
<button
|
||||
v-else-if="student.editing"
|
||||
type="button"
|
||||
@@ -721,11 +632,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
</span>
|
||||
<span v-else class="seen">今天没上线</span>
|
||||
</td>
|
||||
<td
|
||||
v-for="(cell, i) in student.cells"
|
||||
:key="i"
|
||||
:class="{ hl: i === selectedIndex }"
|
||||
>
|
||||
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
|
||||
<button
|
||||
v-if="cell.status === 'tried' && cell.latest"
|
||||
type="button"
|
||||
@@ -778,13 +685,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
>
|
||||
{{ cellText(cell) }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="cell"
|
||||
:data-challenge="cell.challenge ?? undefined"
|
||||
:style="cellStyle(cell)"
|
||||
:title="cellTitle(cell, student)"
|
||||
>
|
||||
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell)" :title="cellTitle(cell, student)">
|
||||
{{ cellText(cell) }}
|
||||
</span>
|
||||
</td>
|
||||
@@ -797,19 +698,9 @@ function lastText(student: ClassBoardStudent) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ExplainModal
|
||||
v-model:show="explainShow"
|
||||
v-model:index="explainIndex"
|
||||
:problem="explainProblem"
|
||||
:people="explainPeople"
|
||||
/>
|
||||
<ExplainModal v-model:show="explainShow" v-model:index="explainIndex" :problem="explainProblem" :people="explainPeople" />
|
||||
|
||||
<LeaveModal
|
||||
v-model:show="leaveDialog"
|
||||
:candidates="leaveCandidates"
|
||||
:current="leaveStudents.map((student) => student.username)"
|
||||
@save="saveLeave"
|
||||
/>
|
||||
<LeaveModal v-model:show="leaveDialog" :candidates="leaveCandidates" :current="leaveStudents.map((student) => student.username)" @save="saveLeave" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -824,11 +715,11 @@ function lastText(student: ClassBoardStudent) {
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.wrong {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -865,7 +756,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
.lesson-label {
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -875,7 +766,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
@@ -891,14 +782,14 @@ function lastText(student: ClassBoardStudent) {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
/* ---------- 刚上课 ---------- */
|
||||
@@ -907,7 +798,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
padding: 22px 24px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("tone('success').background");
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
@@ -938,9 +829,9 @@ function lastText(student: ClassBoardStudent) {
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor1");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.cardColor');
|
||||
color: v-bind('theme.textColor1');
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
@@ -948,12 +839,12 @@ function lastText(student: ClassBoardStudent) {
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: v-bind("theme.borderColor");
|
||||
border-color: v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.card.on {
|
||||
border-color: v-bind("theme.successColor");
|
||||
box-shadow: inset 0 0 0 1px v-bind("theme.successColor");
|
||||
border-color: v-bind('theme.successColor');
|
||||
box-shadow: inset 0 0 0 1px v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -991,14 +882,14 @@ function lastText(student: ClassBoardStudent) {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 4px;
|
||||
background: v-bind("theme.successColor");
|
||||
background: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
/* ---------- 点开一道题:错着的人按错法归堆 ---------- */
|
||||
@@ -1007,7 +898,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid v-bind("tone('success').background");
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -1023,7 +914,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1050,7 +941,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
|
||||
/* 今天没有代码可讲的两堆(以前交过 / 只画了流程图)不是今天的错,不用红底 */
|
||||
.err-group.quiet {
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.err-title {
|
||||
@@ -1071,9 +962,9 @@ function lastText(student: ClassBoardStudent) {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor1");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
background: v-bind('theme.cardColor');
|
||||
color: v-bind('theme.textColor1');
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
@@ -1085,7 +976,7 @@ a.who {
|
||||
}
|
||||
|
||||
.who:hover {
|
||||
border-color: v-bind("theme.borderColor");
|
||||
border-color: v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.mini {
|
||||
@@ -1116,7 +1007,7 @@ a.who {
|
||||
padding: 8px 4px;
|
||||
font-weight: 400;
|
||||
vertical-align: bottom;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1124,7 +1015,7 @@ a.who {
|
||||
height: 40px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1136,7 +1027,7 @@ a.who {
|
||||
padding-right: 24px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.grid td.name {
|
||||
@@ -1149,7 +1040,7 @@ a.who {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.grid th.col {
|
||||
@@ -1161,7 +1052,7 @@ a.who {
|
||||
width: 1%;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.grid .hl {
|
||||
@@ -1185,7 +1076,7 @@ a.who {
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.col-count {
|
||||
@@ -1205,7 +1096,7 @@ a.who {
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
/* 和名字一样钉在左边:横着滑过去也看得见这一组是什么 */
|
||||
@@ -1246,12 +1137,12 @@ a.cell {
|
||||
}
|
||||
|
||||
/* 挑战:做到了贴一枚紫色筹码,在挑战加一圈紫边(设计稿 StkBoard) */
|
||||
.cell[data-challenge="done"] {
|
||||
.cell[data-challenge='done'] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cell[data-challenge="done"]::after {
|
||||
content: "";
|
||||
.cell[data-challenge='done']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
@@ -1261,12 +1152,12 @@ a.cell {
|
||||
box-sizing: border-box;
|
||||
background: #5b34c4;
|
||||
border: 3px solid #b49cf5;
|
||||
box-shadow: 0 0 0 2px v-bind("theme.cardColor");
|
||||
box-shadow: 0 0 0 2px v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.cell[data-challenge="doing"] {
|
||||
.cell[data-challenge='doing'] {
|
||||
box-shadow:
|
||||
0 0 0 2px v-bind("theme.cardColor"),
|
||||
0 0 0 2px v-bind('theme.cardColor'),
|
||||
0 0 0 4px #8f72e3;
|
||||
}
|
||||
|
||||
@@ -1276,7 +1167,7 @@ a.cell {
|
||||
}
|
||||
|
||||
.cell.picked {
|
||||
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
|
||||
box-shadow: 0 0 0 2px v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.num {
|
||||
@@ -1330,12 +1221,12 @@ a.cell {
|
||||
.seen {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.where::before,
|
||||
.present::before {
|
||||
content: "";
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Tone } from "shared/composables/tone"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { ClassBoardCell, ClassBoardStudent } from "utils/types"
|
||||
import type { Tone } from 'shared/composables/tone'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { ClassBoardCell, ClassBoardStudent } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 课堂看板的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
|
||||
@@ -12,14 +12,14 @@ export const STUCK_ATTEMPTS = 3
|
||||
|
||||
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */
|
||||
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
|
||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === day
|
||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
|
||||
}
|
||||
|
||||
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
|
||||
export function cellText(cell: ClassBoardCell) {
|
||||
if (cell.status === "accepted") return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : "✓"
|
||||
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
|
||||
return ""
|
||||
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
|
||||
if (cell.status === 'tried') return cell.attempts ? `✗ ${cell.attempts}` : '✗'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function cellStyle(
|
||||
@@ -31,48 +31,38 @@ export function cellStyle(
|
||||
actionColor: string
|
||||
},
|
||||
) {
|
||||
if (cell.status === "accepted") {
|
||||
const t = colors.tone("success")
|
||||
return acceptedToday(cell, day)
|
||||
? { background: t.background, color: t.color }
|
||||
: { background: t.background, color: t.color, opacity: 0.5 }
|
||||
if (cell.status === 'accepted') {
|
||||
const t = colors.tone('success')
|
||||
return acceptedToday(cell, day) ? { background: t.background, color: t.color } : { background: t.background, color: t.color, opacity: 0.5 }
|
||||
}
|
||||
if (cell.status === "tried") {
|
||||
if (cell.status === 'tried') {
|
||||
if (cell.attempts >= STUCK_ATTEMPTS) {
|
||||
return { background: colors.errorColor, color: "#ffffff" }
|
||||
return { background: colors.errorColor, color: '#ffffff' }
|
||||
}
|
||||
const t = colors.tone("warning")
|
||||
const t = colors.tone('warning')
|
||||
return { background: t.background, color: t.color }
|
||||
}
|
||||
return { background: colors.actionColor }
|
||||
}
|
||||
|
||||
export function cellTitle(
|
||||
cell: ClassBoardCell,
|
||||
student: ClassBoardStudent,
|
||||
day: string | undefined,
|
||||
) {
|
||||
if (cell.status === "accepted") {
|
||||
const when = acceptedToday(cell, day)
|
||||
? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对`
|
||||
: "以前就做对了"
|
||||
if (cell.challenge === "done") return `${when},挑战也做到了`
|
||||
if (cell.challenge === "doing") return `${when},在挑战`
|
||||
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day: string | undefined) {
|
||||
if (cell.status === 'accepted') {
|
||||
const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了'
|
||||
if (cell.challenge === 'done') return `${when},挑战也做到了`
|
||||
if (cell.challenge === 'doing') return `${when},在挑战`
|
||||
return when
|
||||
}
|
||||
if (cell.status === "tried") {
|
||||
if (cell.status === 'tried') {
|
||||
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
|
||||
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : "以前交过,没做对"
|
||||
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : '以前交过,没做对'
|
||||
}
|
||||
return ""
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 点开做对的格子,方块串旁边那句 */
|
||||
export function trailSummary(cell: ClassBoardCell, day: string | undefined) {
|
||||
const total = cell.trail.length
|
||||
if (cell.acceptedTry === 1) return total === 1 ? "一次就做对" : `交了 ${total} 次,第 1 次就做对`
|
||||
if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对`
|
||||
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
|
||||
return acceptedToday(cell, day)
|
||||
? `今天交了 ${total} 次代码`
|
||||
: `以前就做对了,今天又交了 ${total} 次`
|
||||
return acceptedToday(cell, day) ? `今天交了 ${total} 次代码` : `以前就做对了,今天又交了 ${total} 次`
|
||||
}
|
||||
@@ -9,15 +9,15 @@ export interface ExplainPerson {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { onKeyStroke } from "@vueuse/core"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import SubmissionView from "oj/submission/components/SubmissionView.vue"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useBackClose } from "shared/composables/backClose"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import type { ClassBoardProblem } from "utils/types"
|
||||
import ProblemBrief from "./ProblemBrief.vue"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onKeyStroke } from '@vueuse/core'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import SubmissionView from 'oj/submission/components/SubmissionView.vue'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import type { ClassBoardProblem } from 'utils/types'
|
||||
import ProblemBrief from './ProblemBrief.vue'
|
||||
|
||||
/**
|
||||
* 课堂看板「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
|
||||
@@ -34,8 +34,8 @@ const props = defineProps<{
|
||||
problem: ClassBoardProblem | null
|
||||
people: ExplainPerson[]
|
||||
}>()
|
||||
const show = defineModel<boolean>("show", { required: true })
|
||||
const index = defineModel<number>("index", { required: true })
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
const index = defineModel<number>('index', { required: true })
|
||||
|
||||
const theme = useThemeVars()
|
||||
const { isMobile } = useBreakpoints()
|
||||
@@ -46,26 +46,24 @@ useBackClose(show, isMobile)
|
||||
* v-bind 的变量挂在组件根上,传不过去(弹层底色整个透明)
|
||||
*/
|
||||
const vars = computed(() => ({
|
||||
"--card": theme.value.cardColor,
|
||||
"--text1": theme.value.textColor1,
|
||||
"--text2": theme.value.textColor2,
|
||||
"--text3": theme.value.textColor3,
|
||||
"--divider": theme.value.dividerColor,
|
||||
"--border": theme.value.borderColor,
|
||||
"--success": theme.value.successColor,
|
||||
"--hover": theme.value.hoverColor,
|
||||
'--card': theme.value.cardColor,
|
||||
'--text1': theme.value.textColor1,
|
||||
'--text2': theme.value.textColor2,
|
||||
'--text3': theme.value.textColor3,
|
||||
'--divider': theme.value.dividerColor,
|
||||
'--border': theme.value.borderColor,
|
||||
'--success': theme.value.successColor,
|
||||
'--hover': theme.value.hoverColor,
|
||||
}))
|
||||
const current = computed(() => props.people[index.value] ?? null)
|
||||
const showProblem = useStorage("oj_explain_show_problem", false)
|
||||
const showProblem = useStorage('oj_explain_show_problem', false)
|
||||
|
||||
/** 一排人横着滑的时候,换到的人要滑进来(← → 换人、或者点开的就是排在后面的) */
|
||||
const peopleRef = ref<HTMLElement | null>(null)
|
||||
watch([index, show], async () => {
|
||||
if (!show.value) return
|
||||
await nextTick()
|
||||
peopleRef.value
|
||||
?.querySelector<HTMLElement>(".person.on")
|
||||
?.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
peopleRef.value?.querySelector<HTMLElement>('.person.on')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
})
|
||||
|
||||
function step(delta: -1 | 1) {
|
||||
@@ -73,45 +71,30 @@ function step(delta: -1 | 1) {
|
||||
if (next >= 0 && next < props.people.length) index.value = next
|
||||
}
|
||||
|
||||
onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
|
||||
onKeyStroke(['ArrowLeft', 'ArrowRight'], (event) => {
|
||||
if (!show.value || props.people.length < 2) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
event.preventDefault()
|
||||
step(event.key === "ArrowLeft" ? -1 : 1)
|
||||
step(event.key === 'ArrowLeft' ? -1 : 1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="show" :auto-focus="false">
|
||||
<!-- 无 preset 的 n-modal 直接子节点要是普通元素 -->
|
||||
<div
|
||||
class="sheet"
|
||||
:class="{ narrow: isMobile }"
|
||||
:style="vars"
|
||||
role="dialog"
|
||||
:aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'"
|
||||
>
|
||||
<div class="sheet" :class="{ narrow: isMobile }" :style="vars" role="dialog" :aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'">
|
||||
<div class="top">
|
||||
<span v-if="problem" class="problem">
|
||||
<b>{{ problem.problemDisplayId }}</b> {{ problem.title }}
|
||||
<template v-if="people.length > 1"> · 错着的</template>
|
||||
</span>
|
||||
<div ref="peopleRef" class="people">
|
||||
<button
|
||||
v-for="(person, i) in people"
|
||||
:key="person.userId"
|
||||
type="button"
|
||||
class="person"
|
||||
:class="{ on: i === index }"
|
||||
@click="index = i"
|
||||
>
|
||||
<button v-for="(person, i) in people" :key="person.userId" type="button" class="person" :class="{ on: i === index }" @click="index = i">
|
||||
<UserName :username="person.username" />
|
||||
<span class="kind">{{ person.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="people.length > 1 && !isMobile" class="keys"
|
||||
><kbd>←</kbd><kbd>→</kbd> 换人</span
|
||||
>
|
||||
<span v-if="people.length > 1 && !isMobile" class="keys"><kbd>←</kbd><kbd>→</kbd> 换人</span>
|
||||
<div class="spacer"></div>
|
||||
<div class="buttons">
|
||||
<n-button
|
||||
@@ -123,13 +106,7 @@ onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
|
||||
>
|
||||
题目
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="current"
|
||||
:size="isMobile ? 'small' : 'medium'"
|
||||
tag="a"
|
||||
:href="'/submission/' + current.submissionId"
|
||||
target="_blank"
|
||||
>
|
||||
<n-button v-if="current" :size="isMobile ? 'small' : 'medium'" tag="a" :href="'/submission/' + current.submissionId" target="_blank">
|
||||
在新页面打开
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassBoardStudent } from "utils/types"
|
||||
import type { ClassBoardStudent } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 看板的「请假」框:勾今天不在的人。规则在看板那边(只有今天还没交过的人能算请假、
|
||||
@@ -12,7 +12,7 @@ const props = defineProps<{
|
||||
current: string[]
|
||||
}>()
|
||||
const emit = defineEmits<{ save: [usernames: string[]] }>()
|
||||
const show = defineModel<boolean>("show", { required: true })
|
||||
const show = defineModel<boolean>('show', { required: true })
|
||||
|
||||
const selected = ref<string[]>([])
|
||||
watch(show, (open) => {
|
||||
@@ -28,9 +28,9 @@ const unseen = (student: ClassBoardStudent | undefined) => !!student && !student
|
||||
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
|
||||
const sections = computed(() =>
|
||||
[
|
||||
{ label: "今天没上线", students: props.candidates.filter(unseen) },
|
||||
{ label: '今天没上线', students: props.candidates.filter(unseen) },
|
||||
{
|
||||
label: "今天上过线",
|
||||
label: '今天上过线',
|
||||
students: props.candidates.filter((student) => !unseen(student)),
|
||||
},
|
||||
].filter((section) => section.students.length),
|
||||
@@ -41,7 +41,7 @@ function selectAll(list: ClassBoardStudent[]) {
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit("save", selected.value)
|
||||
emit('save', selected.value)
|
||||
show.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -50,11 +50,7 @@ function save() {
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:title="
|
||||
candidates.length
|
||||
? `今天还没交过的 ${candidates.length} 人里,谁请假了?`
|
||||
: '今天全班都交过题了'
|
||||
"
|
||||
:title="candidates.length ? `今天还没交过的 ${candidates.length} 人里,谁请假了?` : '今天全班都交过题了'"
|
||||
style="width: 640px; max-width: calc(100vw - 32px)"
|
||||
>
|
||||
<n-checkbox-group v-if="candidates.length" v-model:value="selected">
|
||||
@@ -72,22 +68,14 @@ function save() {
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="leave-grid">
|
||||
<n-checkbox
|
||||
v-for="student in section.students"
|
||||
:key="student.userId"
|
||||
:value="student.username"
|
||||
:label="student.username"
|
||||
class="leave-option"
|
||||
/>
|
||||
<n-checkbox v-for="student in section.students" :key="student.userId" :value="student.username" :label="student.username" class="leave-option" />
|
||||
</div>
|
||||
</div>
|
||||
</n-checkbox-group>
|
||||
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
|
||||
<template #footer>
|
||||
<n-flex align="center" :wrap="false">
|
||||
<n-text depth="3" class="meta" style="flex-grow: 1">
|
||||
已选 {{ selected.length }} 人 · 只记在这台电脑上,今天有效
|
||||
</n-text>
|
||||
<n-text depth="3" class="meta" style="flex-grow: 1"> 已选 {{ selected.length }} 人 · 只记在这台电脑上,今天有效 </n-text>
|
||||
<n-button :disabled="!selected.length" @click="selected = []">全部取消</n-button>
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button type="primary" @click="save">确定</n-button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { LessonLanguage } from "@oj2/contract"
|
||||
import { setClassLesson } from "oj/api"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import type { ClassBoard } from "utils/types"
|
||||
import type { LessonLanguage } from '@oj2/contract'
|
||||
import { setClassLesson } from 'oj/api'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import type { ClassBoard } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
|
||||
@@ -13,7 +13,7 @@ import type { ClassBoard } from "utils/types"
|
||||
const props = defineProps<{
|
||||
board: ClassBoard
|
||||
className: string
|
||||
variant: "inline" | "start"
|
||||
variant: 'inline' | 'start'
|
||||
}>()
|
||||
const emit = defineEmits<{ saved: []; cancel: [] }>()
|
||||
const message = useMessage()
|
||||
@@ -22,27 +22,24 @@ const message = useMessage()
|
||||
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
|
||||
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
|
||||
*/
|
||||
const lessonLanguage = ref<LessonLanguage>("Python")
|
||||
const LESSON_LANGUAGES: LessonLanguage[] = ["Python", "C", "C++", "SQL"]
|
||||
const lessonLanguage = ref<LessonLanguage>('Python')
|
||||
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
|
||||
const languageOptions = LESSON_LANGUAGES.map((value) => ({
|
||||
label: LANGUAGE_SHOW_VALUE[value],
|
||||
value,
|
||||
}))
|
||||
const lessonInput = ref("")
|
||||
const lessonInput = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
function resetLessonInput() {
|
||||
lessonInput.value =
|
||||
props.board.source === "teacher"
|
||||
? props.board.problems.map((problem) => problem.problemDisplayId).join(" ")
|
||||
: ""
|
||||
lessonLanguage.value = props.board.language ?? props.board.lastLanguage ?? "Python"
|
||||
lessonInput.value = props.board.source === 'teacher' ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
|
||||
lessonLanguage.value = props.board.language ?? props.board.lastLanguage ?? 'Python'
|
||||
}
|
||||
|
||||
resetLessonInput()
|
||||
// 推断出来的题也带进框里,老师多半只是确认一下
|
||||
if (props.variant === "inline" && !lessonInput.value && props.board.problems.length) {
|
||||
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(" ")
|
||||
if (props.variant === 'inline' && !lessonInput.value && props.board.problems.length) {
|
||||
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(' ')
|
||||
}
|
||||
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)。
|
||||
// 必须写成两个来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
|
||||
@@ -54,8 +51,8 @@ async function saveLesson() {
|
||||
saving.value = true
|
||||
try {
|
||||
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null)
|
||||
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉")
|
||||
emit("saved")
|
||||
message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉')
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
message.error(errorMessage(error))
|
||||
} finally {
|
||||
@@ -66,37 +63,14 @@ async function saveLesson() {
|
||||
|
||||
<template>
|
||||
<div v-if="variant === 'inline'">
|
||||
<n-select
|
||||
v-model:value="lessonLanguage"
|
||||
:options="languageOptions"
|
||||
aria-label="用什么语言做"
|
||||
class="lang-select"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="lessonInput"
|
||||
placeholder="这节课的题号,用空格隔开"
|
||||
clearable
|
||||
autofocus
|
||||
@keyup.enter="saveLesson"
|
||||
/>
|
||||
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" class="lang-select" />
|
||||
<n-input v-model:value="lessonInput" placeholder="这节课的题号,用空格隔开" clearable autofocus @keyup.enter="saveLesson" />
|
||||
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
|
||||
<n-button @click="emit('cancel')">取消</n-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-select
|
||||
v-model:value="lessonLanguage"
|
||||
:options="languageOptions"
|
||||
aria-label="用什么语言做"
|
||||
size="large"
|
||||
class="lang-select"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="lessonInput"
|
||||
size="large"
|
||||
placeholder="输入题号,用空格隔开"
|
||||
class="start-input"
|
||||
@keyup.enter="saveLesson"
|
||||
/>
|
||||
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" size="large" class="lang-select" />
|
||||
<n-input v-model:value="lessonInput" size="large" placeholder="输入题号,用空格隔开" class="start-input" @keyup.enter="saveLesson" />
|
||||
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { isSqlProblem } from "@oj2/contract"
|
||||
import { getProblem } from "oj/api"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { ProblemDetail } from "utils/types"
|
||||
import { isSqlProblem } from '@oj2/contract'
|
||||
import { getProblem } from 'oj/api'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { ProblemDetail } from 'utils/types'
|
||||
|
||||
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
|
||||
const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue"))
|
||||
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
|
||||
|
||||
/**
|
||||
* 「讲错误」弹层左边的题目(用户要的:演示学生的错误时题目和代码要并排)。
|
||||
@@ -14,14 +14,14 @@ const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.v
|
||||
const props = defineProps<{ displayId: string }>()
|
||||
|
||||
const problem = ref<ProblemDetail | null>(null)
|
||||
const failed = ref("")
|
||||
const failed = ref('')
|
||||
|
||||
// 同一道题 ← → 换人不重拉:弹层开着时一直是这道题
|
||||
const cache = new Map<string, ProblemDetail>()
|
||||
watch(
|
||||
() => props.displayId,
|
||||
async (id) => {
|
||||
failed.value = ""
|
||||
failed.value = ''
|
||||
const hit = cache.get(id)
|
||||
if (hit) {
|
||||
problem.value = hit
|
||||
@@ -29,7 +29,7 @@ watch(
|
||||
}
|
||||
problem.value = null
|
||||
try {
|
||||
const data = await getProblem(id, "")
|
||||
const data = await getProblem(id, '')
|
||||
cache.set(id, data)
|
||||
if (props.displayId === id) problem.value = data
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { attemptTone, cachedSubmission } from "oj/statistics/attempts"
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { ClassBoardCell, ClassBoardProblem, Submission } from "utils/types"
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { attemptTone, cachedSubmission } from 'oj/statistics/attempts'
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { ClassBoardCell, ClassBoardProblem, Submission } from 'utils/types'
|
||||
|
||||
/**
|
||||
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
|
||||
@@ -28,30 +28,26 @@ defineEmits<{ close: [] }>()
|
||||
const tone = useTone()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const picked = ref(
|
||||
props.cell.acceptedTry
|
||||
? Math.max(props.cell.acceptedTry - 2, 0)
|
||||
: Math.max(props.cell.trail.length - 1, 0),
|
||||
)
|
||||
const picked = ref(props.cell.acceptedTry ? Math.max(props.cell.acceptedTry - 2, 0) : Math.max(props.cell.trail.length - 1, 0))
|
||||
const item = computed(() => props.cell.trail[picked.value] ?? props.cell.trail.at(-1))
|
||||
|
||||
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
|
||||
const code = reactive<Record<string, Submission | 'loading' | 'failed'>>({})
|
||||
watch(
|
||||
() => item.value?.id,
|
||||
async (id) => {
|
||||
if (!id || (code[id] && code[id] !== "failed")) return
|
||||
code[id] = "loading"
|
||||
if (!id || (code[id] && code[id] !== 'failed')) return
|
||||
code[id] = 'loading'
|
||||
try {
|
||||
code[id] = await cachedSubmission(id)
|
||||
} catch {
|
||||
code[id] = "failed"
|
||||
code[id] = 'failed'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const shown = computed(() => {
|
||||
const value = item.value && code[item.value.id]
|
||||
return typeof value === "object" ? value : null
|
||||
return typeof value === 'object' ? value : null
|
||||
})
|
||||
|
||||
const squares = computed(() =>
|
||||
@@ -98,17 +94,9 @@ const squares = computed(() =>
|
||||
<b :style="{ color: tone(attemptTone(item.result)).color }">
|
||||
{{ JUDGE_STATUS[item.result]?.name }}
|
||||
</b>
|
||||
<span class="muted"
|
||||
>{{ parseTime(item.createTime, "HH:mm") }} · 第 {{ picked + 1 }} 次</span
|
||||
>
|
||||
<span class="muted">{{ parseTime(item.createTime, 'HH:mm') }} · 第 {{ picked + 1 }} 次</span>
|
||||
</div>
|
||||
<n-code
|
||||
v-if="shown"
|
||||
class="code-body"
|
||||
:code="shown.code"
|
||||
:language="LANGUAGE_FORMAT_VALUE[shown.language]"
|
||||
show-line-numbers
|
||||
/>
|
||||
<n-code v-if="shown" class="code-body" :code="shown.code" :language="LANGUAGE_FORMAT_VALUE[shown.language]" show-line-numbers />
|
||||
<span v-else-if="code[item.id] === 'failed'" class="muted">代码没拉下来,点下面打开看</span>
|
||||
<span v-else class="muted">代码加载中…</span>
|
||||
</div>
|
||||
@@ -177,14 +165,14 @@ const squares = computed(() =>
|
||||
|
||||
.square.on {
|
||||
box-shadow:
|
||||
0 0 0 2px v-bind("theme.cardColor"),
|
||||
0 0 0 4px v-bind("theme.textColor1");
|
||||
0 0 0 2px v-bind('theme.cardColor'),
|
||||
0 0 0 4px v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.summary {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.code {
|
||||
@@ -193,7 +181,7 @@ const squares = computed(() =>
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.code-head {
|
||||
@@ -214,7 +202,7 @@ const squares = computed(() =>
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -223,6 +211,6 @@ const squares = computed(() =>
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { isExamTag } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
||||
import { isExamTag } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { CONTEST_STATUS, ContestStatus } from 'utils/constants'
|
||||
|
||||
/**
|
||||
* 比赛的两个小标:状态(进行中 / 未开始 / 已结束)和类型(练习 / 期中 / 期末)。
|
||||
@@ -15,35 +15,26 @@ const tone = useTone()
|
||||
|
||||
const style = computed(() => {
|
||||
if (props.status !== undefined) {
|
||||
const kind =
|
||||
props.status === ContestStatus.underway
|
||||
? "success"
|
||||
: props.status === ContestStatus.not_started
|
||||
? "warning"
|
||||
: "default"
|
||||
const kind = props.status === ContestStatus.underway ? 'success' : props.status === ContestStatus.not_started ? 'warning' : 'default'
|
||||
const t = tone(kind)
|
||||
return { color: t.color, background: t.background, border: "1px solid transparent" }
|
||||
return { color: t.color, background: t.background, border: '1px solid transparent' }
|
||||
}
|
||||
if (props.tag && isExamTag(props.tag)) {
|
||||
const t = tone("error")
|
||||
return { color: t.color, background: t.background, border: "1px solid transparent" }
|
||||
const t = tone('error')
|
||||
return { color: t.color, background: t.background, border: '1px solid transparent' }
|
||||
}
|
||||
return {
|
||||
color: theme.value.textColor3,
|
||||
background: "transparent",
|
||||
background: 'transparent',
|
||||
border: `1px solid ${theme.value.borderColor}`,
|
||||
}
|
||||
})
|
||||
|
||||
const text = computed(() =>
|
||||
props.status !== undefined ? CONTEST_STATUS[props.status]?.name : props.tag,
|
||||
)
|
||||
const text = computed(() => (props.status !== undefined ? CONTEST_STATUS[props.status]?.name : props.tag))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="{ strong: status === ContestStatus.underway }" :style="style">{{
|
||||
text
|
||||
}}</span>
|
||||
<span class="badge" :class="{ strong: status === ContestStatus.underway }" :style="style">{{ text }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { parseTime } from "utils/functions"
|
||||
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { useContestStore } from 'oj/store/contest'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import ContestAccessTag from 'shared/components/ContestAccessTag.vue'
|
||||
import Markdown from 'shared/components/Markdown.vue'
|
||||
|
||||
const contestStore = useContestStore()
|
||||
</script>
|
||||
@@ -19,10 +19,10 @@ const contestStore = useContestStore()
|
||||
</div>
|
||||
<n-descriptions bordered label-placement="left" :column="1">
|
||||
<n-descriptions-item label="开始时间">
|
||||
{{ parseTime(contestStore.contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
|
||||
{{ parseTime(contestStore.contest.startTime, 'YYYY年M月D日 HH:mm:ss') }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="结束时间">
|
||||
{{ parseTime(contestStore.contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
|
||||
{{ parseTime(contestStore.contest.endTime, 'YYYY年M月D日 HH:mm:ss') }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="比赛类型">
|
||||
<ContestAccessTag :contest="contestStore.contest" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContestScoreRow } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { secondsToDuration } from "utils/functions"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import type { ContestScoreRow } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { secondsToDuration } from 'utils/functions'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
|
||||
/**
|
||||
* 题目页右边的小榜:前三名,然后是我和我前后各两个人(中间隔一个「⋯」)。
|
||||
@@ -13,8 +13,8 @@ const props = defineProps<{ rows: ContestScoreRow[]; me?: number }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const success = computed(() => tone("success"))
|
||||
const danger = computed(() => tone("error"))
|
||||
const success = computed(() => tone('success'))
|
||||
const danger = computed(() => tone('error'))
|
||||
|
||||
const shown = computed(() => {
|
||||
const rows = props.rows
|
||||
@@ -66,11 +66,11 @@ function move(row: ContestScoreRow) {
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
font-size: var(--oj-fs-sec);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.line.me {
|
||||
background: v-bind("success.background");
|
||||
background: v-bind('success.background');
|
||||
}
|
||||
|
||||
.rank {
|
||||
@@ -78,11 +78,11 @@ function move(row: ContestScoreRow) {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.me .rank {
|
||||
color: v-bind("success.color");
|
||||
color: v-bind('success.color');
|
||||
}
|
||||
|
||||
.move {
|
||||
@@ -91,11 +91,11 @@ function move(row: ContestScoreRow) {
|
||||
}
|
||||
|
||||
.up {
|
||||
color: v-bind("success.color");
|
||||
color: v-bind('success.color');
|
||||
}
|
||||
|
||||
.down {
|
||||
color: v-bind("danger.color");
|
||||
color: v-bind('danger.color');
|
||||
}
|
||||
|
||||
.who {
|
||||
@@ -114,7 +114,7 @@ function move(row: ContestScoreRow) {
|
||||
width: 62px;
|
||||
text-align: right;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,6 @@ function move(row: ContestScoreRow) {
|
||||
padding-left: 44px;
|
||||
font-size: 12px;
|
||||
line-height: 8px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
import { parseTime, secondsToDuration } from "utils/functions"
|
||||
import NotFound from "shared/components/NotFound.vue"
|
||||
import { errorCode } from "utils/api"
|
||||
import { useContestStore } from "../store/contest"
|
||||
import ContestBadge from "./components/ContestBadge.vue"
|
||||
import ContestInfo from "./components/ContestInfo.vue"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { ContestStatus } from 'utils/constants'
|
||||
import { parseTime, secondsToDuration } from 'utils/functions'
|
||||
import NotFound from 'shared/components/NotFound.vue'
|
||||
import { errorCode } from 'utils/api'
|
||||
import { useContestStore } from '../store/contest'
|
||||
import ContestBadge from './components/ContestBadge.vue'
|
||||
import ContestInfo from './components/ContestInfo.vue'
|
||||
|
||||
/**
|
||||
* 比赛页的外壳(设计稿「比赛重设计」):一条顶栏 + 下面三个页之一。
|
||||
@@ -23,21 +23,21 @@ const route = useRoute()
|
||||
const message = useMessage()
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const success = computed(() => tone("success"))
|
||||
const success = computed(() => tone('success'))
|
||||
|
||||
const password = ref("")
|
||||
const password = ref('')
|
||||
|
||||
async function check() {
|
||||
const error = await contestStore.checkPassword(props.contestID, password.value)
|
||||
if (error === "too-many-password-attempts") {
|
||||
message.error("密码错误次数太多,请 10 分钟后再试")
|
||||
if (error === 'too-many-password-attempts') {
|
||||
message.error('密码错误次数太多,请 10 分钟后再试')
|
||||
} else if (!contestStore.access) {
|
||||
message.error("密码不对")
|
||||
message.error('密码不对')
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉比赛失败:原来没接,链接不对就是一整页空白 */
|
||||
const failure = ref<null | "missing" | "failed">(null)
|
||||
const failure = ref<null | 'missing' | 'failed'>(null)
|
||||
|
||||
async function load() {
|
||||
failure.value = null
|
||||
@@ -45,7 +45,7 @@ async function load() {
|
||||
await contestStore.init(props.contestID)
|
||||
} catch (err) {
|
||||
if (contestStore.contest) return
|
||||
failure.value = errorCode(err) === "contest-not-found" ? "missing" : "failed"
|
||||
failure.value = errorCode(err) === 'contest-not-found' ? 'missing' : 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,56 +63,41 @@ onBeforeUnmount(contestStore.clear)
|
||||
const status = computed(() => contestStore.contestStatus)
|
||||
const contest = computed(() => contestStore.contest)
|
||||
|
||||
const passwordFormVisible = computed(
|
||||
() => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin,
|
||||
)
|
||||
const passwordFormVisible = computed(() => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin)
|
||||
/** 学生在开始前进来:题目和榜都还拿不到,整页就是「几点开始、还有多久」 */
|
||||
const waiting = computed(
|
||||
() =>
|
||||
status.value === ContestStatus.not_started &&
|
||||
!contestStore.isContestAdmin &&
|
||||
!passwordFormVisible.value,
|
||||
)
|
||||
const waiting = computed(() => status.value === ContestStatus.not_started && !contestStore.isContestAdmin && !passwordFormVisible.value)
|
||||
|
||||
const untilStart = computed(() => {
|
||||
if (!contest.value) return ""
|
||||
if (!contest.value) return ''
|
||||
const seconds = Math.floor((Date.parse(contest.value.startTime) - contestStore.now) / 1000)
|
||||
if (seconds <= 0) return ""
|
||||
if (seconds <= 0) return ''
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} 天`
|
||||
return secondsToDuration(seconds)
|
||||
})
|
||||
|
||||
const timeRange = computed(() => {
|
||||
if (!contest.value) return ""
|
||||
const start = parseTime(contest.value.startTime, "M月D日 HH:mm")
|
||||
const sameDay =
|
||||
parseTime(contest.value.startTime, "YYYY-MM-DD") ===
|
||||
parseTime(contest.value.endTime, "YYYY-MM-DD")
|
||||
const end = parseTime(contest.value.endTime, sameDay ? "HH:mm" : "M月D日 HH:mm")
|
||||
if (!contest.value) return ''
|
||||
const start = parseTime(contest.value.startTime, 'M月D日 HH:mm')
|
||||
const sameDay = parseTime(contest.value.startTime, 'YYYY-MM-DD') === parseTime(contest.value.endTime, 'YYYY-MM-DD')
|
||||
const end = parseTime(contest.value.endTime, sameDay ? 'HH:mm' : 'M月D日 HH:mm')
|
||||
return `${start} – ${end}`
|
||||
})
|
||||
|
||||
const routeName = computed(() => String(route.name ?? ""))
|
||||
const routeName = computed(() => String(route.name ?? ''))
|
||||
const teacherTabs = computed(() => [
|
||||
{
|
||||
name: "contest class",
|
||||
label: status.value === ContestStatus.finished ? "成绩" : "全班情况",
|
||||
name: 'contest class',
|
||||
label: status.value === ContestStatus.finished ? '成绩' : '全班情况',
|
||||
},
|
||||
{ name: "contest rank", label: "排名" },
|
||||
{ name: "contest submissions", label: "提交" },
|
||||
{ name: "contest problems", label: "题目" },
|
||||
{ name: 'contest rank', label: '排名' },
|
||||
{ name: 'contest submissions', label: '提交' },
|
||||
{ name: 'contest problems', label: '题目' },
|
||||
])
|
||||
const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NotFound
|
||||
v-if="failure === 'missing'"
|
||||
title="这场比赛不存在"
|
||||
hint="可能是链接复制错了,或者这场比赛还没公开"
|
||||
to="/contest"
|
||||
to-label="去比赛列表"
|
||||
/>
|
||||
<NotFound v-if="failure === 'missing'" title="这场比赛不存在" hint="可能是链接复制错了,或者这场比赛还没公开" to="/contest" to-label="去比赛列表" />
|
||||
<NotFound v-else-if="failure === 'failed'" icon="ph:wifi-slash" title="比赛没拉下来">
|
||||
<n-button type="primary" @click="load()">再试一次</n-button>
|
||||
</NotFound>
|
||||
@@ -127,37 +112,26 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
<span class="countdown-num">{{ contestStore.remaining }}</span>
|
||||
</span>
|
||||
<span v-else-if="status === ContestStatus.not_started" class="countdown">
|
||||
<span class="countdown-label">{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始</span>
|
||||
<span class="countdown-label">{{ parseTime(contest.startTime, 'M月D日 HH:mm') }} 开始</span>
|
||||
<span v-if="untilStart" class="countdown-num small">还有 {{ untilStart }}</span>
|
||||
</span>
|
||||
<span v-else class="range">{{ timeRange }}</span>
|
||||
<template v-if="contestStore.canEnter">
|
||||
<i class="divider"></i>
|
||||
<nav v-if="contestStore.isTeacher" class="tabs" aria-label="比赛">
|
||||
<RouterLink
|
||||
v-for="tab in teacherTabs"
|
||||
:key="tab.name"
|
||||
:to="to(tab.name)"
|
||||
class="tab"
|
||||
:class="{ on: routeName === tab.name }"
|
||||
>{{ tab.label }}</RouterLink
|
||||
>
|
||||
<RouterLink v-for="tab in teacherTabs" :key="tab.name" :to="to(tab.name)" class="tab" :class="{ on: routeName === tab.name }">{{
|
||||
tab.label
|
||||
}}</RouterLink>
|
||||
</nav>
|
||||
<template v-else>
|
||||
<RouterLink v-if="routeName !== 'contest problems'" :to="to('contest problems')">
|
||||
<n-button>‹ 题目</n-button>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="routeName !== 'contest submissions'"
|
||||
:to="{ ...to('contest submissions'), query: { myself: '1' } }"
|
||||
>
|
||||
<RouterLink v-if="routeName !== 'contest submissions'" :to="{ ...to('contest submissions'), query: { myself: '1' } }">
|
||||
<n-button>我的提交</n-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<RouterLink
|
||||
v-if="contestStore.isContestAdmin"
|
||||
:to="{ name: 'admin contest edit', params: { contestID } }"
|
||||
>
|
||||
<RouterLink v-if="contestStore.isContestAdmin" :to="{ name: 'admin contest edit', params: { contestID } }">
|
||||
<n-button>比赛设置</n-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -181,9 +155,7 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
</div>
|
||||
<div v-else-if="waiting" class="gate">
|
||||
<b>比赛还没开始</b>
|
||||
<span class="gate-sub">
|
||||
{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始,到时候题目会自己出来,不用刷新
|
||||
</span>
|
||||
<span class="gate-sub"> {{ parseTime(contest.startTime, 'M月D日 HH:mm') }} 开始,到时候题目会自己出来,不用刷新 </span>
|
||||
<span v-if="untilStart" class="gate-count">{{ untilStart }}</span>
|
||||
</div>
|
||||
<div v-else class="body"><router-view></router-view></div>
|
||||
@@ -213,7 +185,7 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -231,7 +203,7 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
color: v-bind("theme.warningColorPressed");
|
||||
color: v-bind('theme.warningColorPressed');
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
@@ -250,19 +222,19 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
|
||||
.range {
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
margin: 0 4px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
background: v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -273,17 +245,17 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tab + .tab {
|
||||
border-left: 1px solid v-bind("theme.borderColor");
|
||||
border-left: 1px solid v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
background: v-bind("success.background");
|
||||
color: v-bind("success.color");
|
||||
background: v-bind('success.background');
|
||||
color: v-bind('success.color');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -298,13 +270,13 @@ const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||
|
||||
.gate-sub {
|
||||
font-size: 14px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.gate-count {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind("theme.warningColorPressed");
|
||||
color: v-bind('theme.warningColorPressed');
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getContestList } from "oj/api"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { contestLength, parseTime, secondsToDuration } from "utils/functions"
|
||||
import type { Contest } from "utils/types"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import AdminLink from "shared/components/AdminLink.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
import ContestBadge from "./components/ContestBadge.vue"
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { getContestList } from 'oj/api'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { contestLength, parseTime, secondsToDuration } from 'utils/functions'
|
||||
import type { Contest } from 'utils/types'
|
||||
import ContestTitle from 'shared/components/ContestTitle.vue'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import { usePagination } from 'shared/composables/pagination'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { ContestStatus } from 'utils/constants'
|
||||
import ContestBadge from './components/ContestBadge.vue'
|
||||
|
||||
/**
|
||||
* 比赛列表(设计稿「比赛重设计」):进行中的单独一大块「接着比」,未开始的一行一个,
|
||||
@@ -33,8 +33,8 @@ interface ContestQuery {
|
||||
}
|
||||
|
||||
const { query } = usePagination<ContestQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
scope: useRouteQuery("scope", "").value,
|
||||
keyword: useRouteQuery('keyword', '').value,
|
||||
scope: useRouteQuery('scope', '').value,
|
||||
})
|
||||
|
||||
const data = ref<Contest[]>([])
|
||||
@@ -42,10 +42,10 @@ const total = ref(0)
|
||||
const active = ref<Contest[]>([])
|
||||
|
||||
const scopes = computed(() => [
|
||||
{ value: "", label: "全部" },
|
||||
...(userStore.isAuthed ? [{ value: "joined", label: "我参加过的" }] : []),
|
||||
{ value: "练习", label: "练习" },
|
||||
{ value: "考试", label: "期中 · 期末" },
|
||||
{ value: '', label: '全部' },
|
||||
...(userStore.isAuthed ? [{ value: 'joined', label: '我参加过的' }] : []),
|
||||
{ value: '练习', label: '练习' },
|
||||
{ value: '考试', label: '期中 · 期末' },
|
||||
])
|
||||
|
||||
async function listContests() {
|
||||
@@ -53,9 +53,9 @@ async function listContests() {
|
||||
offset: (query.page - 1) * query.limit,
|
||||
limit: query.limit,
|
||||
keyword: query.keyword,
|
||||
status: "",
|
||||
tag: query.scope === "joined" ? "" : query.scope,
|
||||
joined: query.scope === "joined",
|
||||
status: '',
|
||||
tag: query.scope === 'joined' ? '' : query.scope,
|
||||
joined: query.scope === 'joined',
|
||||
})
|
||||
data.value = res.results
|
||||
total.value = res.total
|
||||
@@ -64,7 +64,7 @@ async function listContests() {
|
||||
// 进行中 / 即将开始的单独拎到最上面:表格按开始时间倒序,一场开了一周的练习赛
|
||||
// 会被后来建的比赛压到第二页去,学生找不着正在比的那一场
|
||||
async function listActive() {
|
||||
const base = { offset: 0, limit: 6, keyword: "", tag: "" }
|
||||
const base = { offset: 0, limit: 6, keyword: '', tag: '' }
|
||||
const [underway, upcoming] = await Promise.all([
|
||||
getContestList({ ...base, status: ContestStatus.underway }),
|
||||
getContestList({ ...base, status: ContestStatus.not_started }),
|
||||
@@ -73,12 +73,8 @@ async function listActive() {
|
||||
}
|
||||
|
||||
const plain = computed(() => query.page === 1 && !query.keyword && !query.scope)
|
||||
const running = computed(() =>
|
||||
plain.value ? active.value.filter((c) => c.status === ContestStatus.underway) : [],
|
||||
)
|
||||
const upcoming = computed(() =>
|
||||
plain.value ? active.value.filter((c) => c.status === ContestStatus.not_started) : [],
|
||||
)
|
||||
const running = computed(() => (plain.value ? active.value.filter((c) => c.status === ContestStatus.underway) : []))
|
||||
const upcoming = computed(() => (plain.value ? active.value.filter((c) => c.status === ContestStatus.not_started) : []))
|
||||
/** 上面单独列过的,下面表里不再重复 */
|
||||
const rows = computed(() => {
|
||||
const shown = new Set([...running.value, ...upcoming.value].map((c) => c.id))
|
||||
@@ -97,7 +93,7 @@ const now = useNow({ scheduler: (cb) => useIntervalFn(cb, 1000) })
|
||||
function remaining(contest: Contest) {
|
||||
const seconds = Math.floor((Date.parse(contest.endTime) - now.value.getTime()) / 1000)
|
||||
const text = secondsToDuration(Math.max(0, seconds))
|
||||
return text.startsWith("0:") ? text.slice(2) : text
|
||||
return text.startsWith('0:') ? text.slice(2) : text
|
||||
}
|
||||
function untilStart(contest: Contest) {
|
||||
const ms = Date.parse(contest.startTime) - now.value.getTime()
|
||||
@@ -106,9 +102,8 @@ function untilStart(contest: Contest) {
|
||||
return `还有 ${secondsToDuration(Math.max(0, Math.floor(ms / 1000)))}`
|
||||
}
|
||||
function range(contest: Contest) {
|
||||
const sameDay =
|
||||
parseTime(contest.startTime, "YYYY-MM-DD") === parseTime(contest.endTime, "YYYY-MM-DD")
|
||||
return `${parseTime(contest.startTime, "HH:mm")} – ${parseTime(contest.endTime, sameDay ? "HH:mm" : "M月D日 HH:mm")}`
|
||||
const sameDay = parseTime(contest.startTime, 'YYYY-MM-DD') === parseTime(contest.endTime, 'YYYY-MM-DD')
|
||||
return `${parseTime(contest.startTime, 'HH:mm')} – ${parseTime(contest.endTime, sameDay ? 'HH:mm' : 'M月D日 HH:mm')}`
|
||||
}
|
||||
|
||||
function open(contest: Contest) {
|
||||
@@ -117,20 +112,18 @@ function open(contest: Contest) {
|
||||
return
|
||||
}
|
||||
// 老师直接进「全班情况」
|
||||
router.push(
|
||||
userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`,
|
||||
)
|
||||
router.push(userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`)
|
||||
}
|
||||
|
||||
function mineText(contest: Contest) {
|
||||
const mine = contest.mine
|
||||
if (!mine) return ""
|
||||
const solved = `做对 ${mine.solved}/${contest.problemCount ?? "?"}`
|
||||
if (!mine) return ''
|
||||
const solved = `做对 ${mine.solved}/${contest.problemCount ?? '?'}`
|
||||
return mine.rank ? `第 ${mine.rank} 名 / ${mine.total} · ${solved}` : solved
|
||||
}
|
||||
|
||||
const success = computed(() => tone("success"))
|
||||
const warning = computed(() => tone("warning"))
|
||||
const success = computed(() => tone('success'))
|
||||
const warning = computed(() => tone('warning'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,41 +131,22 @@ const warning = computed(() => tone("warning"))
|
||||
<div class="top">
|
||||
<h2>比赛</h2>
|
||||
<div class="spacer"></div>
|
||||
<button
|
||||
v-for="item in scopes"
|
||||
:key="item.value"
|
||||
class="chip"
|
||||
:class="{ on: query.scope === item.value }"
|
||||
@click="query.scope = item.value"
|
||||
>
|
||||
<button v-for="item in scopes" :key="item.value" class="chip" :class="{ on: query.scope === item.value }" @click="query.scope = item.value">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<n-input v-model:value="query.keyword" clearable placeholder="比赛名" style="width: 200px" />
|
||||
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
|
||||
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/contest/create"
|
||||
>新建比赛</AdminLink
|
||||
>
|
||||
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/contest/create">新建比赛</AdminLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="contest in running"
|
||||
:key="contest.id"
|
||||
class="running"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="open(contest)"
|
||||
@keyup.enter="open(contest)"
|
||||
>
|
||||
<div v-for="contest in running" :key="contest.id" class="running" role="link" tabindex="0" @click="open(contest)" @keyup.enter="open(contest)">
|
||||
<ContestBadge :status="ContestStatus.underway" />
|
||||
<div class="running-main">
|
||||
<div class="running-title">
|
||||
<ContestTitle :contest="contest" />
|
||||
<ContestBadge :tag="contest.tag" />
|
||||
</div>
|
||||
<span class="muted small">
|
||||
{{ range(contest) }} · {{ contest.problemCount }} 道题 ·
|
||||
{{ contest.participantCount }} 人在比
|
||||
</span>
|
||||
<span class="muted small"> {{ range(contest) }} · {{ contest.problemCount }} 道题 · {{ contest.participantCount }} 人在比 </span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="running-right">
|
||||
@@ -186,25 +160,17 @@ const warning = computed(() => tone("warning"))
|
||||
>
|
||||
</div>
|
||||
<n-button type="primary" size="large" @click.stop="open(contest)">
|
||||
{{ contest.mine ? "接着比 ›" : "进去比 ›" }}
|
||||
{{ contest.mine ? '接着比 ›' : '进去比 ›' }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="contest in upcoming"
|
||||
:key="contest.id"
|
||||
class="upcoming"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="open(contest)"
|
||||
@keyup.enter="open(contest)"
|
||||
>
|
||||
<div v-for="contest in upcoming" :key="contest.id" class="upcoming" role="link" tabindex="0" @click="open(contest)" @keyup.enter="open(contest)">
|
||||
<ContestBadge :status="ContestStatus.not_started" />
|
||||
<ContestTitle :contest="contest" class="upcoming-title" />
|
||||
<ContestBadge :tag="contest.tag" />
|
||||
<div class="spacer"></div>
|
||||
<span class="small sec">
|
||||
{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始 · 比
|
||||
{{ parseTime(contest.startTime, 'M月D日 HH:mm') }} 开始 · 比
|
||||
{{ contestLength(contest.startTime, contest.endTime) }}
|
||||
</span>
|
||||
<span class="small amber">{{ untilStart(contest) }}</span>
|
||||
@@ -212,7 +178,7 @@ const warning = computed(() => tone("warning"))
|
||||
|
||||
<div class="table">
|
||||
<div class="tr th">
|
||||
<span>{{ plain ? "已结束" : "比赛" }}</span>
|
||||
<span>{{ plain ? '已结束' : '比赛' }}</span>
|
||||
<span>类型</span>
|
||||
<span>日期</span>
|
||||
<span>时长</span>
|
||||
@@ -229,14 +195,11 @@ const warning = computed(() => tone("warning"))
|
||||
@keyup.enter="open(contest)"
|
||||
>
|
||||
<span class="name">
|
||||
<ContestBadge
|
||||
v-if="contest.status !== ContestStatus.finished"
|
||||
:status="contest.status as ContestStatus"
|
||||
/>
|
||||
<ContestBadge v-if="contest.status !== ContestStatus.finished" :status="contest.status as ContestStatus" />
|
||||
<ContestTitle :contest="contest" />
|
||||
</span>
|
||||
<span><ContestBadge :tag="contest.tag" /></span>
|
||||
<span class="muted small num">{{ parseTime(contest.startTime, "YYYY-MM-DD") }}</span>
|
||||
<span class="muted small num">{{ parseTime(contest.startTime, 'YYYY-MM-DD') }}</span>
|
||||
<span class="muted small">{{ contestLength(contest.startTime, contest.endTime) }}</span>
|
||||
<span class="mine small">
|
||||
<template v-if="contest.mine">
|
||||
@@ -260,15 +223,11 @@ const warning = computed(() => tone("warning"))
|
||||
</svg>
|
||||
<span class="sec">{{ mineText(contest) }}</span>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="userStore.isAuthed && contest.status === ContestStatus.finished"
|
||||
class="faint"
|
||||
>没参加</span
|
||||
>
|
||||
<span v-else-if="userStore.isAuthed && contest.status === ContestStatus.finished" class="faint">没参加</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!rows.length" class="empty muted">
|
||||
{{ query.scope === "joined" ? "你还没参加过比赛" : "没有符合的比赛" }}
|
||||
{{ query.scope === 'joined' ? '你还没参加过比赛' : '没有符合的比赛' }}
|
||||
</div>
|
||||
</div>
|
||||
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
||||
@@ -299,16 +258,16 @@ const warning = computed(() => tone("warning"))
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sec {
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.small {
|
||||
@@ -320,29 +279,29 @@ const warning = computed(() => tone("warning"))
|
||||
}
|
||||
|
||||
.green {
|
||||
color: v-bind("success.color");
|
||||
color: v-bind('success.color');
|
||||
}
|
||||
|
||||
.amber {
|
||||
color: v-bind("warning.color");
|
||||
color: v-bind('warning.color');
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: var(--oj-ctrl-h);
|
||||
padding: 0 16px;
|
||||
border-radius: 17px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: transparent;
|
||||
background: v-bind("success.background");
|
||||
color: v-bind("success.color");
|
||||
background: v-bind('success.background');
|
||||
color: v-bind('success.color');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -353,8 +312,8 @@ const warning = computed(() => tone("warning"))
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 20px;
|
||||
border-radius: var(--oj-radius);
|
||||
border: 1px solid v-bind("theme.successColor");
|
||||
background: v-bind("success.background");
|
||||
border: 1px solid v-bind('theme.successColor');
|
||||
background: v-bind('success.background');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -381,7 +340,7 @@ const warning = computed(() => tone("warning"))
|
||||
}
|
||||
|
||||
.countdown {
|
||||
color: v-bind("warning.color");
|
||||
color: v-bind('warning.color');
|
||||
}
|
||||
|
||||
.countdown b {
|
||||
@@ -396,7 +355,7 @@ const warning = computed(() => tone("warning"))
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
border-radius: var(--oj-radius);
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -406,7 +365,7 @@ const warning = computed(() => tone("warning"))
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: var(--oj-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -419,7 +378,7 @@ const warning = computed(() => tone("warning"))
|
||||
min-height: var(--oj-row-h-roomy);
|
||||
padding: 0 var(--oj-pad-x);
|
||||
font-size: var(--oj-fs-body);
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -428,14 +387,14 @@ const warning = computed(() => tone("warning"))
|
||||
}
|
||||
|
||||
.tr:not(.th):hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.th {
|
||||
min-height: var(--oj-head-h);
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -447,7 +406,7 @@ const warning = computed(() => tone("warning"))
|
||||
}
|
||||
|
||||
.tr.done .name {
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.mine {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContestClassView, ContestScoreCell, ContestScoreRow } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { updateACMHelperChecked } from "admin/api"
|
||||
import { getContestClassView, getSubmission, getSubmissions } from "oj/api"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { useLeaveStudents } from "shared/composables/leaveStudents"
|
||||
import { ContestStatus, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||
import { parseTime, secondsToDuration } from "utils/functions"
|
||||
import type { Submission } from "utils/types"
|
||||
import { useVisibleInterval } from "shared/composables/visibleInterval"
|
||||
import type { ContestClassView, ContestScoreCell, ContestScoreRow } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { updateACMHelperChecked } from 'admin/api'
|
||||
import { getContestClassView, getSubmission, getSubmissions } from 'oj/api'
|
||||
import { useContestStore } from 'oj/store/contest'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { useTone } from 'shared/composables/tone'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { useLeaveStudents } from 'shared/composables/leaveStudents'
|
||||
import { ContestStatus, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
|
||||
import { parseTime, secondsToDuration } from 'utils/functions'
|
||||
import type { Submission } from 'utils/types'
|
||||
import { useVisibleInterval } from 'shared/composables/visibleInterval'
|
||||
|
||||
/**
|
||||
* 老师的「全班情况」,考完叫「成绩」(设计稿「比赛重设计」老师两块)。学生看不到这一页。
|
||||
@@ -28,7 +28,7 @@ const contestStore = useContestStore()
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
const message = useMessage()
|
||||
const { onLeave } = useLeaveStudents("oj_leave_students")
|
||||
const { onLeave } = useLeaveStudents('oj_leave_students')
|
||||
|
||||
const view = ref<ContestClassView | null>(null)
|
||||
const failed = ref(false)
|
||||
@@ -78,17 +78,12 @@ function flagOf(row: ContestScoreRow) {
|
||||
for (const p of problems.value) {
|
||||
const cell = cellOf(row, p.id)
|
||||
if (cell && !cell.isAc && cell.errors >= STUCK_ERRORS) {
|
||||
return { kind: "stuck" as const, text: `卡在第 ${p._id} 题,错 ${cell.errors} 次` }
|
||||
return { kind: 'stuck' as const, text: `卡在第 ${p._id} 题,错 ${cell.errors} 次` }
|
||||
}
|
||||
}
|
||||
const idle = minutesSinceLast(row)
|
||||
if (
|
||||
running.value &&
|
||||
idle !== null &&
|
||||
idle >= IDLE_MINUTES &&
|
||||
row.solved < problems.value.length
|
||||
) {
|
||||
return { kind: "idle" as const, text: `${idle} 分钟没交` }
|
||||
if (running.value && idle !== null && idle >= IDLE_MINUTES && row.solved < problems.value.length) {
|
||||
return { kind: 'idle' as const, text: `${idle} 分钟没交` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -97,60 +92,39 @@ const attention = computed(() =>
|
||||
rows.value
|
||||
.map((row) => ({ row, flag: flagOf(row) }))
|
||||
.filter((item) => item.flag !== null)
|
||||
.sort((a, b) =>
|
||||
a.flag!.kind === b.flag!.kind
|
||||
? a.row.solved - b.row.solved
|
||||
: a.flag!.kind === "stuck"
|
||||
? -1
|
||||
: 1,
|
||||
),
|
||||
.sort((a, b) => (a.flag!.kind === b.flag!.kind ? a.row.solved - b.row.solved : a.flag!.kind === 'stuck' ? -1 : 1)),
|
||||
)
|
||||
const others = computed(() => {
|
||||
const flagged = new Set(attention.value.map((item) => item.row.userId))
|
||||
return rows.value
|
||||
.filter((row) => !flagged.has(row.userId))
|
||||
.sort((a, b) => a.solved - b.solved || (minutesSinceLast(b) ?? 0) - (minutesSinceLast(a) ?? 0))
|
||||
return rows.value.filter((row) => !flagged.has(row.userId)).sort((a, b) => a.solved - b.solved || (minutesSinceLast(b) ?? 0) - (minutesSinceLast(a) ?? 0))
|
||||
})
|
||||
const absent = computed(() =>
|
||||
(view.value?.absent ?? []).map((user) => ({ ...user, leave: onLeave(user.username) })),
|
||||
)
|
||||
const absent = computed(() => (view.value?.absent ?? []).map((user) => ({ ...user, leave: onLeave(user.username) })))
|
||||
const leaveCount = computed(() => absent.value.filter((user) => user.leave).length)
|
||||
const rosterSize = computed(() => {
|
||||
const classes = new Set(view.value?.classes ?? [])
|
||||
return (
|
||||
rows.value.filter((row) => row.className && classes.has(row.className)).length +
|
||||
absent.value.length
|
||||
)
|
||||
return rows.value.filter((row) => row.className && classes.has(row.className)).length + absent.value.length
|
||||
})
|
||||
const average = computed(() =>
|
||||
rows.value.length
|
||||
? (rows.value.reduce((sum, row) => sum + row.solved, 0) / rows.value.length).toFixed(1)
|
||||
: "0",
|
||||
)
|
||||
const stuckCount = computed(
|
||||
() => attention.value.filter((item) => item.flag!.kind === "stuck").length,
|
||||
)
|
||||
const idleCount = computed(
|
||||
() => attention.value.filter((item) => item.flag!.kind === "idle").length,
|
||||
)
|
||||
const average = computed(() => (rows.value.length ? (rows.value.reduce((sum, row) => sum + row.solved, 0) / rows.value.length).toFixed(1) : '0'))
|
||||
const stuckCount = computed(() => attention.value.filter((item) => item.flag!.kind === 'stuck').length)
|
||||
const idleCount = computed(() => attention.value.filter((item) => item.flag!.kind === 'idle').length)
|
||||
|
||||
function lastText(row: ContestScoreRow) {
|
||||
const minutes = minutesSinceLast(row)
|
||||
if (minutes === null) return ""
|
||||
return minutes === 0 ? "刚刚" : `${minutes} 分钟前`
|
||||
if (minutes === null) return ''
|
||||
return minutes === 0 ? '刚刚' : `${minutes} 分钟前`
|
||||
}
|
||||
|
||||
const classNames = computed(() => (view.value?.classes ?? []).map(classLabel).join("、"))
|
||||
const classNames = computed(() => (view.value?.classes ?? []).map(classLabel).join('、'))
|
||||
|
||||
// ---------------------------------------------------------------- 考完:成绩
|
||||
|
||||
/** 按姓名(用户名,中文按拼音)/ 名次 / 做对少的在前 */
|
||||
type Sort = "username" | "rank" | "weak"
|
||||
const sort = ref<Sort>("username")
|
||||
type Sort = 'username' | 'rank' | 'weak'
|
||||
const sort = ref<Sort>('username')
|
||||
const gradeRows = computed(() => {
|
||||
const list = [...rows.value]
|
||||
if (sort.value === "username") list.sort((a, b) => a.username.localeCompare(b.username, "zh-CN"))
|
||||
else if (sort.value === "weak") list.sort((a, b) => a.solved - b.solved || b.rank! - a.rank!)
|
||||
if (sort.value === 'username') list.sort((a, b) => a.username.localeCompare(b.username, 'zh-CN'))
|
||||
else if (sort.value === 'weak') list.sort((a, b) => a.solved - b.solved || b.rank! - a.rank!)
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -159,51 +133,36 @@ const distribution = computed(() => {
|
||||
for (const row of rows.value) count.set(row.solved, (count.get(row.solved) ?? 0) + 1)
|
||||
return [...count.entries()].sort(([a], [b]) => b - a)
|
||||
})
|
||||
const acTotal = computed(() =>
|
||||
rows.value.reduce((sum, row) => sum + Object.values(row.cells).filter((c) => c.isAc).length, 0),
|
||||
)
|
||||
const checkedTotal = computed(() =>
|
||||
rows.value.reduce(
|
||||
(sum, row) => sum + Object.values(row.cells).filter((c) => c.isAc && c.checked).length,
|
||||
0,
|
||||
),
|
||||
)
|
||||
const acTotal = computed(() => rows.value.reduce((sum, row) => sum + Object.values(row.cells).filter((c) => c.isAc).length, 0))
|
||||
const checkedTotal = computed(() => rows.value.reduce((sum, row) => sum + Object.values(row.cells).filter((c) => c.isAc && c.checked).length, 0))
|
||||
|
||||
function barColor(solved: number) {
|
||||
const n = problems.value.length || 1
|
||||
const ratio = solved / n
|
||||
if (ratio >= 1) return theme.value.successColor
|
||||
if (ratio >= 0.7) return tone("success").solid + "b3"
|
||||
if (ratio >= 0.5) return tone("success").solid + "66"
|
||||
if (ratio >= 0.7) return tone('success').solid + 'b3'
|
||||
if (ratio >= 0.5) return tone('success').solid + '66'
|
||||
if (ratio >= 0.3) return theme.value.warningColor
|
||||
return theme.value.errorColor + "aa"
|
||||
return theme.value.errorColor + 'aa'
|
||||
}
|
||||
|
||||
const selected = ref<{ userId: number; problemId: number } | null>(null)
|
||||
const code = ref<Submission | null>(null)
|
||||
const codeState = ref<"idle" | "loading" | "none" | "failed">("idle")
|
||||
const codeState = ref<'idle' | 'loading' | 'none' | 'failed'>('idle')
|
||||
|
||||
const selectedRow = computed(() =>
|
||||
selected.value ? rows.value.find((row) => row.userId === selected.value!.userId) : undefined,
|
||||
)
|
||||
const selectedProblem = computed(() =>
|
||||
selected.value ? problems.value.find((p) => p.id === selected.value!.problemId) : undefined,
|
||||
)
|
||||
const selectedCell = computed(() =>
|
||||
selectedRow.value && selectedProblem.value
|
||||
? cellOf(selectedRow.value, selectedProblem.value.id)
|
||||
: undefined,
|
||||
)
|
||||
const selectedRow = computed(() => (selected.value ? rows.value.find((row) => row.userId === selected.value!.userId) : undefined))
|
||||
const selectedProblem = computed(() => (selected.value ? problems.value.find((p) => p.id === selected.value!.problemId) : undefined))
|
||||
const selectedCell = computed(() => (selectedRow.value && selectedProblem.value ? cellOf(selectedRow.value, selectedProblem.value.id) : undefined))
|
||||
|
||||
async function pick(row: ContestScoreRow, problemId: number) {
|
||||
selected.value = { userId: row.userId, problemId }
|
||||
code.value = null
|
||||
const problem = problems.value.find((p) => p.id === problemId)
|
||||
if (!problem || !cellOf(row, problemId)) {
|
||||
codeState.value = "none"
|
||||
codeState.value = 'none'
|
||||
return
|
||||
}
|
||||
codeState.value = "loading"
|
||||
codeState.value = 'loading'
|
||||
try {
|
||||
const list = await getSubmissions({
|
||||
contestId: props.contestID,
|
||||
@@ -215,15 +174,15 @@ async function pick(row: ContestScoreRow, problemId: number) {
|
||||
const mine = list.results.filter((item) => item.username === row.username)
|
||||
const target = mine.find((item) => item.result === 0) ?? mine[0]
|
||||
if (!target) {
|
||||
codeState.value = "none"
|
||||
codeState.value = 'none'
|
||||
return
|
||||
}
|
||||
const detail = await getSubmission(target.id)
|
||||
if (selected.value?.userId !== row.userId || selected.value.problemId !== problemId) return
|
||||
code.value = detail
|
||||
codeState.value = "idle"
|
||||
codeState.value = 'idle'
|
||||
} catch {
|
||||
codeState.value = "failed"
|
||||
codeState.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,15 +191,10 @@ async function toggleChecked(value: boolean) {
|
||||
const cell = selectedCell.value
|
||||
if (!row || !cell || !selected.value) return
|
||||
try {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
row.rankId,
|
||||
String(selected.value.problemId),
|
||||
value,
|
||||
)
|
||||
await updateACMHelperChecked(Number(props.contestID), row.rankId, String(selected.value.problemId), value)
|
||||
cell.checked = value
|
||||
} catch {
|
||||
message.error("没标上,只有出这场比赛的老师能标")
|
||||
message.error('没标上,只有出这场比赛的老师能标')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,10 +208,10 @@ function step(dx: number, dy: number) {
|
||||
if (list[nr] && problems.value[nc]) pick(list[nr], problems.value[nc].id)
|
||||
}
|
||||
|
||||
onKeyStroke(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], (event) => {
|
||||
onKeyStroke(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'], (event) => {
|
||||
if (!ended.value || !selected.value) return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return
|
||||
if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return
|
||||
event.preventDefault()
|
||||
const map = {
|
||||
ArrowLeft: [-1, 0],
|
||||
@@ -270,59 +224,41 @@ onKeyStroke(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], (event) => {
|
||||
})
|
||||
|
||||
function exportGrades() {
|
||||
const title = contestStore.contest?.title ?? "比赛"
|
||||
const head = [
|
||||
"用户名",
|
||||
"班级",
|
||||
"做对",
|
||||
"名次",
|
||||
"用时",
|
||||
...problems.value.map((p) => `第${p._id}题 ${p.title}`),
|
||||
]
|
||||
const title = contestStore.contest?.title ?? '比赛'
|
||||
const head = ['用户名', '班级', '做对', '名次', '用时', ...problems.value.map((p) => `第${p._id}题 ${p.title}`)]
|
||||
const lines = [head]
|
||||
const byUsername = [...rows.value].sort((a, b) => a.username.localeCompare(b.username, "zh-CN"))
|
||||
const byUsername = [...rows.value].sort((a, b) => a.username.localeCompare(b.username, 'zh-CN'))
|
||||
for (const row of byUsername) {
|
||||
lines.push([
|
||||
row.username,
|
||||
row.className ? classLabel(row.className) : "",
|
||||
row.className ? classLabel(row.className) : '',
|
||||
String(row.solved),
|
||||
String(row.rank ?? ""),
|
||||
String(row.rank ?? ''),
|
||||
secondsToDuration(row.totalTime),
|
||||
...problems.value.map((p) => {
|
||||
const cell = cellOf(row, p.id)
|
||||
return !cell ? "没交" : cell.isAc ? "对" : "错"
|
||||
return !cell ? '没交' : cell.isAc ? '对' : '错'
|
||||
}),
|
||||
])
|
||||
}
|
||||
for (const user of absent.value) {
|
||||
lines.push([
|
||||
user.username,
|
||||
user.className ? classLabel(user.className) : "",
|
||||
user.leave ? "请假" : "没考",
|
||||
"",
|
||||
"",
|
||||
...problems.value.map(() => ""),
|
||||
])
|
||||
lines.push([user.username, user.className ? classLabel(user.className) : '', user.leave ? '请假' : '没考', '', '', ...problems.value.map(() => '')])
|
||||
}
|
||||
const csv = lines
|
||||
.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(","))
|
||||
.join("\r\n")
|
||||
const csv = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(',')).join('\r\n')
|
||||
// 带 BOM,Excel 双击打开中文才不乱码
|
||||
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title} 成绩.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const success = computed(() => tone("success"))
|
||||
const danger = computed(() => tone("error"))
|
||||
const warning = computed(() => tone("warning"))
|
||||
const liveColumns = computed(
|
||||
() => `130px 48px repeat(${problems.value.length}, minmax(70px, 96px)) 90px 1fr`,
|
||||
)
|
||||
const success = computed(() => tone('success'))
|
||||
const danger = computed(() => tone('error'))
|
||||
const warning = computed(() => tone('warning'))
|
||||
const liveColumns = computed(() => `130px 48px repeat(${problems.value.length}, minmax(70px, 96px)) 90px 1fr`)
|
||||
const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.value.length}, 50px)`)
|
||||
</script>
|
||||
|
||||
@@ -336,9 +272,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
<div class="stat">
|
||||
<b>{{ rows.length }}</b>
|
||||
<span
|
||||
>进来了<template v-if="classNames">
|
||||
· {{ classNames }} {{ rosterSize }} 人</template
|
||||
></span
|
||||
>进来了<template v-if="classNames"> · {{ classNames }} {{ rosterSize }} 人</template></span
|
||||
>
|
||||
</div>
|
||||
<div v-if="absent.length" class="stat">
|
||||
@@ -377,24 +311,16 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
|
||||
<template v-if="attention.length">
|
||||
<div class="group red-group">要看一眼的 {{ attention.length }} 人</div>
|
||||
<div
|
||||
v-for="{ row, flag } in attention"
|
||||
:key="row.userId"
|
||||
class="tr"
|
||||
:style="{ gridTemplateColumns: liveColumns }"
|
||||
>
|
||||
<div v-for="{ row, flag } in attention" :key="row.userId" class="tr" :style="{ gridTemplateColumns: liveColumns }">
|
||||
<UserName :username="row.username" />
|
||||
<span class="num solved">{{ row.solved }}</span>
|
||||
<span v-for="p in problems" :key="p.id" class="lc">
|
||||
<template v-if="cellOf(row, p.id)">
|
||||
<span v-if="cellOf(row, p.id)!.isAc" class="cell ac"
|
||||
>✓ {{ Math.floor(cellOf(row, p.id)!.acTime / 60) }}′<small
|
||||
v-if="cellOf(row, p.id)!.errors"
|
||||
>-{{ cellOf(row, p.id)!.errors }}</small
|
||||
></span
|
||||
>✓ {{ Math.floor(cellOf(row, p.id)!.acTime / 60) }}′<small v-if="cellOf(row, p.id)!.errors">-{{ cellOf(row, p.id)!.errors }}</small></span
|
||||
>
|
||||
<span v-else class="cell no" :class="{ hot: cellOf(row, p.id)!.errors >= 3 }">{{
|
||||
cellOf(row, p.id)!.errors ? `错 ${cellOf(row, p.id)!.errors}` : "交过"
|
||||
cellOf(row, p.id)!.errors ? `错 ${cellOf(row, p.id)!.errors}` : '交过'
|
||||
}}</span>
|
||||
</template>
|
||||
<span v-else class="dot">·</span>
|
||||
@@ -407,25 +333,15 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
</template>
|
||||
|
||||
<div class="group">其余 {{ others.length }} 人 · 做对少的在前</div>
|
||||
<div
|
||||
v-for="row in others"
|
||||
:key="row.userId"
|
||||
class="tr"
|
||||
:style="{ gridTemplateColumns: liveColumns }"
|
||||
>
|
||||
<div v-for="row in others" :key="row.userId" class="tr" :style="{ gridTemplateColumns: liveColumns }">
|
||||
<UserName :username="row.username" />
|
||||
<span class="num solved">{{ row.solved }}</span>
|
||||
<span v-for="p in problems" :key="p.id" class="lc">
|
||||
<template v-if="cellOf(row, p.id)">
|
||||
<span v-if="cellOf(row, p.id)!.isAc" class="cell ac"
|
||||
>✓ {{ Math.floor(cellOf(row, p.id)!.acTime / 60) }}′<small
|
||||
v-if="cellOf(row, p.id)!.errors"
|
||||
>-{{ cellOf(row, p.id)!.errors }}</small
|
||||
></span
|
||||
>✓ {{ Math.floor(cellOf(row, p.id)!.acTime / 60) }}′<small v-if="cellOf(row, p.id)!.errors">-{{ cellOf(row, p.id)!.errors }}</small></span
|
||||
>
|
||||
<span v-else class="cell no">{{
|
||||
cellOf(row, p.id)!.errors ? `错 ${cellOf(row, p.id)!.errors}` : "交过"
|
||||
}}</span>
|
||||
<span v-else class="cell no">{{ cellOf(row, p.id)!.errors ? `错 ${cellOf(row, p.id)!.errors}` : '交过' }}</span>
|
||||
</template>
|
||||
<span v-else class="dot">·</span>
|
||||
</span>
|
||||
@@ -435,18 +351,13 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
|
||||
<template v-if="absent.length">
|
||||
<div class="group">没进来 {{ absent.length }} 人</div>
|
||||
<div
|
||||
v-for="user in absent"
|
||||
:key="user.userId"
|
||||
class="tr absent"
|
||||
:style="{ gridTemplateColumns: liveColumns }"
|
||||
>
|
||||
<div v-for="user in absent" :key="user.userId" class="tr absent" :style="{ gridTemplateColumns: liveColumns }">
|
||||
<UserName :username="user.username" muted />
|
||||
<span></span>
|
||||
<span v-for="p in problems" :key="p.id"></span>
|
||||
<span></span>
|
||||
<span class="flag-wrap">
|
||||
<span class="flag grey">{{ user.leave ? "请假(看板里标的)" : "没进来" }}</span>
|
||||
<span class="flag grey">{{ user.leave ? '请假(看板里标的)' : '没进来' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -466,16 +377,12 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
></span>
|
||||
</div>
|
||||
<div class="dist-legend">
|
||||
<span v-for="[solved, n] in distribution" :key="solved">
|
||||
<i :style="{ background: barColor(solved) }"></i>{{ solved }} 道 {{ n }} 人
|
||||
</span>
|
||||
<span v-for="[solved, n] in distribution" :key="solved"> <i :style="{ background: barColor(solved) }"></i>{{ solved }} 道 {{ n }} 人 </span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sec small">
|
||||
{{ rows.length }} 人考了<template v-if="absent.length">
|
||||
· {{ absent.length }} 人没考<template v-if="leaveCount"
|
||||
>({{ leaveCount }} 人请假)</template
|
||||
></template
|
||||
· {{ absent.length }} 人没考<template v-if="leaveCount">({{ leaveCount }} 人请假)</template></template
|
||||
>
|
||||
· 平均做对 {{ average }} 道
|
||||
</span>
|
||||
@@ -488,13 +395,9 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
<section class="sheet">
|
||||
<div class="sort">
|
||||
<span class="muted small">排序</span>
|
||||
<button class="chip" :class="{ on: sort === 'username' }" @click="sort = 'username'">
|
||||
姓名
|
||||
</button>
|
||||
<button class="chip" :class="{ on: sort === 'username' }" @click="sort = 'username'">姓名</button>
|
||||
<button class="chip" :class="{ on: sort === 'rank' }" @click="sort = 'rank'">名次</button>
|
||||
<button class="chip" :class="{ on: sort === 'weak' }" @click="sort = 'weak'">
|
||||
做对少的在前
|
||||
</button>
|
||||
<button class="chip" :class="{ on: sort === 'weak' }" @click="sort = 'weak'">做对少的在前</button>
|
||||
</div>
|
||||
<div class="sheet-scroll">
|
||||
<div class="tr th" :style="{ gridTemplateColumns: gradeColumns }">
|
||||
@@ -536,15 +439,10 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
<i v-if="cellOf(row, p.id)?.checked" class="seen"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="user in absent"
|
||||
:key="user.userId"
|
||||
class="tr absent"
|
||||
:style="{ gridTemplateColumns: gradeColumns }"
|
||||
>
|
||||
<div v-for="user in absent" :key="user.userId" class="tr absent" :style="{ gridTemplateColumns: gradeColumns }">
|
||||
<span></span>
|
||||
<UserName :username="user.username" muted />
|
||||
<span class="tiny">{{ user.leave ? "请假" : "没考" }}</span>
|
||||
<span class="tiny">{{ user.leave ? '请假' : '没考' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -555,49 +453,29 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
<UserName :username="selectedRow.username" />
|
||||
<span class="muted">·</span>
|
||||
<span
|
||||
><span class="muted">{{ selectedProblem._id }}</span>
|
||||
{{ selectedProblem.title }}</span
|
||||
><span class="muted">{{ selectedProblem._id }}</span> {{ selectedProblem.title }}</span
|
||||
>
|
||||
<span v-if="selectedCell?.isAc" class="pill ok">
|
||||
{{
|
||||
Math.floor(selectedCell.acTime / 60) === 0
|
||||
? "1 分钟内做对"
|
||||
: `第 ${Math.floor(selectedCell.acTime / 60)} 分钟做对`
|
||||
}}
|
||||
{{ Math.floor(selectedCell.acTime / 60) === 0 ? '1 分钟内做对' : `第 ${Math.floor(selectedCell.acTime / 60)} 分钟做对` }}
|
||||
</span>
|
||||
<span v-else-if="selectedCell" class="pill bad">没做对</span>
|
||||
<span v-if="selectedCell?.errors" class="muted small"
|
||||
>错过 {{ selectedCell.errors }} 次</span
|
||||
>
|
||||
<span v-if="selectedCell?.errors" class="muted small">错过 {{ selectedCell.errors }} 次</span>
|
||||
<span v-if="selectedCell?.firstAc" class="muted small">· 全班最先做对</span>
|
||||
</div>
|
||||
<div class="code-box">
|
||||
<n-code
|
||||
v-if="code"
|
||||
:code="code.code"
|
||||
:language="LANGUAGE_FORMAT_VALUE[code.language]"
|
||||
show-line-numbers
|
||||
/>
|
||||
<n-code v-if="code" :code="code.code" :language="LANGUAGE_FORMAT_VALUE[code.language]" show-line-numbers />
|
||||
<span v-else-if="codeState === 'loading'" class="muted">代码加载中…</span>
|
||||
<span v-else-if="codeState === 'none'" class="muted">这题没交</span>
|
||||
<span v-else-if="codeState === 'failed'" class="muted">代码没拉下来,再点一次</span>
|
||||
</div>
|
||||
<div class="code-foot">
|
||||
<n-checkbox
|
||||
v-if="selectedCell?.isAc"
|
||||
:checked="selectedCell.checked"
|
||||
:disabled="!contestStore.isContestAdmin"
|
||||
@update:checked="toggleChecked"
|
||||
<n-checkbox v-if="selectedCell?.isAc" :checked="selectedCell.checked" :disabled="!contestStore.isContestAdmin" @update:checked="toggleChecked"
|
||||
>看过了,没问题</n-checkbox
|
||||
>
|
||||
<span v-if="code" class="muted tiny">
|
||||
{{ code.language }} · {{ parseTime(code.createTime, "M月D日 HH:mm:ss") }}
|
||||
</span>
|
||||
<span v-if="code" class="muted tiny"> {{ code.language }} · {{ parseTime(code.createTime, 'M月D日 HH:mm:ss') }} </span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="code-empty muted">
|
||||
点左边任意一格,这里看那个人那道题的代码。<br />做对的格子看完可以标「看过了」,查抄袭用。
|
||||
</div>
|
||||
<div v-else class="code-empty muted">点左边任意一格,这里看那个人那道题的代码。<br />做对的格子看完可以标「看过了」,查抄袭用。</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -607,15 +485,15 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
.empty {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.sec {
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.small {
|
||||
@@ -649,7 +527,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 36px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.stat {
|
||||
@@ -666,19 +544,19 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
|
||||
.stat span {
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.stat .grey {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.stat .red {
|
||||
color: v-bind("danger.color");
|
||||
color: v-bind('danger.color');
|
||||
}
|
||||
|
||||
.stat .amber {
|
||||
color: v-bind("warning.color");
|
||||
color: v-bind('warning.color');
|
||||
}
|
||||
|
||||
.grid-wrap {
|
||||
@@ -694,14 +572,14 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
padding: 0 var(--oj-pad-x);
|
||||
font-size: var(--oj-fs-body);
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.th {
|
||||
min-height: 50px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.ph {
|
||||
@@ -712,7 +590,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
}
|
||||
|
||||
.ph b {
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.ph.center {
|
||||
@@ -751,24 +629,24 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
|
||||
.cell.ac {
|
||||
font-weight: 600;
|
||||
background: v-bind("success.background");
|
||||
color: v-bind("success.color");
|
||||
background: v-bind('success.background');
|
||||
color: v-bind('success.color');
|
||||
}
|
||||
|
||||
.cell.no {
|
||||
background: v-bind("danger.background");
|
||||
color: v-bind("danger.color");
|
||||
background: v-bind('danger.background');
|
||||
color: v-bind('danger.color');
|
||||
}
|
||||
|
||||
.cell.no.hot {
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px v-bind("danger.color");
|
||||
box-shadow: inset 0 0 0 1px v-bind('danger.color');
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -779,14 +657,14 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
align-items: center;
|
||||
font-size: var(--oj-fs-meta);
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.red-group {
|
||||
color: v-bind("danger.color");
|
||||
background: v-bind("danger.background");
|
||||
color: v-bind('danger.color');
|
||||
background: v-bind('danger.background');
|
||||
}
|
||||
|
||||
.flag-wrap {
|
||||
@@ -805,23 +683,23 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
}
|
||||
|
||||
.flag.stuck {
|
||||
background: v-bind("danger.background");
|
||||
color: v-bind("danger.color");
|
||||
background: v-bind('danger.background');
|
||||
color: v-bind('danger.color');
|
||||
}
|
||||
|
||||
.flag.idle {
|
||||
background: v-bind("warning.background");
|
||||
color: v-bind("warning.color");
|
||||
background: v-bind('warning.background');
|
||||
color: v-bind('warning.color');
|
||||
}
|
||||
|
||||
.flag.grey {
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind('theme.actionColor');
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.tr.absent {
|
||||
color: v-bind("theme.textColor3");
|
||||
background: v-bind("theme.actionColor");
|
||||
color: v-bind('theme.textColor3');
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
/* ---------- 成绩 ---------- */
|
||||
@@ -834,7 +712,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.dist {
|
||||
@@ -857,7 +735,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.dist-legend span {
|
||||
@@ -880,7 +758,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
.sheet {
|
||||
flex: 1 1 560px;
|
||||
min-width: 0;
|
||||
border-right: 1px solid v-bind("theme.dividerColor");
|
||||
border-right: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.sort {
|
||||
@@ -889,25 +767,25 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 15px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: transparent;
|
||||
background: v-bind("success.background");
|
||||
color: v-bind("success.color");
|
||||
background: v-bind('success.background');
|
||||
color: v-bind('success.color');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -929,7 +807,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
}
|
||||
|
||||
.tr.picked {
|
||||
background: v-bind("theme.hoverColor");
|
||||
background: v-bind('theme.hoverColor');
|
||||
}
|
||||
|
||||
.gc {
|
||||
@@ -940,22 +818,22 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--oj-fs-sec);
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gc.ac {
|
||||
background: v-bind("success.background");
|
||||
color: v-bind("success.color");
|
||||
background: v-bind('success.background');
|
||||
color: v-bind('success.color');
|
||||
}
|
||||
|
||||
.gc.no {
|
||||
background: v-bind("danger.background");
|
||||
color: v-bind("danger.color");
|
||||
background: v-bind('danger.background');
|
||||
color: v-bind('danger.color');
|
||||
}
|
||||
|
||||
.gc.sel {
|
||||
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
|
||||
box-shadow: 0 0 0 2px v-bind('theme.textColor1');
|
||||
}
|
||||
|
||||
.seen {
|
||||
@@ -965,7 +843,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: v-bind("success.color");
|
||||
background: v-bind('success.color');
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
@@ -976,7 +854,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
}
|
||||
|
||||
.code-head {
|
||||
@@ -997,13 +875,13 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
background: v-bind("success.background");
|
||||
color: v-bind("success.color");
|
||||
background: v-bind('success.background');
|
||||
color: v-bind('success.color');
|
||||
}
|
||||
|
||||
.pill.bad {
|
||||
background: v-bind("danger.background");
|
||||
color: v-bind("danger.color");
|
||||
background: v-bind('danger.background');
|
||||
color: v-bind('danger.color');
|
||||
}
|
||||
|
||||
.code-box {
|
||||
@@ -1012,9 +890,9 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
|
||||
max-height: calc(100vh - 360px);
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: var(--oj-radius);
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 480 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user