mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-08-12 09:20:31 +00:00
Compare commits
4 Commits
28a3e878b7
...
b63f453aaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b63f453aaf | ||
|
|
2f8bc65bbb | ||
|
|
664157587e | ||
|
|
c66a265935 |
94
.github/workflows/store-pwa.yml
vendored
Normal file
94
.github/workflows/store-pwa.yml
vendored
Normal 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
|
||||
23
docs/microsoft-store-pwa.md
Normal file
23
docs/microsoft-store-pwa.md
Normal 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
BIN
images/1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
BIN
images/2.jpeg
Normal file
BIN
images/2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
13
index.html
13
index.html
@ -11,6 +11,7 @@
|
||||
<meta name="author" content="Sunwuyuan" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://cs.houlang.cloud/" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Classworks 作业板" />
|
||||
@ -27,9 +28,17 @@
|
||||
<meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
|
||||
<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="mask-icon" href="/image/mask-icon.svg" color="#212121" />
|
||||
<link rel="apple-touch-icon" href="./pwa/image/apple-touch-icon-180x180.png" sizes="180x180" />
|
||||
<link rel="mask-icon" href="./pwa/image/logo.svg" color="#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>
|
||||
<body>
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"build:store": "node scripts/generate-sound-list.js && vite build && node scripts/validate-pwa-build.js",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix",
|
||||
"pwa:validate": "node scripts/validate-pwa-build.js",
|
||||
"prebuild": "node scripts/generate-sound-list.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
BIN
public/images/1.jpeg
Normal file
BIN
public/images/1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
BIN
public/images/2.jpeg
Normal file
BIN
public/images/2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
134
scripts/validate-pwa-build.js
Normal file
134
scripts/validate-pwa-build.js
Normal 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 缺少 categories,Microsoft 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 构建校验通过。');
|
||||
20
src/App.vue
20
src/App.vue
@ -72,6 +72,17 @@ const bgOverlayStyle = computed(() => ({
|
||||
|
||||
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(() => {
|
||||
// 应用保存的主题设置
|
||||
const savedTheme = getSetting("theme.mode");
|
||||
@ -88,15 +99,14 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
window.deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new Event('pwa-prompt-ready'));
|
||||
});
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', onAppInstalled);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unwatchSettings) unwatchSettings();
|
||||
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', onAppInstalled);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@ -361,15 +361,22 @@ const onDisplayModeChange = () => {
|
||||
refreshStates();
|
||||
};
|
||||
|
||||
const onPwaInstalled = () => {
|
||||
permissionStates.pwa.status = "granted";
|
||||
refreshStates();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshStates();
|
||||
window.addEventListener('pwa-prompt-ready', onPromptReady);
|
||||
window.addEventListener('pwa-installed', onPwaInstalled);
|
||||
displayModeMedia = window.matchMedia('(display-mode: standalone)');
|
||||
displayModeMedia.addEventListener('change', onDisplayModeChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pwa-prompt-ready', onPromptReady);
|
||||
window.removeEventListener('pwa-installed', onPwaInstalled);
|
||||
if (displayModeMedia) {
|
||||
displayModeMedia.removeEventListener('change', onDisplayModeChange);
|
||||
}
|
||||
|
||||
@ -38,12 +38,18 @@ export default defineConfig({
|
||||
|
||||
|
||||
workbox: {
|
||||
globPatterns: ['*'],
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,ico,png,svg,webmanifest,txt,json,woff2,ttf,mp3}',
|
||||
],
|
||||
navigateFallback: 'index.html',
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api\//,
|
||||
/^\/socket\.io\//,
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url, sameOrigin }) => {
|
||||
return sameOrigin && url.pathname.endsWith('/assets/');
|
||||
return sameOrigin && url.pathname.startsWith('/assets/');
|
||||
},
|
||||
handler: 'CacheFirst',
|
||||
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 }) => {
|
||||
return sameOrigin && url.pathname.startsWith('/pwa/');
|
||||
@ -79,7 +101,7 @@ export default defineConfig({
|
||||
if (!sameOrigin) return false;
|
||||
const path = url.pathname;
|
||||
// 排除已经由其他规则处理的路径
|
||||
return !(path.includes('/assets/') || path.includes('/pwa/'));
|
||||
return !(path.includes('/assets/') || path.includes('/pwa/') || path.includes('/sounds/'));
|
||||
},
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
@ -98,19 +120,59 @@ export default defineConfig({
|
||||
additionalManifestEntries: [],
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
importScripts: ['/sw-cache-manager.js']
|
||||
importScripts: ['sw-cache-manager.js']
|
||||
},
|
||||
manifest: {
|
||||
name: 'Classworks作业板',
|
||||
short_name: 'Classworks',
|
||||
description: '记录,查看并同步作业',
|
||||
id: '7C24F2B3.ClassworksPWA',
|
||||
name: 'Classworks PWA',
|
||||
short_name: 'Classworks PWA',
|
||||
description: '适用于班级大屏的作业板小工具,支持记录、查看并同步作业。',
|
||||
theme_color: '#212121',
|
||||
background_color: '#212121',
|
||||
lang: 'zh-CN',
|
||||
dir: 'ltr',
|
||||
display: 'standalone',
|
||||
display_override: ['window-controls-overlay', 'standalone', 'minimal-ui', 'fullscreen'],
|
||||
start_url: './',
|
||||
edge_side_panel: {
|
||||
default_path: './',
|
||||
scope: './',
|
||||
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: [
|
||||
{
|
||||
src: './pwa/image/pwa-64x64.png',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user