From 4e5f06e02081e5a7ebd71b111ac9946743c83c60 Mon Sep 17 00:00:00 2001 From: chunyu Date: Sat, 20 Jun 2026 19:50:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(dp):=20DataPoints=20=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E6=92=A4=E6=8E=89=E5=AD=A6=E6=A0=A1/=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=A1=A3=E6=AC=A1=EF=BC=8C=E6=94=B9=E4=B8=BA=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E5=88=B0=E9=A1=B9=E7=9B=AE=E7=9A=84=E6=90=9C=E7=B4=A2=E7=AD=9B?= =?UTF-8?q?=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 撤掉'学校'和'项目档次(tier)'两个下拉 - 新增可搜索的'项目'筛选 ProgramFilter:输入学校/项目名实时过滤, 选中后经 programId 精确锁定单个 program(学校+项目名) - listDp 改传 programId;URL key school/tier → programId - activeFilters 显示所选项目的 '学校 · 项目' 标签 - 依赖后端 /api/dp 的 programId 参数(已合并 backend,需先部署 worker) --- src/pages/datapoints.jsx | 142 +++++++++++++++++++++++++++----- src/pages/datapoints.module.css | 80 ++++++++++++++++++ 2 files changed, 201 insertions(+), 21 deletions(-) diff --git a/src/pages/datapoints.jsx b/src/pages/datapoints.jsx index 90ea49d..6327f52 100644 --- a/src/pages/datapoints.jsx +++ b/src/pages/datapoints.jsx @@ -3,7 +3,7 @@ import Layout from '@theme/Layout'; import BrowserOnly from '@docusaurus/BrowserOnly'; import Head from '@docusaurus/Head'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import { getMe, listDp, getFilterOptions, getCounts } from '@site/src/lib/dp/api'; +import { getMe, listDp, getFilterOptions, getCounts, listPrograms } from '@site/src/lib/dp/api'; import SignInButtons from '@site/src/lib/auth/SignInButtons'; import { startOAuth } from '@site/src/lib/auth/oauth'; import styles from './datapoints.module.css'; @@ -19,7 +19,7 @@ const EMPTY_FILTER_OPTS = { schools: [], tiers: [], years: [], ugCats: [], major const COPY = { 'zh-Hans': { pageTitle: 'DataPoints', - pageDesc: 'CS / MSCS 申请历史 DataPoints — 可按学校、Tier、年份、结果、本科背景筛选', + pageDesc: 'CS / MSCS 申请历史 DataPoints — 可按项目、年份、结果、本科背景筛选', headerTitle: 'CS Application DataPoints', metaApplicants: '位申请者', metaPrograms: '个项目', @@ -29,8 +29,8 @@ const COPY = { loadFail: '加载数据失败:', searchPlaceholder: '搜索学校 / 项目', searchLabel: '搜索', - filterSchool: '学校', - filterTier: '项目档次', + filterProgram: '项目', + programSearchPlaceholder: '搜索学校 / 项目', filterYear: '年份', filterResult: '结果', filterUgCat: '本科类别', @@ -110,7 +110,7 @@ const COPY = { }, en: { pageTitle: 'DataPoints', - pageDesc: 'CS / MSCS application DataPoints — filterable by school, tier, year, result, and undergrad background', + pageDesc: 'CS / MSCS application DataPoints — filterable by program, year, result, and undergrad background', headerTitle: 'CS Application DataPoints', metaApplicants: 'applicants', metaPrograms: 'programs', @@ -120,8 +120,8 @@ const COPY = { loadFail: 'Failed to load data: ', searchPlaceholder: 'Search school / program', searchLabel: 'Search', - filterSchool: 'School', - filterTier: 'Program tier', + filterProgram: 'Program', + programSearchPlaceholder: 'Search school / program', filterYear: 'Year', filterResult: 'Result', filterUgCat: 'Undergrad type', @@ -196,7 +196,7 @@ function pickLocale(loc) { // ---------- URL <-> filter state helpers ---------- -const URL_KEYS = ['school', 'tier', 'year', 'result', 'ugCat', 'major']; +const URL_KEYS = ['programId', 'year', 'result', 'ugCat', 'major']; function readFiltersFromUrl() { if (typeof window === 'undefined') return {}; @@ -332,19 +332,29 @@ function Table({ counts, filterOpts, me, meChecked, t }) { const [openApplicantId, setOpenApplicantId] = useState(null); const initial = useMemo(() => readFiltersFromUrl(), []); - const [school, setSchool] = useState(initial.school || ''); - const [tier, setTier] = useState(initial.tier || ''); + const [programId, setProgramId] = useState(initial.programId || ''); + const [programs, setPrograms] = useState([]); const [year, setYear] = useState(initial.year || ''); const [result, setResult] = useState(initial.result || ''); const [ugCat, setUgCat] = useState(initial.ugCat || ''); const [major, setMajor] = useState(initial.major || ''); const [page, setPage] = useState(0); + // Program list powers the searchable "项目" filter and resolves a programId + // (e.g. one restored from the URL) back to its school·program label. useEffect(() => { - writeFiltersToUrl({ school, tier, year, result, ugCat, major }); - }, [school, tier, year, result, ugCat, major]); + let cancelled = false; + listPrograms({ limit: 500 }) + .then((r) => { if (!cancelled) setPrograms(r.rows || []); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + writeFiltersToUrl({ programId, year, result, ugCat, major }); + }, [programId, year, result, ugCat, major]); - useEffect(() => setPage(0), [school, tier, year, result, ugCat, major]); + useEffect(() => setPage(0), [programId, year, result, ugCat, major]); // Fetch rows from API on filter/page change. Keep previous rows visible // while a new fetch is in flight so the table doesn't blink. @@ -366,8 +376,7 @@ function Table({ counts, filterOpts, me, meChecked, t }) { setApiError(null); try { const res = await listDp({ - school: school || undefined, - tier: tier || undefined, + programId: programId || undefined, year: year || undefined, result: result || undefined, ugCategory: ugCat || undefined, @@ -385,21 +394,31 @@ function Table({ counts, filterOpts, me, meChecked, t }) { } }, delay); return () => { cancelled = true; clearTimeout(handle); }; - }, [school, tier, year, result, ugCat, major, page]); + }, [programId, year, result, ugCat, major, page]); const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); const paged = rows; + const selectedProgram = useMemo( + () => programs.find((p) => p.id === programId) || null, + [programs, programId], + ); + const activeFilters = useMemo(() => { const items = []; - if (school) items.push({ key: 'school', label: t.filterSchool, value: school }); - if (tier) items.push({ key: 'tier', label: t.filterTier, value: tier }); + if (programId) { + items.push({ + key: 'programId', + label: t.filterProgram, + value: selectedProgram ? `${selectedProgram.school} · ${selectedProgram.program}` : programId, + }); + } if (year) items.push({ key: 'year', label: t.filterYear, value: year }); if (result) items.push({ key: 'result', label: t.filterResult, value: result }); if (ugCat) items.push({ key: 'ugCat', label: t.filterUgCat, value: ugCat }); if (major) items.push({ key: 'major', label: t.filterMajor, value: major }); return items; - }, [school, tier, year, result, ugCat, major, t]); + }, [programId, selectedProgram, year, result, ugCat, major, t]); return (
@@ -428,8 +447,15 @@ function Table({ counts, filterOpts, me, meChecked, t }) {
- + { if (blurTimer.current) clearTimeout(blurTimer.current); setOpen(true); }} + onBlur={() => { blurTimer.current = setTimeout(() => setOpen(false), 150); }} + onChange={(e) => { if (value) onChange(''); setQuery(e.target.value); setOpen(true); }} + /> + {value ? ( + + ) : null} + {open && filtered.length > 0 ? ( +
    + {filtered.map((p) => ( +
  • e.preventDefault()} onClick={() => pick(p)}> + {p.school} · {p.program} + {p.tier ? {p.tier} : null} +
  • + ))} +
+ ) : null} +
+ + ); +} + function pillClass(result) { switch (result) { case 'Admit': return styles.pillAdmit; diff --git a/src/pages/datapoints.module.css b/src/pages/datapoints.module.css index 989abf5..36a52fc 100644 --- a/src/pages/datapoints.module.css +++ b/src/pages/datapoints.module.css @@ -53,6 +53,86 @@ width: 100%; } +/* ---------- Searchable program filter ---------- */ +.programFilter { + position: relative; + /* Span more grid width than a plain select — program labels are long. */ + grid-column: span 2; +} +.programFilterControl { + position: relative; + width: 100%; +} +.programFilterInput { + padding: 6px 26px 6px 10px; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + font-size: 13px; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + width: 100%; + text-overflow: ellipsis; +} +.programFilterClear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + border: none; + background: transparent; + color: var(--ifm-color-emphasis-600); + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 2px; +} +.programFilterClear:hover { + color: var(--ifm-font-color-base); +} +.programFilterDropdown { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + left: 0; + right: 0; + margin: 0; + padding: 4px; + list-style: none; + max-height: 280px; + overflow-y: auto; + background: var(--ifm-background-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} +.programFilterDropdown li { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; +} +.programFilterDropdown li:hover { + background: var(--ifm-color-emphasis-100); +} +.programFilterName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.programFilterTier { + margin-left: auto; + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); +} + .summary { font-size: 13px; color: var(--ifm-color-emphasis-700);