diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49ea857..62844ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,10 +6,10 @@ on: workflow_dispatch: inputs: version: - description: '版本号(例如 1.2.3 或 v1.2.3)' + description: "版本号(例如 1.2.3 或 v1.2.3)" required: true build: - description: '构建命令标记(build:win|build:mac|build:linux|build:all)' + description: "构建命令标记(build:win|build:mac|build:linux|build:all)" required: true permissions: @@ -25,6 +25,7 @@ jobs: run_win: ${{ steps.parse.outputs.run_win }} run_mac: ${{ steps.parse.outputs.run_mac }} run_linux: ${{ steps.parse.outputs.run_linux }} + run_android: ${{ steps.parse.outputs.run_android }} steps: - uses: actions/checkout@v4 with: @@ -159,8 +160,59 @@ jobs: name: dist-linux path: src-tauri/target/release/bundle/** + build_android: + needs: parse + if: ${{ needs.parse.outputs.run_android == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: 安装 Rust + uses: dtolnay/rust-action@stable + with: + targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android, i686-linux-android + + - name: 安装 NDK + uses: nttld/setup-ndk@v1 + with: + ndk-version: r25c + + - name: 安装 Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: 应用版本号 + run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }} + + - name: 安装依赖 + run: pnpm install --frozen-lockfile + + - name: 构建 Android(ARM64) + run: pnpm tauri android build --target aarch64-linux-android + + - name: 构建 Android(ARMv7) + run: pnpm tauri android build --target armv7-linux-androideabi + + - name: 构建 Android(x86_64) + run: pnpm tauri android build --target x86_64-linux-android + + - uses: actions/upload-artifact@v4 + with: + name: dist-android + path: src-tauri/gen/android/app/build/outputs/**/*.apk + release: - needs: [parse, build_win, build_mac, build_linux] + needs: [parse, build_win, build_mac, build_linux, build_android] if: ${{ always() && needs.parse.outputs.tag != '' }} runs-on: ubuntu-latest steps: @@ -183,3 +235,4 @@ jobs: artifacts/**/*.deb artifacts/**/*.rpm artifacts/**/*.AppImage + artifacts/**/*.apk diff --git a/scripts/ci/apply-version.mjs b/scripts/ci/apply-version.mjs new file mode 100644 index 0000000..0154627 --- /dev/null +++ b/scripts/ci/apply-version.mjs @@ -0,0 +1,72 @@ +import fs from "fs" +import path from "path" + +const rawVersion = String(process.argv[2] || "") + .trim() + .replace(/^v/i, "") +if (!rawVersion) { + process.stderr.write("缺少版本号参数,例如:node scripts/ci/apply-version.mjs 1.2.3\n") + process.exit(1) +} + +const semverRe = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/ + +function toSemver(version) { + if (semverRe.test(version)) { + return version + } + + const parts = version.split(/[._-]/) + const nums = parts.filter((p) => /^\d+$/.test(p)) + const nonNums = parts.filter((p) => !/^\d+$/.test(p) && p.length > 0) + + let major = "0", + minor = "0", + patch = "0" + let prerelease = "" + let buildMeta = "" + + if (nums.length >= 3) { + major = nums[0] + minor = nums[1] + patch = nums[2] + if (nums.length > 3) { + buildMeta = nums.slice(3).join(".") + } + } else if (nums.length === 2) { + major = nums[0] + minor = nums[1] + patch = "0" + } else if (nums.length === 1) { + major = nums[0] + minor = "0" + patch = "0" + } + + if (nonNums.length > 0) { + if (prerelease) { + prerelease += "." + nonNums.join(".") + } else { + prerelease = nonNums.join(".") + } + } + + let result = `${major}.${minor}.${patch}` + if (prerelease) { + result += `-${prerelease}` + } + if (buildMeta) { + result += `+${buildMeta}` + } + + return result +} + +const version = toSemver(rawVersion) + +process.stdout.write(`版本号转换: "${rawVersion}" -> "${version}"\n`) + +const pkgPath = path.join(process.cwd(), "package.json") +const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) +pkg.version = version +fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8") diff --git a/scripts/ci/parse-commit.mjs b/scripts/ci/parse-commit.mjs new file mode 100644 index 0000000..58d2f57 --- /dev/null +++ b/scripts/ci/parse-commit.mjs @@ -0,0 +1,104 @@ +import fs from "fs" +import path from "path" + +const cwd = process.cwd() +const pkgPath = path.join(cwd, "package.json") +const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) +const scripts = pkg?.scripts ?? {} + +const rawMessage = + process.env.RELEASE_MESSAGE ?? + process.env.GITHUB_COMMIT_MESSAGE ?? + process.argv.slice(2).join(" ") ?? + "" +const message = String(rawMessage || "").trim() + +const isReleasePrefix = /^release:/i.test(message) + +const semverRe = /\bv?(\S+)/ +const versionMatch = message.match(semverRe) +const version = versionMatch ? versionMatch[1] : "" + +const buildToken = + message.match(/\b(build:(?:win|mac|linux|android|unpack|all))\b/i)?.[1]?.toLowerCase() ?? "" +let buildScript = + buildToken === "build:all" + ? "build:all" + : buildToken && buildToken.startsWith("build:") + ? buildToken + : "" + +if (isReleasePrefix && !buildScript) { + buildScript = "build:all" +} + +const supportedBuildScripts = new Set([ + "build:win", + "build:mac", + "build:linux", + "build:android", + "build:unpack", +]) +const hasBuildScript = buildScript + ? buildScript === "build:all" + ? true + : typeof scripts[buildScript] === "string" && scripts[buildScript] + : false + +const fail = (text) => { + process.stderr.write(`${text}\n`) + process.exit(1) +} + +const hasAnySignal = Boolean(isReleasePrefix || version || buildScript) +const shouldSkip = !hasAnySignal + +if (!shouldSkip) { + if (!version) { + fail( + [ + "未在提交信息中检测到版本号。", + "示例:release: v1.2.3 build:win 或 release: 1.2.3", + "支持格式:v1.2.3 或 1.2.3(可带 -beta.1 等后缀)", + ].join("\n") + ) + } + + if (!buildScript) { + fail( + [ + "未在提交信息中检测到构建命令标记。", + "示例:release: v1.2.3 build:win", + "支持:build:win | build:mac | build:linux | build:android | build:unpack | build:all", + ].join("\n") + ) + } + + if (!hasBuildScript) { + fail(`构建脚本不存在或为空:${buildScript}`) + } +} + +const runWin = !shouldSkip && (buildScript === "build:all" || buildScript === "build:win") +const runMac = !shouldSkip && (buildScript === "build:all" || buildScript === "build:mac") +const runLinux = !shouldSkip && (buildScript === "build:all" || buildScript === "build:linux") +const runAndroid = !shouldSkip && (buildScript === "build:all" || buildScript === "build:android") +const runUnpack = !shouldSkip && buildScript === "build:unpack" + +const outputs = { + version: shouldSkip ? "" : version, + tag: shouldSkip ? "" : `v${version}`, + build_script: shouldSkip ? "" : buildScript, + run_win: String(runWin), + run_mac: String(runMac), + run_linux: String(runLinux), + run_android: String(runAndroid), + run_unpack: String(runUnpack), +} + +if (process.env.GITHUB_OUTPUT) { + const lines = Object.entries(outputs).map(([k, v]) => `${k}=${v}`) + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`) +} else { + process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`) +} diff --git a/src/App.tsx b/src/App.tsx index c5f4742..9fd7faf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,7 +52,7 @@ function MainContent(): React.JSX.Element { const [authVisible, setAuthVisible] = useState(false) const [authPassword, setAuthPassword] = useState("") const [authLoading, setAuthLoading] = useState(false) - const [isPortraitMode, setIsPortraitMode] = useState(defaultPortraitMode) + const [isPortraitMode] = useState(defaultPortraitMode) const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode) const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false) const [syncConflictVisible, setSyncConflictVisible] = useState(false) @@ -149,7 +149,8 @@ function MainContent(): React.JSX.Element { const res = await api.dbSyncApply(strategy) if (res?.success && res?.data?.success) { messageApi.success( - res.data.message || `同步完成(同步 ${res.data.synced_records} 条,解决冲突 ${res.data.resolved_conflicts} 条)` + res.data.message || + `同步完成(同步 ${res.data.synced_records} 条,解决冲突 ${res.data.resolved_conflicts} 条)` ) window.dispatchEvent( new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } }) @@ -178,7 +179,11 @@ function MainContent(): React.JSX.Element { try { syncCheckingRef.current = true const statusRes = await api.dbGetStatus() - if (!statusRes?.success || statusRes?.data?.type !== "postgresql" || !statusRes?.data?.connected) { + if ( + !statusRes?.success || + statusRes?.data?.type !== "postgresql" || + !statusRes?.data?.connected + ) { return } @@ -279,40 +284,6 @@ function MainContent(): React.JSX.Element { if (key === "settings") navigate("/settings") } - const toggleOrientationMode = async () => { - const api = (window as any).api - if (!api) return - - const nextPortraitMode = !isPortraitMode - try { - const maximized = await api.windowIsMaximized() - if (maximized) { - await api.windowMaximize() - } - if (nextPortraitMode) { - await api.windowSetResizable(false) - await api.windowResize(940, 1600) - setSidebarCollapsed(true) - setFloatingSidebarExpanded(false) - } else { - await api.windowSetResizable(true) - await api.windowResize(2560, 1440) - setSidebarCollapsed(false) - setFloatingSidebarExpanded(false) - } - setIsPortraitMode(nextPortraitMode) - } catch (error) { - messageApi.error(t("common.error")) - console.error("Failed to toggle orientation mode:", error) - } - } - - useEffect(() => { - if (!isPortraitMode || !sidebarCollapsed) { - setFloatingSidebarExpanded(false) - } - }, [isPortraitMode, sidebarCollapsed]) - const toggleSidebar = () => { if (isPortraitMode && sidebarCollapsed) { setFloatingSidebarExpanded((prev) => !prev) @@ -357,7 +328,6 @@ function MainContent(): React.JSX.Element { floatingExpand={isPortraitMode} floatingExpanded={floatingSidebarExpanded} onToggleSidebar={toggleSidebar} - onToggleOrientation={toggleOrientationMode} /> setWizardVisible(false)} /> @@ -396,7 +366,9 @@ function MainContent(): React.JSX.Element { maskClosable={false} destroyOnClose > -
+
自动同步发现冲突,请选择冲突时优先保留哪一侧的数据。
const AutoScoreManager = lazy(() => loadAutoScoreManager().then((m) => ({ default: m.AutoScoreManager })) ) -const RewardSettings = lazy(() => - loadRewardSettings().then((m) => ({ default: m.RewardSettings })) -) +const RewardSettings = lazy(() => loadRewardSettings().then((m) => ({ default: m.RewardSettings }))) const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager }))) const warmupRouteChunks = () => @@ -60,7 +58,6 @@ interface ContentAreaProps { floatingExpand: boolean floatingExpanded: boolean onToggleSidebar: () => void - onToggleOrientation: () => void } export function ContentArea({ @@ -74,7 +71,6 @@ export function ContentArea({ floatingExpand, floatingExpanded, onToggleSidebar, - onToggleOrientation, }: ContentAreaProps): React.JSX.Element { const { t } = useTranslation() @@ -153,13 +149,17 @@ export function ContentArea({ size="small" onClick={onToggleSidebar} icon={ - floatingExpand && sidebarCollapsed - ? floatingExpanded - ? - : - : sidebarCollapsed - ? - : + floatingExpand && sidebarCollapsed ? ( + floatingExpanded ? ( + + ) : ( + + ) + ) : sidebarCollapsed ? ( + + ) : ( + + ) } title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"} style={{ width: "32px", height: "32px" }} @@ -199,12 +199,7 @@ export function ContentArea({ )}
- {showWindowControls && ( - - )} + {showWindowControls && }
diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index a58b669..abe96fd 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -5,6 +5,7 @@ import { UploadOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" import { TagEditorDialog } from "./TagEditorDialog" import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar" +import { useResponsive, useIsMobile, useIsTablet } from "../hooks/useResponsive" const createXlsxWorker = () => { return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), { @@ -48,6 +49,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const xlsxWorkerRef = useRef(null) const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() + const isMobile = useIsMobile() + const isTablet = useIsTablet() + const breakpoint = useResponsive() useEffect(() => { xlsxWorkerRef.current = createXlsxWorker() @@ -470,107 +474,148 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } } - const columns: ColumnsType = [ - { - title: t("students.avatar"), - key: "avatar", - width: 88, - align: "center", - render: (_, row) => - row.avatarUrl ? ( - {row.name} = useMemo(() => { + const baseColumns: ColumnsType = [ + { + title: t("students.avatar"), + key: "avatar", + width: isMobile ? 60 : 88, + align: "center", + render: (_, row) => + row.avatarUrl ? ( + {row.name} + ) : ( +
+ - +
+ ), + }, + { + title: t("students.name"), + dataIndex: "name", + key: "name", + width: isMobile ? 80 : 100, + ellipsis: true, + }, + { + title: t("students.currentScore"), + dataIndex: "score", + key: "score", + width: isMobile ? 100 : 160, + align: "center", + render: (score: number) => ( + - ) : ( -
0 + ? "var(--ant-color-success, #52c41a)" + : score < 0 + ? "var(--ant-color-error, #ff4d4f)" + : "inherit", + fontSize: isMobile ? 12 : 14, }} > - - -
+ {score > 0 ? `+${score}` : score} +
), - }, - { title: t("students.name"), dataIndex: "name", key: "name", width: 100 }, - { - title: t("students.currentScore"), - dataIndex: "score", - key: "score", - width: 160, - align: "center", - render: (score: number) => ( - 0 - ? "var(--ant-color-success, #52c41a)" - : score < 0 - ? "var(--ant-color-error, #ff4d4f)" - : "inherit", - }} - > - {score > 0 ? `+${score}` : score} - - ), - }, - { - title: t("students.tags"), - dataIndex: "tags", - key: "tags", - width: 200, - render: (tags: string[] = []) => ( - - {tags.length === 0 ? ( - {t("students.noTags")} - ) : ( - tags.slice(0, 3).map((tag) => ( - - {tag} + }, + { + title: t("students.tags"), + dataIndex: "tags", + key: "tags", + width: isMobile ? 120 : 200, + render: (tags: string[] = []) => ( + + {tags.length === 0 ? ( + + {t("students.noTags")} + + ) : ( + tags.slice(0, isMobile ? 2 : 3).map((tag) => ( + + {tag} + + )) + )} + {tags.length > (isMobile ? 2 : 3) && ( + + +{tags.length - (isMobile ? 2 : 3)} - )) - )} - {tags.length > 3 && +{tags.length - 3}} - - ), - }, - { - title: t("common.operation"), - key: "operation", - width: 220, - render: (_, row) => ( - - - - - - ), - }, - ] + )} + + ), + }, + { + title: t("common.operation"), + key: "operation", + width: isMobile ? 150 : 220, + fixed: isMobile ? "right" : undefined, + render: (_, row) => ( + + + + + + ), + }, + ] + + if (isMobile) { + return baseColumns.filter((col) => col.key !== "tags") + } + + return baseColumns + }, [t, canEdit, isMobile, isTablet, breakpoint]) const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize) @@ -649,7 +694,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { placeholder={t("students.importTextPlaceholder")} disabled={!canEdit || textImportLoading} /> -