添加 Android 打包工作流并实现响应式设计

- 在 GitHub Actions 中添加 Android 打包工作流,支持 ARM64、ARMv7 和 x86_64 架构
- 创建 CI 脚本用于解析提交信息和应用版本号
- 移除标题栏的旋转屏幕按钮,简化窗口控制
- 创建响应式设计 hooks (useResponsive) 用于检测屏幕尺寸
- 更新 StudentManager 组件以支持移动端响应式布局
- 在移动设备上自动调整表格列宽、按钮大小和字体大小
- 移动端隐藏标签列以节省空间
- 按钮在移动端使用垂直布局和更小的尺寸
This commit is contained in:
Fox_block
2026-03-20 21:02:52 +08:00
parent bb156f7ffd
commit 73db5778f1
8 changed files with 492 additions and 175 deletions
+56 -3
View File
@@ -6,10 +6,10 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: '版本号(例如 1.2.3 或 v1.2.3' description: "版本号(例如 1.2.3 或 v1.2.3"
required: true required: true
build: build:
description: '构建命令标记(build:win|build:mac|build:linux|build:all' description: "构建命令标记(build:win|build:mac|build:linux|build:all"
required: true required: true
permissions: permissions:
@@ -25,6 +25,7 @@ jobs:
run_win: ${{ steps.parse.outputs.run_win }} run_win: ${{ steps.parse.outputs.run_win }}
run_mac: ${{ steps.parse.outputs.run_mac }} run_mac: ${{ steps.parse.outputs.run_mac }}
run_linux: ${{ steps.parse.outputs.run_linux }} run_linux: ${{ steps.parse.outputs.run_linux }}
run_android: ${{ steps.parse.outputs.run_android }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -159,8 +160,59 @@ jobs:
name: dist-linux name: dist-linux
path: src-tauri/target/release/bundle/** 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: 构建 AndroidARM64
run: pnpm tauri android build --target aarch64-linux-android
- name: 构建 AndroidARMv7
run: pnpm tauri android build --target armv7-linux-androideabi
- name: 构建 Androidx86_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: 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 != '' }} if: ${{ always() && needs.parse.outputs.tag != '' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -183,3 +235,4 @@ jobs:
artifacts/**/*.deb artifacts/**/*.deb
artifacts/**/*.rpm artifacts/**/*.rpm
artifacts/**/*.AppImage artifacts/**/*.AppImage
artifacts/**/*.apk
+72
View File
@@ -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")
+104
View File
@@ -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`)
}
+11 -39
View File
@@ -52,7 +52,7 @@ function MainContent(): React.JSX.Element {
const [authVisible, setAuthVisible] = useState(false) const [authVisible, setAuthVisible] = useState(false)
const [authPassword, setAuthPassword] = useState("") const [authPassword, setAuthPassword] = useState("")
const [authLoading, setAuthLoading] = useState(false) const [authLoading, setAuthLoading] = useState(false)
const [isPortraitMode, setIsPortraitMode] = useState(defaultPortraitMode) const [isPortraitMode] = useState(defaultPortraitMode)
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode) const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false) const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
const [syncConflictVisible, setSyncConflictVisible] = useState(false) const [syncConflictVisible, setSyncConflictVisible] = useState(false)
@@ -149,7 +149,8 @@ function MainContent(): React.JSX.Element {
const res = await api.dbSyncApply(strategy) const res = await api.dbSyncApply(strategy)
if (res?.success && res?.data?.success) { if (res?.success && res?.data?.success) {
messageApi.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( window.dispatchEvent(
new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } }) new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } })
@@ -178,7 +179,11 @@ function MainContent(): React.JSX.Element {
try { try {
syncCheckingRef.current = true syncCheckingRef.current = true
const statusRes = await api.dbGetStatus() 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 return
} }
@@ -279,40 +284,6 @@ function MainContent(): React.JSX.Element {
if (key === "settings") navigate("/settings") 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 = () => { const toggleSidebar = () => {
if (isPortraitMode && sidebarCollapsed) { if (isPortraitMode && sidebarCollapsed) {
setFloatingSidebarExpanded((prev) => !prev) setFloatingSidebarExpanded((prev) => !prev)
@@ -357,7 +328,6 @@ function MainContent(): React.JSX.Element {
floatingExpand={isPortraitMode} floatingExpand={isPortraitMode}
floatingExpanded={floatingSidebarExpanded} floatingExpanded={floatingSidebarExpanded}
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
onToggleOrientation={toggleOrientationMode}
/> />
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} /> <OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
@@ -396,7 +366,9 @@ function MainContent(): React.JSX.Element {
maskClosable={false} maskClosable={false}
destroyOnClose destroyOnClose
> >
<div style={{ marginBottom: "10px", color: "var(--ss-text-secondary)", fontSize: "12px" }}> <div
style={{ marginBottom: "10px", color: "var(--ss-text-secondary)", fontSize: "12px" }}
>
</div> </div>
<div <div
+13 -18
View File
@@ -28,9 +28,7 @@ const SettlementHistory = lazy(() =>
const AutoScoreManager = lazy(() => const AutoScoreManager = lazy(() =>
loadAutoScoreManager().then((m) => ({ default: m.AutoScoreManager })) loadAutoScoreManager().then((m) => ({ default: m.AutoScoreManager }))
) )
const RewardSettings = lazy(() => const RewardSettings = lazy(() => loadRewardSettings().then((m) => ({ default: m.RewardSettings })))
loadRewardSettings().then((m) => ({ default: m.RewardSettings }))
)
const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager }))) const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager })))
const warmupRouteChunks = () => const warmupRouteChunks = () =>
@@ -60,7 +58,6 @@ interface ContentAreaProps {
floatingExpand: boolean floatingExpand: boolean
floatingExpanded: boolean floatingExpanded: boolean
onToggleSidebar: () => void onToggleSidebar: () => void
onToggleOrientation: () => void
} }
export function ContentArea({ export function ContentArea({
@@ -74,7 +71,6 @@ export function ContentArea({
floatingExpand, floatingExpand,
floatingExpanded, floatingExpanded,
onToggleSidebar, onToggleSidebar,
onToggleOrientation,
}: ContentAreaProps): React.JSX.Element { }: ContentAreaProps): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
@@ -153,13 +149,17 @@ export function ContentArea({
size="small" size="small"
onClick={onToggleSidebar} onClick={onToggleSidebar}
icon={ icon={
floatingExpand && sidebarCollapsed floatingExpand && sidebarCollapsed ? (
? floatingExpanded floatingExpanded ? (
? <MenuFoldOutlined /> <MenuFoldOutlined />
: <MenuUnfoldOutlined /> ) : (
: sidebarCollapsed <MenuUnfoldOutlined />
? <MenuUnfoldOutlined /> )
: <MenuFoldOutlined /> ) : sidebarCollapsed ? (
<MenuUnfoldOutlined />
) : (
<MenuFoldOutlined />
)
} }
title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"} title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"}
style={{ width: "32px", height: "32px" }} style={{ width: "32px", height: "32px" }}
@@ -199,12 +199,7 @@ export function ContentArea({
)} )}
</Space> </Space>
</div> </div>
{showWindowControls && ( {showWindowControls && <WindowControls />}
<WindowControls
isPortraitMode={isPortraitMode}
onToggleOrientation={onToggleOrientation}
/>
)}
</div> </div>
<Content style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}> <Content style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
+147 -97
View File
@@ -5,6 +5,7 @@ import { UploadOutlined } from "@ant-design/icons"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { TagEditorDialog } from "./TagEditorDialog" import { TagEditorDialog } from "./TagEditorDialog"
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar" import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
import { useResponsive, useIsMobile, useIsTablet } from "../hooks/useResponsive"
const createXlsxWorker = () => { const createXlsxWorker = () => {
return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), { 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<Worker | null>(null) const xlsxWorkerRef = useRef<Worker | null>(null)
const [form] = Form.useForm() const [form] = Form.useForm()
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const isMobile = useIsMobile()
const isTablet = useIsTablet()
const breakpoint = useResponsive()
useEffect(() => { useEffect(() => {
xlsxWorkerRef.current = createXlsxWorker() xlsxWorkerRef.current = createXlsxWorker()
@@ -470,107 +474,148 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
} }
const columns: ColumnsType<student> = [ const columns: ColumnsType<student> = useMemo(() => {
{ const baseColumns: ColumnsType<student> = [
title: t("students.avatar"), {
key: "avatar", title: t("students.avatar"),
width: 88, key: "avatar",
align: "center", width: isMobile ? 60 : 88,
render: (_, row) => align: "center",
row.avatarUrl ? ( render: (_, row) =>
<img row.avatarUrl ? (
src={row.avatarUrl} <img
alt={row.name} src={row.avatarUrl}
alt={row.name}
style={{
width: isMobile ? 24 : 32,
height: isMobile ? 24 : 32,
borderRadius: "50%",
objectFit: "cover",
border: "1px solid var(--ss-border-color)",
}}
/>
) : (
<div
style={{
width: isMobile ? 24 : 32,
height: isMobile ? 24 : 32,
borderRadius: "50%",
margin: "0 auto",
backgroundColor: "var(--ss-bg-color)",
border: "1px dashed var(--ss-border-color)",
color: "var(--ss-text-secondary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: isMobile ? 10 : 12,
}}
>
-
</div>
),
},
{
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) => (
<span
style={{ style={{
width: 32, fontWeight: "bold",
height: 32, color:
borderRadius: "50%", score > 0
objectFit: "cover", ? "var(--ant-color-success, #52c41a)"
border: "1px solid var(--ss-border-color)", : score < 0
}} ? "var(--ant-color-error, #ff4d4f)"
/> : "inherit",
) : ( fontSize: isMobile ? 12 : 14,
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
margin: "0 auto",
backgroundColor: "var(--ss-bg-color)",
border: "1px dashed var(--ss-border-color)",
color: "var(--ss-text-secondary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
}} }}
> >
- {score > 0 ? `+${score}` : score}
</div> </span>
), ),
}, },
{ title: t("students.name"), dataIndex: "name", key: "name", width: 100 }, {
{ title: t("students.tags"),
title: t("students.currentScore"), dataIndex: "tags",
dataIndex: "score", key: "tags",
key: "score", width: isMobile ? 120 : 200,
width: 160, render: (tags: string[] = []) => (
align: "center", <Space size={isMobile ? 2 : 4}>
render: (score: number) => ( {tags.length === 0 ? (
<span <span style={{ color: "var(--ss-text-secondary)", fontSize: isMobile ? 10 : 12 }}>
style={{ {t("students.noTags")}
fontWeight: "bold", </span>
color: ) : (
score > 0 tags.slice(0, isMobile ? 2 : 3).map((tag) => (
? "var(--ant-color-success, #52c41a)" <Tag key={tag} color="blue" style={{ fontSize: isMobile ? 10 : 12, margin: 0 }}>
: score < 0 {tag}
? "var(--ant-color-error, #ff4d4f)" </Tag>
: "inherit", ))
}} )}
> {tags.length > (isMobile ? 2 : 3) && (
{score > 0 ? `+${score}` : score} <Tag style={{ fontSize: isMobile ? 10 : 12, margin: 0 }}>
</span> +{tags.length - (isMobile ? 2 : 3)}
),
},
{
title: t("students.tags"),
dataIndex: "tags",
key: "tags",
width: 200,
render: (tags: string[] = []) => (
<Space>
{tags.length === 0 ? (
<span style={{ color: "var(--ss-text-secondary)" }}>{t("students.noTags")}</span>
) : (
tags.slice(0, 3).map((tag) => (
<Tag key={tag} color="blue">
{tag}
</Tag> </Tag>
)) )}
)} </Space>
{tags.length > 3 && <Tag>+{tags.length - 3}</Tag>} ),
</Space> },
), {
}, title: t("common.operation"),
{ key: "operation",
title: t("common.operation"), width: isMobile ? 150 : 220,
key: "operation", fixed: isMobile ? "right" : undefined,
width: 220, render: (_, row) => (
render: (_, row) => ( <Space size={isMobile ? 4 : 8} direction={isMobile ? "vertical" : "horizontal"}>
<Space> <Button
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}> type="link"
{t("students.editTags")} size={isMobile ? "small" : "middle"}
</Button> disabled={!canEdit}
<Button type="link" disabled={!canEdit} onClick={() => handleOpenAvatarEditor(row)}> onClick={() => handleOpenTagEditor(row)}
{t("students.editAvatar")} style={{ padding: isMobile ? "2px 4px" : undefined }}
</Button> >
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}> {t("students.editTags")}
{t("common.delete")} </Button>
</Button> <Button
</Space> type="link"
), size={isMobile ? "small" : "middle"}
}, disabled={!canEdit}
] onClick={() => handleOpenAvatarEditor(row)}
style={{ padding: isMobile ? "2px 4px" : undefined }}
>
{t("students.editAvatar")}
</Button>
<Button
type="link"
danger
size={isMobile ? "small" : "middle"}
disabled={!canEdit}
onClick={() => handleDelete(row.id)}
style={{ padding: isMobile ? "2px 4px" : undefined }}
>
{t("common.delete")}
</Button>
</Space>
),
},
]
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) 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")} placeholder={t("students.importTextPlaceholder")}
disabled={!canEdit || textImportLoading} disabled={!canEdit || textImportLoading}
/> />
<Button type="primary" loading={textImportLoading} disabled={!canEdit} onClick={handleImportTextNames}> <Button
type="primary"
loading={textImportLoading}
disabled={!canEdit}
onClick={handleImportTextNames}
>
{t("students.importByText")} {t("students.importByText")}
</Button> </Button>
<Button <Button
+1 -18
View File
@@ -4,19 +4,10 @@ import {
BorderOutlined, BorderOutlined,
CloseOutlined, CloseOutlined,
FullscreenExitOutlined, FullscreenExitOutlined,
RotateRightOutlined,
} from "@ant-design/icons" } from "@ant-design/icons"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
interface WindowControlsProps { export function WindowControls(): React.JSX.Element {
isPortraitMode: boolean
onToggleOrientation: () => void
}
export function WindowControls({
isPortraitMode,
onToggleOrientation,
}: WindowControlsProps): React.JSX.Element {
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => { useEffect(() => {
@@ -76,14 +67,6 @@ export function WindowControls({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<Button
type="text"
onClick={onToggleOrientation}
title={isPortraitMode ? "切换到横屏模式" : "切换到竖屏模式"}
style={{ width: "46px", height: "32px", borderRadius: 0 }}
>
<RotateRightOutlined />
</Button>
<Button <Button
type="text" type="text"
onClick={minimize} onClick={minimize}
+88
View File
@@ -0,0 +1,88 @@
import { useState, useEffect } from "react"
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"
export interface Breakpoints {
xs: number
sm: number
md: number
lg: number
xl: number
xxl: number
}
const defaultBreakpoints: Breakpoints = {
xs: 480,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1600,
}
export function useResponsive(customBreakpoints?: Partial<Breakpoints>): Breakpoint {
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg")
const breakpoints = { ...defaultBreakpoints, ...customBreakpoints }
useEffect(() => {
const updateBreakpoint = () => {
const width = window.innerWidth
if (width < breakpoints.xs) {
setBreakpoint("xs")
} else if (width < breakpoints.sm) {
setBreakpoint("sm")
} else if (width < breakpoints.md) {
setBreakpoint("md")
} else if (width < breakpoints.lg) {
setBreakpoint("lg")
} else if (width < breakpoints.xl) {
setBreakpoint("xl")
} else {
setBreakpoint("xxl")
}
}
updateBreakpoint()
window.addEventListener("resize", updateBreakpoint)
return () => window.removeEventListener("resize", updateBreakpoint)
}, [breakpoints])
return breakpoint
}
export function useScreenSize() {
const [screenSize, setScreenSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
})
useEffect(() => {
const handleResize = () => {
setScreenSize({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
return screenSize
}
export function useIsMobile() {
const breakpoint = useResponsive()
return breakpoint === "xs" || breakpoint === "sm"
}
export function useIsTablet() {
const breakpoint = useResponsive()
return breakpoint === "md"
}
export function useIsDesktop() {
const breakpoint = useResponsive()
return breakpoint === "lg" || breakpoint === "xl" || breakpoint === "xxl"
}