mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
添加 Android 打包工作流并实现响应式设计
- 在 GitHub Actions 中添加 Android 打包工作流,支持 ARM64、ARMv7 和 x86_64 架构 - 创建 CI 脚本用于解析提交信息和应用版本号 - 移除标题栏的旋转屏幕按钮,简化窗口控制 - 创建响应式设计 hooks (useResponsive) 用于检测屏幕尺寸 - 更新 StudentManager 组件以支持移动端响应式布局 - 在移动设备上自动调整表格列宽、按钮大小和字体大小 - 移动端隐藏标签列以节省空间 - 按钮在移动端使用垂直布局和更小的尺寸
This commit is contained in:
@@ -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: 构建 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:
|
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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user