From 3081bcf283b42a6fe7b434c2e89af80a9ca8b5a7 Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 11 Jul 2026 18:47:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=8F=AD=E4=BC=98?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E7=99=BB=E5=BD=95=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 29 ++++++ scripts/banyou-login-playwright.cjs | 46 +++++++++ src-tauri/src/commands/student.rs | 139 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/StudentManager.tsx | 119 ++++++++++++++++++++---- src/i18n/locales/en-US.json | 15 ++- src/i18n/locales/zh-CN.json | 7 ++ src/preload/types.ts | 8 ++ 9 files changed, 345 insertions(+), 20 deletions(-) create mode 100644 scripts/banyou-login-playwright.cjs diff --git a/package.json b/package.json index d9f6c78..13fd630 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dayjs": "^1.11.20", "i18next": "^25.8.14", "pinyin-pro": "^3.27.0", + "playwright": "^1.61.1", "react": "^19.2.1", "react-dom": "^19.2.1", "react-i18next": "^16.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0c714d..f1a70e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: pinyin-pro: specifier: ^3.27.0 version: 3.28.0 + playwright: + specifier: ^1.61.1 + version: 1.61.1 react: specifier: ^19.2.1 version: 19.2.4 @@ -1594,6 +1597,11 @@ packages: resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} engines: {node: '>=0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2012,6 +2020,16 @@ packages: pinyin-pro@3.28.0: resolution: {integrity: sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4263,6 +4281,9 @@ snapshots: frac@1.1.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4680,6 +4701,14 @@ snapshots: pinyin-pro@3.28.0: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.5.8: diff --git a/scripts/banyou-login-playwright.cjs b/scripts/banyou-login-playwright.cjs new file mode 100644 index 0000000..124bbaa --- /dev/null +++ b/scripts/banyou-login-playwright.cjs @@ -0,0 +1,46 @@ +const { chromium } = require("playwright") + +const LOGIN_URL = "https://care.seewo.com/" +const LOGIN_BUTTON_SELECTOR = "a#index-login-btn" +const USER_PANEL_SELECTOR = "span#user-info-panel" +const LOGIN_TIMEOUT_MS = 10 * 60 * 1000 + +const toCookieHeader = (cookies) => { + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ") +} + +;(async () => { + let browser + try { + browser = await chromium.launch({ headless: false }) + const context = await browser.newContext() + const page = await context.newPage() + + await page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 60 * 1000 }) + await page.locator(LOGIN_BUTTON_SELECTOR).click({ timeout: 60 * 1000 }) + await page.locator(USER_PANEL_SELECTOR).waitFor({ + state: "visible", + timeout: LOGIN_TIMEOUT_MS, + }) + + const cookies = await context.cookies(LOGIN_URL) + const cookieHeader = toCookieHeader(cookies) + if (!cookieHeader) { + throw new Error("No care.seewo.com cookies were captured after login") + } + + process.stdout.write( + JSON.stringify({ + cookie: cookieHeader, + count: cookies.length, + }) + ) + } catch (error) { + process.stderr.write(error && error.stack ? error.stack : String(error)) + process.exitCode = 1 + } finally { + if (browser) { + await browser.close().catch(() => undefined) + } + } +})() diff --git a/src-tauri/src/commands/student.rs b/src-tauri/src/commands/student.rs index 65539a4..00e50f3 100644 --- a/src-tauri/src/commands/student.rs +++ b/src-tauri/src/commands/student.rs @@ -4,8 +4,11 @@ use sea_orm::{ }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tauri::State; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; use crate::db::entities::students; use crate::models::{StudentUpdate, StudentWithTags}; @@ -40,6 +43,13 @@ pub struct FetchBanYouClassroomDetailParams { pub team_plan_id: Option, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BanYouBrowserCookieData { + pub cookie: String, + pub count: usize, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct BanYouClassroom { @@ -239,6 +249,135 @@ fn extract_csrf_token_from_html(html: &str) -> Option { None } +fn find_node_project_dir() -> Option { + let mut candidates = Vec::new(); + if let Ok(current_dir) = std::env::current_dir() { + candidates.push(current_dir); + } + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + candidates.push(manifest_dir.clone()); + if let Some(parent) = manifest_dir.parent() { + candidates.push(parent.to_path_buf()); + } + + for candidate in candidates { + for dir in candidate.ancestors() { + if dir.join("package.json").is_file() + && dir + .join("scripts") + .join("banyou-login-playwright.cjs") + .is_file() + { + return Some(dir.to_path_buf()); + } + } + } + None +} + +fn node_executable() -> &'static str { + if cfg!(windows) { + "node.exe" + } else { + "node" + } +} + +async fn run_banyou_playwright_script( + project_dir: &Path, +) -> Result { + let script_path = project_dir + .join("scripts") + .join("banyou-login-playwright.cjs"); + let output = timeout( + Duration::from_secs(10 * 60), + Command::new(node_executable()) + .arg(script_path) + .current_dir(project_dir) + .output(), + ) + .await + .map_err(|_| "BanYou browser login timed out".to_string())? + .map_err(|e| format!("Failed to start Node.js for Playwright: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + "Playwright browser login failed".to_string() + } else { + stderr + }; + return Err(message); + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + return Err("Playwright did not return a Cookie".to_string()); + } + serde_json::from_str::(&stdout) + .map_err(|e| format!("Failed to parse Playwright Cookie output: {}", e)) +} + +#[tauri::command] +pub async fn student_fetch_banyou_cookie_with_browser( + state: State<'_, Arc>>, + sender_id: Option, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + + log_banyou( + state.inner(), + LogLevel::Info, + "browser cookie fetch started", + None, + ); + + let Some(project_dir) = find_node_project_dir() else { + log_banyou( + state.inner(), + LogLevel::Error, + "failed to locate project directory for playwright script", + None, + ); + return Ok(IpcResponse::error( + "Cannot find Playwright login script in project directory", + )); + }; + + match run_banyou_playwright_script(&project_dir).await { + Ok(data) => { + if data.cookie.trim().is_empty() { + return Ok(IpcResponse::error("Cookie cannot be empty")); + } + log_banyou( + state.inner(), + LogLevel::Info, + "browser cookie fetch succeeded", + Some(serde_json::json!({ + "cookie_length": data.cookie.len(), + "cookie_count": data.count, + "contains_uid": data.cookie.contains("uid="), + "contains_access_token": data.cookie.contains("accessToken="), + })), + ); + Ok(IpcResponse::success(data)) + } + Err(err) => { + log_banyou( + state.inner(), + LogLevel::Error, + "browser cookie fetch failed", + Some(serde_json::json!({ "error": err })), + ); + Ok(IpcResponse::error( + "通过浏览器登录班优失败,请重试或改用手动输入 Cookie", + )) + } + } +} + async fn get_banyou_client_and_csrf( state: &Arc>, cookie: &str, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aeed7c2..eac9542 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,6 +40,7 @@ pub fn run() { student_update, student_delete, student_import_from_xlsx, + student_fetch_banyou_cookie_with_browser, student_fetch_banyou_classrooms, student_fetch_banyou_classroom_detail, tags_get_all, diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index a2b3ad6..86665bf 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -14,7 +14,14 @@ import { Select, } from "antd" import type { ColumnsType } from "antd/es/table" -import { UploadOutlined, MoreOutlined, PlusOutlined, CopyOutlined } from "@ant-design/icons" +import { + UploadOutlined, + MoreOutlined, + PlusOutlined, + CopyOutlined, + LoginOutlined, + EditOutlined, +} from "@ant-design/icons" import { useTranslation } from "react-i18next" import { TagEditorDialog } from "./TagEditorDialog" import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar" @@ -94,7 +101,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [visible, setVisible] = useState(false) const [textImportVisible, setTextImportVisible] = useState(false) const [xlsxVisible, setXlsxVisible] = useState(false) + const [banYouLoginMethodVisible, setBanYouLoginMethodVisible] = useState(false) const [banYouVisible, setBanYouVisible] = useState(false) + const [banYouImportMode, setBanYouImportMode] = useState<"browser" | "manual">("manual") const [tagEditVisible, setTagEditVisible] = useState(false) const [editingStudent, setEditingStudent] = useState(null) const [groupEditVisible, setGroupEditVisible] = useState(false) @@ -135,6 +144,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [xlsxSelectedCol, setXlsxSelectedCol] = useState(null) const [textImportValue, setTextImportValue] = useState("") const [banYouCookie, setBanYouCookie] = useState("") + const [banYouBrowserLoginLoading, setBanYouBrowserLoginLoading] = useState(false) const [banYouLoading, setBanYouLoading] = useState(false) const [banYouDetailLoading, setBanYouDetailLoading] = useState(false) const [banYouImportLoading, setBanYouImportLoading] = useState(false) @@ -1002,15 +1012,21 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } const handleOpenBanYouImport = () => { + setBanYouLoginMethodVisible(true) + } + + const handleOpenManualBanYouImport = () => { + setBanYouImportMode("manual") + setBanYouLoginMethodVisible(false) setBanYouVisible(true) } - const handleFetchBanYouClassrooms = async () => { + const handleFetchBanYouClassrooms = async (cookieOverride?: string) => { if (!canEdit) { messageApi.error(t("common.readOnly")) return } - const cookie = banYouCookie.trim() + const cookie = (cookieOverride ?? banYouCookie).trim() if (!cookie) { messageApi.warning(t("students.banyouCookieRequired")) return @@ -1046,6 +1062,38 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } } + const handleBanYouBrowserLogin = async () => { + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + if (!(window as any).api?.fetchBanYouCookieWithBrowser) { + messageApi.error(t("students.banyouBrowserLoginFailed")) + return + } + + setBanYouBrowserLoginLoading(true) + try { + messageApi.info(t("students.banyouBrowserLoginWaiting")) + const res = await (window as any).api.fetchBanYouCookieWithBrowser() + const cookie = String(res?.data?.cookie ?? "").trim() + if (!res?.success || !cookie) { + messageApi.error(res?.message || t("students.banyouBrowserLoginFailed")) + return + } + setBanYouCookie(cookie) + setBanYouImportMode("browser") + setBanYouLoginMethodVisible(false) + setBanYouVisible(true) + messageApi.success(t("students.banyouBrowserCookieSuccess")) + await handleFetchBanYouClassrooms(cookie) + } catch (e: any) { + messageApi.error(e?.message || t("students.banyouBrowserLoginFailed")) + } finally { + setBanYouBrowserLoginLoading(false) + } + } + const medalKey = (item: BanYouMedal, idx: number) => item.key || item.uid || `${item.name}-${idx}` const fetchBanYouClassDetail = async (classroom: BanYouClassroom, teamPlanId?: number) => { @@ -1804,6 +1852,37 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + setBanYouLoginMethodVisible(false)} + footer={null} + destroyOnHidden + > + + +
+ {t("students.banyouBrowserLoginHint")} +
+ +
+
+ = ({ canEdit }) => { destroyOnHidden > -
- {t("students.banyouCookieHint")} -
- setBanYouCookie(e.target.value)} - rows={4} - placeholder={t("students.banyouCookiePlaceholder")} - disabled={banYouLoading} - /> - + {banYouImportMode === "manual" ? ( + <> +
+ {t("students.banyouCookieHint")} +
+ setBanYouCookie(e.target.value)} + rows={4} + placeholder={t("students.banyouCookiePlaceholder")} + disabled={banYouLoading} + /> + + + ) : null}

diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index e85e384..196938c 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -572,7 +572,14 @@ "importTextPlaceholder": "Example:\nAlice\nBob\nCharlie", "importByText": "Import from Text", "importByXlsx": "Import via xlsx", - "importByBanyou": "Import from BanYou", + "importByBanyou": "Import from Easicare", + "banyouLoginMethodTitle": "Choose Easicare Login Method", + "banyouBrowserLogin": "Login to Easicare in Browser (Recommended)", + "banyouBrowserLoginHint": "A Playwright browser window will open. Complete login there, then SecScore will capture the Cookie and close the browser automatically.", + "banyouManualCookie": "Enter Cookie Manually", + "banyouBrowserLoginWaiting": "Browser opened. Please complete Easicare login.", + "banyouBrowserCookieSuccess": "Easicare Cookie captured", + "banyouBrowserLoginFailed": "Failed to login to Easicare in browser", "xlsxPreview": "XLSX Preview & Import", "file": "File", "selectNameCol": "Click header to select name column", @@ -584,12 +591,12 @@ "selectNameColFirst": "Please select \"Name Column\" first", "noNamesFound": "No importable names found in selected column", "importFailed": "Import failed", - "banyouCookieHint": "After signing in to BanYou Web, copy the full Cookie and paste it below.", + "banyouCookieHint": "After signing in to Easicare Web, copy the full Cookie and paste it below.", "banyouCookiePlaceholder": "uid=...; accessToken=...; ...", "banyouCookieRequired": "Please paste Cookie first", "banyouFetch": "Fetch Classrooms", "banyouFetchSuccess": "Fetched {{count}} classrooms", - "banyouFetchFailed": "Failed to fetch BanYou classrooms", + "banyouFetchFailed": "Failed to fetch Easicare classrooms", "banyouCreatedClasses": "Classes I Created", "banyouJoinedClasses": "Classes I Joined", "banyouNoClasses": "No classroom data", @@ -609,7 +616,7 @@ "banyouSelectTeamPlanFirst": "Please select a group plan first", "banyouImportSelected": "Import Selected", "banyouNothingSelected": "Please select at least one item to import", - "banyouImportSummary": "BanYou import done: Students +{{studentsInserted}}/{{studentsSkipped}} skipped; Reasons +{{reasonsInserted}}/{{reasonsSkipped}} skipped; Groups updated {{groupsUpdated}}/{{groupsSkipped}} skipped", + "banyouImportSummary": "Easicare import done: Students +{{studentsInserted}}/{{studentsSkipped}} skipped; Reasons +{{reasonsInserted}}/{{reasonsSkipped}} skipped; Groups updated {{groupsUpdated}}/{{groupsSkipped}} skipped", "editTagTitle": "Edit Tags - {{name}}", "editGroup": "Set Group", "editGroupTitle": "Set Group - {{name}}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b9df8a6..27bffb4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -573,6 +573,13 @@ "importByText": "从文本导入", "importByXlsx": "通过 xlsx 导入", "importByBanyou": "从班优导入", + "banyouLoginMethodTitle": "选择班优登录方式", + "banyouBrowserLogin": "通过浏览器登录班优(推荐)", + "banyouBrowserLoginHint": "会打开一个 Playwright 浏览器窗口,请在窗口中完成登录;登录成功后会自动抓取 Cookie 并关闭浏览器。", + "banyouManualCookie": "手动输入 Cookie", + "banyouBrowserLoginWaiting": "已打开浏览器,请完成班优登录", + "banyouBrowserCookieSuccess": "已获取班优 Cookie", + "banyouBrowserLoginFailed": "通过浏览器登录班优失败", "xlsxPreview": "xlsx 预览与导入", "file": "文件", "selectNameCol": "点击表头选择姓名列", diff --git a/src/preload/types.ts b/src/preload/types.ts index 2191c88..f42c1b2 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -173,6 +173,14 @@ const api = { names: string[] }): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> => invoke("student_import_from_xlsx", { params }), + fetchBanYouCookieWithBrowser: (): Promise<{ + success: boolean + data?: { + cookie: string + count: number + } + message?: string + }> => invoke("student_fetch_banyou_cookie_with_browser"), fetchBanYouClassrooms: (params: { cookie: string }): Promise<{