feat(integrations,classisland,server,web,desktop): ClassIsland 联动插件——上报课表与上课状态、下发提醒并回执、教室端推送通道
教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。 - 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动" - 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404 - 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件 - Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发) - 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx - 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图 门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
This commit is contained in:
1 parent
fe41ebb7d6
commit
d0830f6830
61 files changed
+7026
-93
No files matched your search
@@ -68,6 +68,8 @@ async function submit(): Promise<void> {
|
||||
try {
|
||||
await auth.login(form.serverUrl, form.code.trim(), form.password);
|
||||
await appStore.init(form.serverUrl);
|
||||
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
|
||||
await appStore.loadNotificationChannel(auth.classId);
|
||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||
ElMessage.success(`已进入 ${auth.displayName}`);
|
||||
await router.replace('/schedule');
|
||||
|
||||
@@ -3,6 +3,9 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
@@ -10,11 +13,13 @@ import {
|
||||
ISLAND_STYLES,
|
||||
ISLAND_POSITIONS,
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
type IslandAppearance,
|
||||
type IslandPosition,
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||
import { classApi } from '../api/index.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { normalizeServerUrl } from '../config.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
@@ -113,6 +118,51 @@ async function logout(): Promise<void> {
|
||||
await router.replace('/login');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 通知显示位置 */
|
||||
|
||||
/**
|
||||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
||||
*
|
||||
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
|
||||
* - client:只弹 ClassHelper 客户端
|
||||
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
|
||||
*
|
||||
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
|
||||
*/
|
||||
const channelSaving = ref(false);
|
||||
/**
|
||||
* 本班有没有接入 ClassIsland 设备。
|
||||
*
|
||||
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
|
||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||
*/
|
||||
const classIslandConnected = ref(false);
|
||||
|
||||
const channelOptions = computed(() =>
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||
value,
|
||||
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
||||
disabled: value === 'classisland' && !classIslandConnected.value,
|
||||
})),
|
||||
);
|
||||
|
||||
async function loadClassChannel(): Promise<void> {
|
||||
const classId = auth.classId;
|
||||
if (!classId) return;
|
||||
await appStore.loadNotificationChannel(classId);
|
||||
}
|
||||
|
||||
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
|
||||
channelSaving.value = true;
|
||||
try {
|
||||
const saved = await appStore.setNotificationChannel(auth.classId, value);
|
||||
if (saved) ElMessage.success('提醒显示位置已更新');
|
||||
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
|
||||
} finally {
|
||||
channelSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 灵动岛个性化 */
|
||||
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
@@ -184,6 +234,12 @@ onMounted(async () => {
|
||||
await loadAppInfo();
|
||||
await appStore.refreshCacheStats();
|
||||
await loadIslandAppearance();
|
||||
await loadClassChannel();
|
||||
// 本班有没有 ClassIsland 设备:没接就禁用"弹在 ClassIsland"这一项,避免选了却没效果
|
||||
const classList = await classApi.list().catch(() => []);
|
||||
classIslandConnected.value = classList.some(
|
||||
(item) => item.id === auth.classId && item.classIslandConnected === true,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -365,6 +421,30 @@ onMounted(async () => {
|
||||
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>通知显示位置</span></template>
|
||||
<p class="text-muted">
|
||||
老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级,
|
||||
服务端据此决定是否一并推送到教室的 ClassIsland。
|
||||
</p>
|
||||
<el-radio-group
|
||||
:model-value="appStore.notificationChannel"
|
||||
:disabled="channelSaving"
|
||||
class="channel-group"
|
||||
@change="(value: string | number | boolean | undefined) => applyChannel(value as ClassIslandNotificationChannel)"
|
||||
>
|
||||
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="text-muted channel-hint">
|
||||
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
|
||||
<template v-if="!classIslandConnected">
|
||||
<br />提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在 ClassIsland 上弹")。
|
||||
</template>
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
@@ -425,6 +505,18 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.channel-hint {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user