1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-08-12 17:40:32 +00:00

Compare commits

..

4 Commits

Author SHA1 Message Date
Sunwuyuan
b63f453aaf
修复名称问题 2026-06-28 15:03:38 +08:00
Sunwuyuan
2f8bc65bbb
update node 2026-06-28 14:28:31 +08:00
Sunwuyuan
664157587e
优化pwa 2026-06-28 14:26:30 +08:00
Sunwuyuan
c66a265935
PWA 2026-06-28 13:59:11 +08:00
12 changed files with 357 additions and 16 deletions

94
.github/workflows/store-pwa.yml vendored Normal file
View File

@ -0,0 +1,94 @@
name: PWA Store Build
on:
pull_request:
branches: ["main"]
paths:
- ".github/workflows/store-pwa.yml"
- "index.html"
- "package.json"
- "pnpm-lock.yaml"
- "public/**"
- "scripts/**"
- "src/**"
- "vite.config.mjs"
workflow_dispatch:
inputs:
production_url:
description: "Public HTTPS URL used for Microsoft Store/PWABuilder submission"
required: true
default: "https://cs.houlang.cloud/"
permissions:
contents: read
concurrency:
group: pwa-store-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-validate:
runs-on: ubuntu-latest
env:
VITE_APP_ID: d158067f53627d2b98babe8bffd2fd7d
VITE_DEFAULT_KV_SERVER: https://kv-service.houlang.cloud
VITE_DEFAULT_AUTH_SERVER: https://kv.houlang.cloud
STORE_PRODUCTION_URL: ${{ inputs.production_url || 'https://cs.houlang.cloud/' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: "24.x"
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build and validate PWA
run: pnpm run build:store
- name: Create store handoff metadata
run: |
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const url = process.env.STORE_PRODUCTION_URL;
const handoffDir = path.resolve('store-handoff');
fs.mkdirSync(handoffDir, { recursive: true });
fs.writeFileSync(
path.join(handoffDir, 'microsoft-store-pwa.json'),
JSON.stringify({
productionUrl: url,
manifestUrl: new URL('manifest.webmanifest', url).toString(),
packageSource: 'Use PWABuilder or Microsoft Partner Center with the production URL above.',
validationArtifact: 'dist/',
notes: [
'Serve the app over HTTPS before packaging.',
'Run this workflow after changing manifest, icons, service worker, or public assets.',
'Partner Center listing screenshots, age rating, privacy policy, and publisher identity are handled outside this repository.'
]
}, null, 2)
);
NODE
- name: Upload validated PWA dist
uses: actions/upload-artifact@v4
with:
name: classworks-pwa-dist
path: dist
if-no-files-found: error
- name: Upload Microsoft Store handoff
uses: actions/upload-artifact@v4
with:
name: microsoft-store-handoff
path: store-handoff
if-no-files-found: error

View File

@ -0,0 +1,23 @@
# Microsoft Store PWA 上架流程
## 仓库内自动化
1. 在 GitHub Actions 手动运行 `PWA Store Build`
2. 确认 `production_url` 是最终公开 HTTPS 地址,例如 `https://cs.houlang.cloud/`
3. workflow 会执行 `pnpm run build:store`,生成并校验 `dist/manifest.webmanifest``dist/sw.js`、图标、截图和 HTML PWA hint。
4. 下载 `classworks-pwa-dist``microsoft-store-handoff` artifacts作为 PWABuilder 或 Partner Center 提交前的构建记录。
## 提交到 Microsoft Store
1. 确保生产站点已部署最新构建,并可通过 HTTPS 访问。
2. 使用 PWABuilder 或 Microsoft Partner Center 指向生产 URL。
3. 填写商店资料:应用分类、年龄分级、隐私政策、支持链接、截图和发布者信息。
4. 如果 manifest、图标、service worker 或公开资源有变更,重新运行 `PWA Store Build`
## 本地校验
```bash
pnpm run build:store
```
该命令会先刷新音频资源清单,再构建 Vite 应用,最后执行 PWA 构建校验。

BIN
images/1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
images/2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@ -11,6 +11,7 @@
<meta name="author" content="Sunwuyuan" /> <meta name="author" content="Sunwuyuan" />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<link rel="canonical" href="https://cs.houlang.cloud/" /> <link rel="canonical" href="https://cs.houlang.cloud/" />
<link rel="manifest" href="./manifest.webmanifest" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Classworks 作业板" /> <meta property="og:title" content="Classworks 作业板" />
@ -27,9 +28,17 @@
<meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" /> <meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
<meta name="twitter:image" content="https://cs.houlang.cloud/banner.png" /> <meta name="twitter:image" content="https://cs.houlang.cloud/banner.png" />
<link rel="apple-touch-icon" href="/image/apple-touch-icon.png" sizes="180x180" /> <link rel="apple-touch-icon" href="./pwa/image/apple-touch-icon-180x180.png" sizes="180x180" />
<link rel="mask-icon" href="/image/mask-icon.svg" color="#212121" /> <link rel="mask-icon" href="./pwa/image/logo.svg" color="#212121" />
<meta name="theme-color" content="#212121" /> <meta name="theme-color" content="#212121" />
<meta name="application-name" content="Classworks" />
<meta name="apple-mobile-web-app-title" content="Classworks" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-TileColor" content="#212121" />
<meta name="msapplication-TileImage" content="./pwa/image/pwa-192x192.png" />
<meta name="msapplication-starturl" content="./" />
</head> </head>
<body> <body>

View File

@ -6,8 +6,10 @@
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "vite build", "build": "vite build",
"build:store": "node scripts/generate-sound-list.js && vite build && node scripts/validate-pwa-build.js",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"pwa:validate": "node scripts/validate-pwa-build.js",
"prebuild": "node scripts/generate-sound-list.js" "prebuild": "node scripts/generate-sound-list.js"
}, },
"dependencies": { "dependencies": {

BIN
public/images/1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
public/images/2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@ -0,0 +1,134 @@
import fs from 'node:fs';
import path from 'node:path';
const distDir = path.resolve('dist');
const manifestPath = path.join(distDir, 'manifest.webmanifest');
const indexPath = path.join(distDir, 'index.html');
const serviceWorkerPath = path.join(distDir, 'sw.js');
const errors = [];
function fail(message) {
errors.push(message);
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
fail(`无法读取或解析 ${path.relative(process.cwd(), filePath)}: ${error.message}`);
return null;
}
}
function fileExistsFromManifest(src) {
const normalized = src.replace(/^\.?\//, '').split('?')[0];
return fs.existsSync(path.join(distDir, normalized));
}
if (!fs.existsSync(distDir)) {
fail('dist 目录不存在,请先运行构建。');
}
const manifest = fs.existsSync(manifestPath) ? readJson(manifestPath) : null;
if (!manifest) {
fail('dist/manifest.webmanifest 不存在。');
} else {
for (const field of ['id', 'name', 'short_name', 'description', 'start_url', 'scope', 'display', 'theme_color', 'background_color']) {
if (!manifest[field]) {
fail(`manifest 缺少 ${field}`);
}
}
if (manifest.id !== '7C24F2B3.ClassworksPWA') {
fail('manifest.id 必须是 7C24F2B3.ClassworksPWA。');
}
if (!['standalone', 'fullscreen', 'minimal-ui'].includes(manifest.display)) {
fail('manifest.display 应为 standalone、fullscreen 或 minimal-ui。');
}
const icons = Array.isArray(manifest.icons) ? manifest.icons : [];
const has192 = icons.some((icon) => icon.sizes?.includes('192x192') && fileExistsFromManifest(icon.src));
const has512 = icons.some((icon) => icon.sizes?.includes('512x512') && fileExistsFromManifest(icon.src));
const hasMaskable = icons.some((icon) => icon.sizes?.includes('512x512') && icon.purpose?.includes('maskable') && fileExistsFromManifest(icon.src));
if (!has192) fail('manifest 缺少可访问的 192x192 图标。');
if (!has512) fail('manifest 缺少可访问的 512x512 图标。');
if (!hasMaskable) fail('manifest 缺少可访问的 512x512 maskable 图标。');
if (!Array.isArray(manifest.categories) || manifest.categories.length === 0) {
fail('manifest 缺少 categoriesMicrosoft Store/PWABuilder 会降低质量评分。');
}
const screenshots = Array.isArray(manifest.screenshots) ? manifest.screenshots : [];
if (screenshots.length === 0) {
fail('manifest 缺少 screenshots。');
}
for (const screenshot of screenshots) {
if (!screenshot.src || !screenshot.sizes || !screenshot.type) {
fail(`截图条目不完整: ${JSON.stringify(screenshot)}`);
} else if (!fileExistsFromManifest(screenshot.src)) {
fail(`截图文件不存在: ${screenshot.src}`);
}
}
const fileHandlers = Array.isArray(manifest.file_handlers) ? manifest.file_handlers : [];
const fileExtensions = new Set(
fileHandlers.flatMap((handler) => Object.values(handler.accept || {}).flat())
);
if (!fileExtensions.has('.csb') || !fileExtensions.has('.csi')) {
fail('manifest.file_handlers 必须允许 .csb 和 .csi。');
}
const protocolHandlers = Array.isArray(manifest.protocol_handlers) ? manifest.protocol_handlers : [];
if (!protocolHandlers.some((handler) => handler.protocol === 'cs' && handler.url?.includes('%s'))) {
fail('manifest.protocol_handlers 必须声明 cs:// 协议处理。');
}
if (manifest.edge_side_panel) {
fail('manifest 不应声明 edge_side_panel。');
}
}
if (!fs.existsSync(indexPath)) {
fail('dist/index.html 不存在。');
} else {
const html = fs.readFileSync(indexPath, 'utf8');
if (!html.includes('rel="manifest"')) {
fail('index.html 缺少 manifest 链接。');
}
if (!html.includes('apple-touch-icon')) {
fail('index.html 缺少 apple-touch-icon。');
}
if (!html.includes('msapplication-TileImage')) {
fail('index.html 缺少 Windows tile 图标声明。');
}
}
if (!fs.existsSync(serviceWorkerPath)) {
fail('dist/sw.js 不存在PWA 离线能力未生成。');
} else {
const sw = fs.readFileSync(serviceWorkerPath, 'utf8');
if (!sw.includes('sw-cache-manager.js')) {
fail('sw.js 未导入 sw-cache-manager.js。');
}
if (!sw.includes('index.html')) {
fail('sw.js 未包含导航回退,离线打开应用可能失败。');
}
}
if (!fs.existsSync(path.join(distDir, 'sw-cache-manager.js'))) {
fail('dist/sw-cache-manager.js 不存在。');
}
if (errors.length > 0) {
console.error('PWA 构建校验失败:');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
console.log('PWA 构建校验通过。');

View File

@ -72,6 +72,17 @@ const bgOverlayStyle = computed(() => ({
let unwatchSettings = null; let unwatchSettings = null;
const onBeforeInstallPrompt = (e) => {
e.preventDefault();
window.deferredPwaPrompt = e;
window.dispatchEvent(new Event('pwa-prompt-ready'));
};
const onAppInstalled = () => {
window.deferredPwaPrompt = null;
window.dispatchEvent(new Event('pwa-installed'));
};
onMounted(() => { onMounted(() => {
// //
const savedTheme = getSetting("theme.mode"); const savedTheme = getSetting("theme.mode");
@ -88,15 +99,14 @@ onMounted(() => {
} }
}); });
window.addEventListener('beforeinstallprompt', (e) => { window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
e.preventDefault(); window.addEventListener('appinstalled', onAppInstalled);
window.deferredPwaPrompt = e;
window.dispatchEvent(new Event('pwa-prompt-ready'));
});
}); });
onUnmounted(() => { onUnmounted(() => {
if (unwatchSettings) unwatchSettings(); if (unwatchSettings) unwatchSettings();
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
window.removeEventListener('appinstalled', onAppInstalled);
}); });
</script> </script>
<style> <style>

View File

@ -361,15 +361,22 @@ const onDisplayModeChange = () => {
refreshStates(); refreshStates();
}; };
const onPwaInstalled = () => {
permissionStates.pwa.status = "granted";
refreshStates();
};
onMounted(() => { onMounted(() => {
refreshStates(); refreshStates();
window.addEventListener('pwa-prompt-ready', onPromptReady); window.addEventListener('pwa-prompt-ready', onPromptReady);
window.addEventListener('pwa-installed', onPwaInstalled);
displayModeMedia = window.matchMedia('(display-mode: standalone)'); displayModeMedia = window.matchMedia('(display-mode: standalone)');
displayModeMedia.addEventListener('change', onDisplayModeChange); displayModeMedia.addEventListener('change', onDisplayModeChange);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('pwa-prompt-ready', onPromptReady); window.removeEventListener('pwa-prompt-ready', onPromptReady);
window.removeEventListener('pwa-installed', onPwaInstalled);
if (displayModeMedia) { if (displayModeMedia) {
displayModeMedia.removeEventListener('change', onDisplayModeChange); displayModeMedia.removeEventListener('change', onDisplayModeChange);
} }

View File

@ -38,12 +38,18 @@ export default defineConfig({
workbox: { workbox: {
globPatterns: ['*'], globPatterns: [
'**/*.{js,css,html,ico,png,svg,webmanifest,txt,json,woff2,ttf,mp3}',
],
navigateFallback: 'index.html', navigateFallback: 'index.html',
navigateFallbackDenylist: [
/^\/api\//,
/^\/socket\.io\//,
],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: ({ url, sameOrigin }) => { urlPattern: ({ url, sameOrigin }) => {
return sameOrigin && url.pathname.endsWith('/assets/'); return sameOrigin && url.pathname.startsWith('/assets/');
}, },
handler: 'CacheFirst', handler: 'CacheFirst',
options: { options: {
@ -57,6 +63,22 @@ export default defineConfig({
} }
} }
}, },
{
urlPattern: ({ url, sameOrigin }) => {
return sameOrigin && url.pathname.startsWith('/sounds/');
},
handler: 'CacheFirst',
options: {
cacheName: 'sound-cache',
expiration: {
maxEntries: 80,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 天
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{ {
urlPattern: ({ url, sameOrigin }) => { urlPattern: ({ url, sameOrigin }) => {
return sameOrigin && url.pathname.startsWith('/pwa/'); return sameOrigin && url.pathname.startsWith('/pwa/');
@ -79,7 +101,7 @@ export default defineConfig({
if (!sameOrigin) return false; if (!sameOrigin) return false;
const path = url.pathname; const path = url.pathname;
// 排除已经由其他规则处理的路径 // 排除已经由其他规则处理的路径
return !(path.includes('/assets/') || path.includes('/pwa/')); return !(path.includes('/assets/') || path.includes('/pwa/') || path.includes('/sounds/'));
}, },
handler: 'NetworkFirst', handler: 'NetworkFirst',
options: { options: {
@ -98,19 +120,59 @@ export default defineConfig({
additionalManifestEntries: [], additionalManifestEntries: [],
clientsClaim: true, clientsClaim: true,
skipWaiting: true, skipWaiting: true,
importScripts: ['/sw-cache-manager.js'] importScripts: ['sw-cache-manager.js']
}, },
manifest: { manifest: {
name: 'Classworks作业板', id: '7C24F2B3.ClassworksPWA',
short_name: 'Classworks', name: 'Classworks PWA',
description: '记录,查看并同步作业', short_name: 'Classworks PWA',
description: '适用于班级大屏的作业板小工具,支持记录、查看并同步作业。',
theme_color: '#212121', theme_color: '#212121',
background_color: '#212121', background_color: '#212121',
lang: 'zh-CN',
dir: 'ltr',
display: 'standalone', display: 'standalone',
display_override: ['window-controls-overlay', 'standalone', 'minimal-ui', 'fullscreen'],
start_url: './', start_url: './',
edge_side_panel: { scope: './',
default_path: './', orientation: 'any',
categories: ['education', 'productivity', 'utilities'],
prefer_related_applications: false,
launch_handler: {
client_mode: 'navigate-existing',
}, },
screenshots: [
{
src: './images/1.jpeg',
sizes: '1901x1080',
type: 'image/jpeg',
form_factor: 'wide',
label: 'Classworks 作业板主界面',
},
{
src: './images/2.jpeg',
sizes: '1901x1080',
type: 'image/jpeg',
form_factor: 'wide',
label: 'Classworks 设置与管理界面',
},
],
file_handlers: [
{
action: './?file-handler=true',
accept: {
'application/octet-stream': ['.csb', '.csi'],
'application/x-classworks-backup': ['.csb'],
'application/x-classworks-install': ['.csi'],
},
},
],
protocol_handlers: [
{
protocol: 'cs',
url: './?protocol=%s',
},
],
icons: [ icons: [
{ {
src: './pwa/image/pwa-64x64.png', src: './pwa/image/pwa-64x64.png',