mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-02-04 16:03:10 +00:00
feat: 更新KV服务器地址,添加PWA安装卡片及权限请求逻辑
This commit is contained in:
parent
4244f84b34
commit
f6b8d76906
@ -1,5 +1,5 @@
|
||||
# Classworks KV 默认服务器域名
|
||||
VITE_DEFAULT_KV_SERVER=https://kv.wuyuan.dev
|
||||
VITE_DEFAULT_KV_SERVER=https://kv-service.houlang.cloud
|
||||
|
||||
# Classworks KV 授权服务器域名
|
||||
VITE_DEFAULT_AUTH_SERVER=https://kv.houlang.cloud
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
- name: Build
|
||||
env:
|
||||
VITE_APP_ID: d158067f53627d2b98babe8bffd2fd7d
|
||||
VITE_DEFAULT_KV_SERVER: https://kv.wuyuan.dev
|
||||
VITE_DEFAULT_KV_SERVER: https://kv-service.houlang.cloud
|
||||
VITE_DEFAULT_AUTH_SERVER: https://kv.houlang.cloud
|
||||
run: |
|
||||
npm install
|
||||
|
||||
@ -23,6 +23,12 @@ onMounted(() => {
|
||||
// 应用保存的主题设置
|
||||
const savedTheme = getSetting("theme.mode");
|
||||
theme.global.name.value = savedTheme;
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
window.deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new Event('pwa-prompt-ready'));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
|
||||
350
src/components/PwaInstallCard.vue
Normal file
350
src/components/PwaInstallCard.vue
Normal file
@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<v-card
|
||||
v-if="showCard"
|
||||
class="mb-4"
|
||||
color="surface-variant"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="d-flex flex-no-wrap justify-space-between">
|
||||
<div class="pe-4">
|
||||
<v-card-title class="text-h6">
|
||||
安装应用与授权
|
||||
</v-card-title>
|
||||
|
||||
<v-card-subtitle class="pb-1">
|
||||
手动点选下方项目请求安装和权限,也可以直接关闭
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-card-text class="pt-0 pb-1">
|
||||
<v-list density="comfortable" lines="two">
|
||||
<v-list-item
|
||||
v-for="item in chipList"
|
||||
:key="item.key"
|
||||
:disabled="isRequesting"
|
||||
@click="() => handleSingleRequest(item.key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar :color="chipColors[item.status]" size="32" variant="tonal">
|
||||
<v-icon :icon="statusIcons[item.status]"></v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title>{{ item.label }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-chip :color="chipColors[item.status]" size="small" variant="tonal" class="me-2">
|
||||
{{ statusText[item.status] }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
variant="text"
|
||||
icon="mdi-information"
|
||||
size="small"
|
||||
:disabled="isRequesting"
|
||||
@click.stop="() => openHelp(item.key)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
class="ms-2"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="dismiss"
|
||||
>
|
||||
关闭
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ms-2"
|
||||
variant="elevated"
|
||||
color="primary"
|
||||
size="small"
|
||||
:prepend-icon="isRequesting ? 'mdi-timer-sand' : 'mdi-shield-check'"
|
||||
:disabled="!hasPendingRequests || isRequesting"
|
||||
@click="handleRequest"
|
||||
>
|
||||
{{ isRequesting ? "处理中" : "一次处理全部" }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
|
||||
<v-avatar
|
||||
class="ma-3"
|
||||
size="100"
|
||||
rounded="0"
|
||||
>
|
||||
<v-icon icon="mdi-monitor-cellphone" size="80"></v-icon>
|
||||
</v-avatar>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="helpDialog" max-width="520">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">{{ helpContent.title }}</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3">{{ helpContent.message }}</p>
|
||||
<v-list density="comfortable">
|
||||
<v-list-item
|
||||
v-for="(link, index) in helpContent.links"
|
||||
:key="index"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<v-list-item-title>{{ link.text }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ link.desc }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="helpDialog = false">我知道了</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { getSetting, setSetting, requestNotificationPermission, requestPersistentStorage } from "@/utils/settings";
|
||||
|
||||
const showCard = ref(false);
|
||||
const isRequesting = ref(false);
|
||||
const helpDialog = ref(false);
|
||||
const helpContent = reactive({
|
||||
title: "",
|
||||
message: "",
|
||||
links: [],
|
||||
});
|
||||
|
||||
const statusText = {
|
||||
pending: "待授权",
|
||||
granted: "已完成",
|
||||
denied: "已拒绝",
|
||||
unavailable: "不可用",
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
pending: "mdi-progress-clock",
|
||||
granted: "mdi-check-circle",
|
||||
denied: "mdi-close-circle",
|
||||
unavailable: "mdi-help-circle",
|
||||
};
|
||||
|
||||
const chipColors = {
|
||||
pending: "primary",
|
||||
granted: "success",
|
||||
denied: "error",
|
||||
unavailable: "surface-variant",
|
||||
};
|
||||
|
||||
const permissionStates = reactive({
|
||||
pwa: { label: "安装应用", description: "将网站安装为独立应用,便于快速启动", status: "pending" },
|
||||
notification: { label: "通知权限", description: "允许接收作业、考试等通知提醒", status: "pending" },
|
||||
storage: { label: "离线存储", description: "启用持久化存储以获得更稳健的离线体验", status: "pending" },
|
||||
});
|
||||
|
||||
const chipList = computed(() => [
|
||||
{ key: "pwa", ...permissionStates.pwa },
|
||||
{ key: "notification", ...permissionStates.notification },
|
||||
{ key: "storage", ...permissionStates.storage },
|
||||
]);
|
||||
|
||||
const hasPendingRequests = computed(() => chipList.value.some((item) => item.status === "pending"));
|
||||
|
||||
const helpLinks = {
|
||||
pwa: [
|
||||
{
|
||||
text: "MDN - 安装 PWA 指南",
|
||||
desc: "检查浏览器是否支持并手动触发安装",
|
||||
href: "https://developer.mozilla.org/zh-CN/docs/Web/Progressive_web_apps/Guides/Installing",
|
||||
},
|
||||
{
|
||||
text: "Microsoft Edge - PWA 体验",
|
||||
desc: "Edge 浏览器安装与 UX 说明",
|
||||
href: "https://learn.microsoft.com/zh-cn/microsoft-edge/progressive-web-apps/ux",
|
||||
},
|
||||
],
|
||||
notification: [
|
||||
{
|
||||
text: "MDN - 通知权限与用法",
|
||||
desc: "浏览器通知权限的工作方式与调试",
|
||||
href: "https://developer.mozilla.org/zh-CN/docs/Web/API/notification",
|
||||
},
|
||||
],
|
||||
storage: [
|
||||
{
|
||||
text: "MDN - Storage 持久化说明",
|
||||
desc: "了解持久化存储的可用性与申请方式",
|
||||
href: "https://developer.mozilla.org/zh-CN/docs/Web/API/StorageManager/persist",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let displayModeMedia;
|
||||
|
||||
const refreshStates = async () => {
|
||||
const hideCard = getSetting("pwa.hideInstallCard");
|
||||
if (hideCard) {
|
||||
showCard.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true;
|
||||
|
||||
if (isStandalone) {
|
||||
permissionStates.pwa.status = "granted";
|
||||
} else if (window.deferredPwaPrompt) {
|
||||
permissionStates.pwa.status = "pending";
|
||||
} else {
|
||||
// 仍标记为待处理,允许点击后给出指导
|
||||
permissionStates.pwa.status = "pending";
|
||||
}
|
||||
|
||||
if (typeof Notification === "undefined") {
|
||||
permissionStates.notification.status = "unavailable";
|
||||
} else {
|
||||
const current = Notification.permission;
|
||||
permissionStates.notification.status = current === "granted"
|
||||
? "granted"
|
||||
: current === "denied"
|
||||
? "denied"
|
||||
: "pending";
|
||||
}
|
||||
|
||||
if (navigator.storage?.persisted) {
|
||||
const persisted = await navigator.storage.persisted();
|
||||
permissionStates.storage.status = persisted ? "granted" : "pending";
|
||||
} else {
|
||||
permissionStates.storage.status = "unavailable";
|
||||
}
|
||||
|
||||
const stillNeedAction = chipList.value.some((item) => item.status !== "granted");
|
||||
showCard.value = stillNeedAction;
|
||||
};
|
||||
|
||||
const requestPwaInstall = async () => {
|
||||
const promptEvent = window.deferredPwaPrompt;
|
||||
if (!promptEvent) {
|
||||
permissionStates.pwa.status = "pending";
|
||||
openHelp("pwa", "浏览器没有提供安装提示,可按文档手动安装。");
|
||||
return;
|
||||
}
|
||||
|
||||
promptEvent.prompt();
|
||||
const { outcome } = await promptEvent.userChoice;
|
||||
|
||||
permissionStates.pwa.status = outcome === "accepted" ? "granted" : "denied";
|
||||
if (outcome !== "accepted") {
|
||||
openHelp("pwa", "如果未出现安装弹窗,或被拒绝,请按说明手动安装。");
|
||||
}
|
||||
window.deferredPwaPrompt = null;
|
||||
};
|
||||
|
||||
const requestNotification = async () => {
|
||||
if (typeof Notification === "undefined") {
|
||||
permissionStates.notification.status = "unavailable";
|
||||
openHelp("notification", "当前环境不支持通知 API,可查看说明手动开启或更换浏览器。");
|
||||
return;
|
||||
}
|
||||
|
||||
const granted = await requestNotificationPermission();
|
||||
permissionStates.notification.status = granted ? "granted" : "denied";
|
||||
if (!granted) {
|
||||
openHelp("notification", "通知请求未被授予,请按说明检查浏览器或系统设置。");
|
||||
}
|
||||
};
|
||||
|
||||
const requestStorage = async () => {
|
||||
if (!navigator.storage?.persist) {
|
||||
permissionStates.storage.status = "unavailable";
|
||||
openHelp("storage", "当前浏览器不支持持久化存储,可查看说明或更换浏览器。");
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = await requestPersistentStorage();
|
||||
permissionStates.storage.status = persisted ? "granted" : "denied";
|
||||
if (!persisted) {
|
||||
openHelp("storage", "未能启用持久化存储,可按说明检查浏览器或系统设置。");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequest = async () => {
|
||||
if (!hasPendingRequests.value || isRequesting.value) return;
|
||||
isRequesting.value = true;
|
||||
|
||||
try {
|
||||
await requestPwaInstall();
|
||||
await requestNotification();
|
||||
await requestStorage();
|
||||
} finally {
|
||||
isRequesting.value = false;
|
||||
await refreshStates();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleRequest = async (key) => {
|
||||
if (isRequesting.value) return;
|
||||
isRequesting.value = true;
|
||||
try {
|
||||
if (key === "pwa") {
|
||||
await requestPwaInstall();
|
||||
} else if (key === "notification") {
|
||||
await requestNotification();
|
||||
} else if (key === "storage") {
|
||||
await requestStorage();
|
||||
}
|
||||
} finally {
|
||||
isRequesting.value = false;
|
||||
await refreshStates();
|
||||
}
|
||||
};
|
||||
|
||||
const openHelp = (key, message = "") => {
|
||||
if (key === "pwa") {
|
||||
helpContent.title = "如何安装为应用";
|
||||
} else if (key === "notification") {
|
||||
helpContent.title = "如何开启通知";
|
||||
} else {
|
||||
helpContent.title = "如何启用离线存储";
|
||||
}
|
||||
|
||||
helpContent.message = message || "查看以下步骤获取更多说明。";
|
||||
helpContent.links = helpLinks[key] || [];
|
||||
helpDialog.value = true;
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
setSetting("pwa.hideInstallCard", true);
|
||||
showCard.value = false;
|
||||
};
|
||||
|
||||
const onPromptReady = () => {
|
||||
if (permissionStates.pwa.status !== "granted") {
|
||||
permissionStates.pwa.status = "pending";
|
||||
}
|
||||
refreshStates();
|
||||
};
|
||||
|
||||
const onDisplayModeChange = () => {
|
||||
refreshStates();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshStates();
|
||||
window.addEventListener('pwa-prompt-ready', onPromptReady);
|
||||
displayModeMedia = window.matchMedia('(display-mode: standalone)');
|
||||
displayModeMedia.addEventListener('change', onDisplayModeChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pwa-prompt-ready', onPromptReady);
|
||||
if (displayModeMedia) {
|
||||
displayModeMedia.removeEventListener('change', onDisplayModeChange);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -169,6 +169,8 @@
|
||||
@add-exam-card="showAddExamDialog = true"
|
||||
/>
|
||||
|
||||
<pwa-install-card />
|
||||
|
||||
<!-- 推荐添加考试提示 -->
|
||||
<v-alert
|
||||
v-if="upcomingExams.length > 0 && !hasExamCard"
|
||||
@ -441,6 +443,7 @@ import AttendanceSidebar from "@/components/attendance/AttendanceSidebar.vue";
|
||||
import AttendanceManagementDialog from "@/components/attendance/AttendanceManagementDialog.vue";
|
||||
import HomeworkGrid from "@/components/home/HomeworkGrid.vue";
|
||||
import HomeActions from "@/components/home/HomeActions.vue";
|
||||
import PwaInstallCard from "@/components/PwaInstallCard.vue";
|
||||
import ExamScheduleCard from "@/components/home/ExamScheduleCard.vue";
|
||||
import ExamConfigEditor from "@/components/ExamConfigEditor.vue";
|
||||
import HitokotoCard from "@/components/HitokotoCard.vue";
|
||||
@ -485,6 +488,7 @@ export default {
|
||||
AttendanceManagementDialog,
|
||||
HomeworkGrid,
|
||||
HomeActions,
|
||||
PwaInstallCard,
|
||||
ExamScheduleCard,
|
||||
ExamConfigEditor,
|
||||
},
|
||||
|
||||
@ -5,4 +5,3 @@ export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
//
|
||||
}),
|
||||
})
|
||||
|
||||
@ -143,7 +143,7 @@ export default {
|
||||
* // 成功时返回:
|
||||
* {
|
||||
* success: true,
|
||||
* url: "https://kv.wuyuan.dev/device-uuid-123/exam_configs?token=abc123", // 私有访问时包含token
|
||||
* url: "https://kv-service.houlang.cloud/device-uuid-123/exam_configs?token=abc123", // 私有访问时包含token
|
||||
* migrated: true, // 是否成功迁移了本地数据
|
||||
* configured: false // 是否自动配置了云端设置
|
||||
* }
|
||||
@ -151,7 +151,7 @@ export default {
|
||||
* // 公开访问时返回:
|
||||
* {
|
||||
* success: true,
|
||||
* url: "https://kv.wuyuan.dev/device-uuid-123/exam_configs", // 公开访问不包含token
|
||||
* url: "https://kv-service.houlang.cloud/device-uuid-123/exam_configs", // 公开访问不包含token
|
||||
* migrated: false,
|
||||
* configured: true
|
||||
* }
|
||||
@ -183,7 +183,7 @@ export default {
|
||||
if (autoConfigureCloud) {
|
||||
// 使用classworksCloudDefaults配置
|
||||
const classworksCloudDefaults = {
|
||||
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "https://kv.wuyuan.dev",
|
||||
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "https://kv-service.houlang.cloud",
|
||||
"server.siteKey": "",
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// 请求通知权限
|
||||
async function requestNotificationPermission() {
|
||||
if (Notification && Notification.requestPermission) {
|
||||
if (typeof Notification !== "undefined" && Notification.requestPermission) {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
console.log("通知权限已授予");
|
||||
@ -45,10 +45,7 @@ async function initializeStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 在页面加载时初始化
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", initializeStorage);
|
||||
}
|
||||
// 初始化将由显式触发方调用,避免页面加载时立即请求权限
|
||||
|
||||
/**
|
||||
* 配置项定义
|
||||
@ -68,7 +65,7 @@ const SETTINGS_STORAGE_KEY = "Classworks_settings";
|
||||
|
||||
// 新增: Classworks云端存储的默认设置
|
||||
const classworksCloudDefaults = {
|
||||
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "https://kv.wuyuan.dev",
|
||||
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "https://kv-service.houlang.cloud",
|
||||
//"server.domain": "http://localhost:3030",
|
||||
"server.siteKey": "",
|
||||
};
|
||||
@ -462,6 +459,14 @@ const settingsDefinitions = {
|
||||
description: "学号模式最小值",
|
||||
icon: "mdi-numeric-negative-1",
|
||||
},
|
||||
|
||||
// PWA 设置
|
||||
"pwa.hideInstallCard": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "不显示PWA安装卡片",
|
||||
icon: "mdi-download-off",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@ -752,4 +757,6 @@ export {
|
||||
watchSettings,
|
||||
getSettingDefinition,
|
||||
exportSettingsAsKeyValue,
|
||||
requestNotificationPermission,
|
||||
requestPersistentStorage,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user