feat(server,web,desktop,shared,deploy,plugin): 数据库管理模块 + 客户端深色模式/设置拆子路由/首启引导,版本统一 1.0.0
- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
(备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
.env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
(prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
(/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导
验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。
注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
This commit is contained in:
1 parent
391e2c1591
commit
572295c297
66 files changed
+5131
-1027
No files matched your search
@@ -226,7 +226,7 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
background: var(--ch-layer-solid);
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
@@ -234,7 +234,7 @@ onUnmounted(() => {
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@@ -246,7 +246,7 @@ onUnmounted(() => {
|
||||
.stat-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.level-list {
|
||||
|
||||
@@ -916,7 +916,7 @@ onUnmounted(() => {
|
||||
.content-block {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.75;
|
||||
background: #fafafa;
|
||||
background: var(--ch-layer-alt);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
@@ -1148,7 +1148,7 @@ onUnmounted(() => {
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
color: var(--ch-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1176,7 +1176,7 @@ onUnmounted(() => {
|
||||
.submission-list {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ebeef5;
|
||||
border: 1px solid var(--ch-divider);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useOnboardingStore } from '../stores/onboarding.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
@@ -134,6 +136,14 @@ async function enterOffline(): Promise<void> {
|
||||
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
|
||||
<div class="login-guide">
|
||||
<el-button link type="primary" @click="onboarding.open()">
|
||||
<el-icon><Guide /></el-icon>
|
||||
查看使用引导
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="testResult === 'ok'"
|
||||
class="mt-12"
|
||||
@@ -177,7 +187,7 @@ async function enterOffline(): Promise<void> {
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
background: #fff;
|
||||
background: var(--ch-layer-solid);
|
||||
border-radius: 14px;
|
||||
padding: 28px 26px 20px;
|
||||
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
|
||||
@@ -198,7 +208,7 @@ async function enterOffline(): Promise<void> {
|
||||
.login-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
@@ -210,6 +220,12 @@ async function enterOffline(): Promise<void> {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-guide {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.offline-button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -218,6 +234,6 @@ async function enterOffline(): Promise<void> {
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #c0c4cc;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -165,11 +165,11 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.notice-item:hover {
|
||||
background: #f7f9fc;
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
.notice-item.unread {
|
||||
background: #f0f7ff;
|
||||
background: var(--ch-accent-soft);
|
||||
}
|
||||
|
||||
.notice-item:last-child {
|
||||
@@ -194,7 +194,7 @@ onUnmounted(() => {
|
||||
.notice-content {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
color: var(--ch-text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -220,7 +220,7 @@ onUnmounted(() => {
|
||||
.content-block {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
background: #fafafa;
|
||||
background: var(--ch-layer-alt);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin: 0 0 16px;
|
||||
|
||||
@@ -460,7 +460,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.hero-status.status-idle {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
.status-title {
|
||||
@@ -553,7 +553,7 @@ onUnmounted(() => {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--ch-layer-solid);
|
||||
border: 2px solid var(--ch-border-strong);
|
||||
}
|
||||
|
||||
@@ -574,7 +574,8 @@ onUnmounted(() => {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: var(--ch-radius-card);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
/* 走主题变量:浅色是白玻璃、深色是深玻璃(写死白色会在深色主题下变成"浅灰卡 + 浅字"看不清) */
|
||||
background: var(--ch-layer);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
@@ -595,7 +596,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.timeline-item.is-past .timeline-card-inner {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: var(--ch-layer-alt);
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,708 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
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,
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
ISLAND_STYLE_LABELS,
|
||||
ISLAND_STYLES,
|
||||
ISLAND_POSITIONS,
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
type IslandAppearance,
|
||||
type IslandPosition,
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||
import { classIslandStatusApi } from '../api/index.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { normalizeServerUrl } from '../config.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useNotificationStore } from '../stores/notifications.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const serverInput = ref(appStore.serverUrl);
|
||||
const testing = ref(false);
|
||||
const saving = ref(false);
|
||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||
|
||||
const storeLabels: Record<string, string> = {
|
||||
profile: '登录资料',
|
||||
classes: '班级 / 学期',
|
||||
schedules: '课表',
|
||||
homeworks: '作业',
|
||||
notifications: '通知',
|
||||
grades: '成绩',
|
||||
};
|
||||
|
||||
const cacheRows = computed(() =>
|
||||
appStore.cacheStoreStats.map((item) => ({
|
||||
label: storeLabels[item.store] ?? item.store,
|
||||
store: item.store,
|
||||
count: item.count,
|
||||
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
||||
})),
|
||||
);
|
||||
|
||||
async function loadAppInfo(): Promise<void> {
|
||||
if (!window.desktop) return;
|
||||
appInfo.value = await window.desktop.getAppInfo();
|
||||
}
|
||||
|
||||
async function saveServer(): Promise<void> {
|
||||
const normalized = normalizeServerUrl(serverInput.value);
|
||||
if (!normalized) {
|
||||
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await appStore.applyServerUrl(normalized);
|
||||
serverInput.value = normalized;
|
||||
ElMessage.success('服务器地址已保存');
|
||||
await testConnection();
|
||||
if (auth.token) realtime.connect(normalized, auth.token);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await pingHealth();
|
||||
if (result.ok) ElMessage.success('服务器连接正常');
|
||||
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSync(): Promise<void> {
|
||||
setApiBaseUrl(appStore.serverUrl);
|
||||
await appStore.ping();
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
|
||||
return;
|
||||
}
|
||||
await notifications.load();
|
||||
appStore.markSynced();
|
||||
ElMessage.success('已同步最新数据');
|
||||
}
|
||||
|
||||
async function clearCache(): Promise<void> {
|
||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||
type: 'warning',
|
||||
});
|
||||
await appStore.clearCache();
|
||||
ElMessage.success('离线缓存已清空');
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||
realtime.disconnect();
|
||||
await auth.logout();
|
||||
ElMessage.success('已退出登录');
|
||||
await router.replace('/login');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 通知显示位置 */
|
||||
|
||||
/**
|
||||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
||||
*
|
||||
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
|
||||
* - client:只弹 ClassHelper 客户端
|
||||
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
|
||||
*
|
||||
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
|
||||
*/
|
||||
const channelSaving = ref(false);
|
||||
/**
|
||||
* 本班有没有接入 ClassIsland 设备。
|
||||
*
|
||||
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
|
||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||
*/
|
||||
const classIslandConnected = ref(false);
|
||||
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
|
||||
const classIslandStatus = ref<{
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
lastSeenAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const classIslandStatusText = computed(() => {
|
||||
const status = classIslandStatus.value;
|
||||
if (!status || !status.connected) return '未接入';
|
||||
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
||||
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 作业录入快捷短语 */
|
||||
|
||||
/**
|
||||
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
|
||||
*
|
||||
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
|
||||
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
|
||||
*/
|
||||
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
|
||||
const phraseInput = ref('');
|
||||
const savingPhrases = ref(false);
|
||||
|
||||
async function loadPhrases(): Promise<void> {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
|
||||
}
|
||||
|
||||
async function savePhrases(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingPhrases.value = true;
|
||||
try {
|
||||
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
|
||||
phrases.value = [...saved.homeworkPhrases];
|
||||
} finally {
|
||||
savingPhrases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addPhrase(): Promise<void> {
|
||||
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
|
||||
if (!value) return;
|
||||
if (phrases.value.includes(value)) {
|
||||
ElMessage.warning('这条短语已经有了');
|
||||
return;
|
||||
}
|
||||
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
|
||||
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
|
||||
return;
|
||||
}
|
||||
phrases.value = [...phrases.value, value];
|
||||
phraseInput.value = '';
|
||||
await savePhrases();
|
||||
ElMessage.success('已添加');
|
||||
}
|
||||
|
||||
async function removePhrase(value: string): Promise<void> {
|
||||
phrases.value = phrases.value.filter((item) => item !== value);
|
||||
await savePhrases();
|
||||
}
|
||||
|
||||
async function resetPhrases(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
|
||||
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
||||
await savePhrases();
|
||||
ElMessage.success('已恢复默认');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 灵动岛个性化 */
|
||||
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
|
||||
/**
|
||||
* 左右边距是否有效:只在停靠左 / 右时生效。
|
||||
*
|
||||
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
|
||||
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
|
||||
*/
|
||||
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
|
||||
const savingIsland = ref(false);
|
||||
|
||||
const positionOptions = ISLAND_POSITIONS.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_POSITION_LABELS[value],
|
||||
}));
|
||||
|
||||
const styleOptions = ISLAND_STYLES.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_STYLE_LABELS[value],
|
||||
}));
|
||||
|
||||
/** 读取当前生效的外观(主进程为单一事实来源) */
|
||||
async function loadIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.islandGetAppearance) return;
|
||||
island.value = await window.desktop.islandGetAppearance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
|
||||
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
|
||||
*
|
||||
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
|
||||
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
|
||||
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
|
||||
*/
|
||||
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
|
||||
island.value = { ...island.value, ...patch };
|
||||
window.desktop?.islandSetAppearance({ ...island.value });
|
||||
}
|
||||
|
||||
async function saveIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingIsland.value = true;
|
||||
try {
|
||||
await window.desktop.saveConfig({ island: { ...island.value } });
|
||||
ElMessage.success('个性化设置已保存');
|
||||
} finally {
|
||||
savingIsland.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetIslandAppearance(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
|
||||
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
await saveIslandAppearance();
|
||||
}
|
||||
|
||||
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
|
||||
function testIsland(): void {
|
||||
window.desktop?.islandPush?.({
|
||||
notification: {
|
||||
id: `appearance-test-${Date.now()}`,
|
||||
title: '灵动岛外观预览',
|
||||
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
|
||||
priority: 'NORMAL',
|
||||
createdAt: new Date().toISOString(),
|
||||
courseName: null,
|
||||
teacherName: '本地预览',
|
||||
},
|
||||
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAppInfo();
|
||||
await appStore.refreshCacheStats();
|
||||
await loadIslandAppearance();
|
||||
await loadClassChannel();
|
||||
await loadPhrases();
|
||||
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||
if (auth.classId) {
|
||||
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||
classIslandStatus.value = status;
|
||||
classIslandConnected.value = status?.connected === true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">设置</h2>
|
||||
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>服务器连接</span></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="服务器地址">
|
||||
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
|
||||
<template #prefix>
|
||||
<el-icon><Link /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
|
||||
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
||||
<el-button @click="manualSync">立即同步</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions class="mt-16" :column="1" border size="small">
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
|
||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||
</el-tag>
|
||||
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
|
||||
{{ realtime.statusText }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header>
|
||||
<span>灵动岛 · 个性化</span>
|
||||
</template>
|
||||
<el-form label-width="96px" label-position="left">
|
||||
<el-form-item :label="`高度 ${island.height}px`">
|
||||
<el-slider
|
||||
:model-value="island.height"
|
||||
:min="RANGES.height.min"
|
||||
:max="RANGES.height.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ height: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`宽度 ${island.width}px`">
|
||||
<el-slider
|
||||
:model-value="island.width"
|
||||
:min="RANGES.width.min"
|
||||
:max="RANGES.width.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ width: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`圆角 ${island.radius}px`">
|
||||
<el-slider
|
||||
:model-value="island.radius"
|
||||
:min="RANGES.radius.min"
|
||||
:max="RANGES.radius.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ radius: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
|
||||
<el-slider
|
||||
:model-value="island.opacity"
|
||||
:min="RANGES.opacity.min"
|
||||
:max="RANGES.opacity.max"
|
||||
:step="0.05"
|
||||
@input="(value: number) => applyIslandAppearance({ opacity: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`字号 ${island.fontSize}px`">
|
||||
<el-slider
|
||||
:model-value="island.fontSize"
|
||||
:min="RANGES.fontSize.min"
|
||||
:max="RANGES.fontSize.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
|
||||
<el-slider
|
||||
:model-value="island.speed"
|
||||
:min="RANGES.speed.min"
|
||||
:max="RANGES.speed.max"
|
||||
:step="0.1"
|
||||
@input="(value: number) => applyIslandAppearance({ speed: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="主题色">
|
||||
<el-color-picker
|
||||
:model-value="island.accent"
|
||||
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
|
||||
/>
|
||||
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="视觉风格">
|
||||
<el-select
|
||||
:model-value="island.style"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in styleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示位置">
|
||||
<el-select
|
||||
:model-value="island.position"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`左右边距 ${island.marginX}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginX"
|
||||
:min="RANGES.marginX.min"
|
||||
:max="RANGES.marginX.max"
|
||||
:step="2"
|
||||
:disabled="!marginXApplies"
|
||||
@input="(value: number) => applyIslandAppearance({ marginX: value })"
|
||||
/>
|
||||
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
|
||||
<span v-else class="text-muted ml-8">
|
||||
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`上下边距 ${island.marginY}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginY"
|
||||
:min="RANGES.marginY.min"
|
||||
:max="RANGES.marginY.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ marginY: value })"
|
||||
/>
|
||||
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟随鼠标屏幕">
|
||||
<el-switch
|
||||
:model-value="island.followCursorDisplay"
|
||||
@change="
|
||||
(value: boolean | string | number) =>
|
||||
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="动画">
|
||||
<el-switch
|
||||
:model-value="island.animations"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="始终置顶">
|
||||
<el-switch
|
||||
:model-value="island.alwaysOnTop"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="空闲细缝">
|
||||
<el-switch
|
||||
:model-value="island.idleSliver"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
|
||||
保存设置
|
||||
</el-button>
|
||||
<el-button @click="testIsland">预览效果</el-button>
|
||||
<el-button @click="resetIslandAppearance">恢复默认</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>ClassIsland 联动</span></template>
|
||||
<el-descriptions :column="1" border size="small" class="mb-12">
|
||||
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="插件 / ClassIsland">
|
||||
{{ classIslandStatus?.pluginVersion ?? '—' }} /
|
||||
{{ classIslandStatus?.classIslandVersion ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-muted">
|
||||
<strong>通知模式</strong>
|
||||
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 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>
|
||||
<p class="text-muted">
|
||||
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
|
||||
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
|
||||
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
|
||||
</p>
|
||||
<div class="phrase-row">
|
||||
<el-tag
|
||||
v-for="item in phrases"
|
||||
:key="item"
|
||||
closable
|
||||
type="info"
|
||||
effect="plain"
|
||||
@close="removePhrase(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
|
||||
</div>
|
||||
<div class="toolbar mt-12">
|
||||
<el-input
|
||||
v-model="phraseInput"
|
||||
placeholder="新增短语,如 大本"
|
||||
style="width: 200px"
|
||||
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
|
||||
@keyup.enter="addPhrase"
|
||||
/>
|
||||
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
|
||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
|
||||
{{ auth.user?.username ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录方式">
|
||||
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-16">
|
||||
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
<div class="text-muted mt-12">
|
||||
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
|
||||
班级密码重新登录。
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>离线缓存(IndexedDB)</span></template>
|
||||
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
||||
<el-table-column prop="label" label="数据类型" width="130" />
|
||||
<el-table-column label="条目数" width="90">
|
||||
<template #default="{ row }">{{ row.count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近更新" min-width="150">
|
||||
<template #default="{ row }">{{ row.updatedAt }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="离线可用"
|
||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>客户端信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.channel-hint {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.phrase-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type { DesktopAppInfo } from '../../../types/desktop.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
|
||||
/**
|
||||
* 关于(ClassIsland 同款折叠卡片式):
|
||||
* 应用信息(版本/运行时/常用链接)→ 诊断信息(排查问题用)→ 鸣谢。
|
||||
* 所有外链走主进程 openExternal(只放行 http/https,见 main/ipc.ts 的白名单)。
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
|
||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||
/** 折叠面板默认展开第一项(应用信息),诊断信息默认收起 */
|
||||
const expandedNames = ref(['app']);
|
||||
|
||||
const PROJECT_URL = 'https://github.com/LoveChengke/ClassHelper';
|
||||
const ISSUES_URL = 'https://github.com/LoveChengke/ClassHelper/issues';
|
||||
|
||||
const collapseItems = [
|
||||
{ name: 'app', title: '应用信息', icon: 'School' },
|
||||
{ name: 'diagnostics', title: '查看诊断信息', icon: 'Warning' },
|
||||
{ name: 'thanks', title: '鸣谢', icon: 'Pointer' },
|
||||
];
|
||||
|
||||
async function openExternal(url: string): Promise<void> {
|
||||
if (window.desktop?.openExternal) {
|
||||
const handled = await window.desktop.openExternal(url);
|
||||
if (handled) return;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!window.desktop) return;
|
||||
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">关于</h2>
|
||||
<p class="page-subtitle">版本、诊断信息与鸣谢</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="expandedNames" class="about-collapse">
|
||||
<!-- 应用信息 -->
|
||||
<el-collapse-item name="app">
|
||||
<template #title>
|
||||
<span class="about-title">
|
||||
<el-icon><School /></el-icon>
|
||||
{{ collapseItems[0].title }}
|
||||
</span>
|
||||
<span class="about-extra">
|
||||
班级小助手 v{{ appInfo?.appVersion ?? '-' }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<p class="about-copy">
|
||||
Copyright © 2025-2026 班级小助手
|
||||
<br />
|
||||
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
|
||||
</p>
|
||||
<div class="about-links">
|
||||
<span class="about-links-label">常用链接</span>
|
||||
<el-button link type="primary" @click="openExternal(PROJECT_URL)">项目主页</el-button>
|
||||
<el-button link type="primary" @click="openExternal(ISSUES_URL)">问题反馈</el-button>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small" class="mt-12">
|
||||
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运行时">
|
||||
Electron {{ appInfo?.electron ?? '-' }} · Chromium {{ appInfo?.chrome ?? '-' }} · Node
|
||||
{{ appInfo?.node ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 诊断信息 -->
|
||||
<el-collapse-item name="diagnostics">
|
||||
<template #title>
|
||||
<span class="about-title">
|
||||
<el-icon><Warning /></el-icon>
|
||||
{{ collapseItems[1].title }}
|
||||
</span>
|
||||
<span class="about-extra">报障时请附带本页信息</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="服务器地址">{{ appStore.serverUrl }}</el-descriptions-item>
|
||||
<el-descriptions-item label="连接状态">
|
||||
<el-tag size="small" :type="appStore.serverReachable ? 'success' : 'danger'">
|
||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="缓存条目">{{ appStore.cacheItemCount }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="配置文件">{{ appInfo?.configPath ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 鸣谢 -->
|
||||
<el-collapse-item name="thanks">
|
||||
<template #title>
|
||||
<span class="about-title">
|
||||
<el-icon><Pointer /></el-icon>
|
||||
{{ collapseItems[2].title }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<ul class="about-thanks">
|
||||
<li><strong>ClassIsland</strong> —— 界面风格、设置页与「今天」时间轴的交互参考</li>
|
||||
<li><strong>WinIsland</strong> —— 灵动岛的窗口形态与弹簧动画参考</li>
|
||||
<li><strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong> —— 本项目赖以构建的开源基石</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<p class="about-quote">谨以此应用,献给朝夕相伴的班级与同窗;愿时光流转,情谊常在。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.about-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.about-extra {
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.about-section {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.about-copy {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.8;
|
||||
color: var(--ch-text-secondary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.about-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.about-links-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--ch-text-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.about-thanks {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
line-height: 2;
|
||||
font-size: 12.5px;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.about-quote {
|
||||
margin: 18px 0 4px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||
|
||||
/**
|
||||
* 账号设置:本机登录的班级账号信息 + 退出登录。
|
||||
* 班级密码由管理员在 Web 管理端维护(本机按用户要求不提供改密入口)。
|
||||
*/
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||
realtime.disconnect();
|
||||
await auth.logout();
|
||||
ElMessage.success('已退出登录');
|
||||
await router.replace('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">账号</h2>
|
||||
<p class="page-subtitle">本机登录的班级账号</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
|
||||
{{ auth.user?.username ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录方式">
|
||||
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-16">
|
||||
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
<div class="text-muted mt-12">
|
||||
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
|
||||
班级密码重新登录。
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useUiStore } from '../../stores/ui.js';
|
||||
|
||||
/**
|
||||
* 外观:黑夜 / 白天模式。
|
||||
* 状态与持久化都在 stores/ui.ts(主进程配置 theme 字段),顶栏右上角的
|
||||
* 日月按钮与本页写同一份配置,切换立即生效(html.dark + Element Plus dark css-vars)。
|
||||
*/
|
||||
const ui = useUiStore();
|
||||
|
||||
const options = [
|
||||
{ value: 'light', label: '浅色', desc: '明亮界面,适合白天与投影环境' },
|
||||
{ value: 'dark', label: '深色', desc: '黑夜模式,适合晚自习与暗教室' },
|
||||
] as const;
|
||||
|
||||
async function selectTheme(value: 'light' | 'dark'): Promise<void> {
|
||||
if (value === ui.theme) return;
|
||||
await ui.setTheme(value);
|
||||
ElMessage.success(value === 'dark' ? '已切换到深色模式' : '已切换到浅色模式');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">外观</h2>
|
||||
<p class="page-subtitle">黑夜与白天模式,切换立即生效并自动保存</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>界面主题</span></template>
|
||||
<div class="theme-options">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="theme-option"
|
||||
:class="[`theme-${option.value}`, { active: ui.theme === option.value }]"
|
||||
:data-theme-option="option.value"
|
||||
@click="selectTheme(option.value)"
|
||||
>
|
||||
<span class="theme-preview">
|
||||
<span class="preview-side" />
|
||||
<span class="preview-body">
|
||||
<span class="preview-line" />
|
||||
<span class="preview-line short" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="theme-name">
|
||||
<el-icon><Sunny v-if="option.value === 'light'" /><Moon v-else /></el-icon>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span class="theme-desc">{{ option.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="顶栏右上角也有主题快捷切换按钮"
|
||||
description="灵动岛始终为深色浮窗,不随主题变化;登录页与全屏作业看板为固定配色,亦不受影响。"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ch-border-strong);
|
||||
border-radius: var(--ch-radius-card);
|
||||
background: var(--ch-layer);
|
||||
cursor: pointer;
|
||||
color: var(--ch-text);
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
border-color: var(--ch-accent);
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
border-color: var(--ch-accent);
|
||||
box-shadow: 0 0 0 1px var(--ch-accent) inset;
|
||||
}
|
||||
|
||||
/* 主题缩略预览:两块小色板直接表现各自配色 */
|
||||
.theme-preview {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ch-border-strong);
|
||||
}
|
||||
|
||||
.theme-light .theme-preview {
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.theme-dark .theme-preview {
|
||||
background: #161719;
|
||||
}
|
||||
|
||||
.preview-side {
|
||||
width: 28px;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.theme-dark .preview-side {
|
||||
background: rgba(32, 33, 36, 0.9);
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-line {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
width: 80%;
|
||||
background: rgba(15, 108, 189, 0.55);
|
||||
}
|
||||
|
||||
.preview-line.short {
|
||||
width: 50%;
|
||||
background: rgba(128, 128, 128, 0.35);
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||
formatDate,
|
||||
} from '@classhelper/shared';
|
||||
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
|
||||
import { normalizeServerUrl } from '../../config.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
import { useNotificationStore } from '../../stores/notifications.js';
|
||||
import { useOnboardingStore } from '../../stores/onboarding.js';
|
||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||
|
||||
/**
|
||||
* 通用设置:服务器连接、离线缓存与作业录入短语。
|
||||
* (外观 / 灵动岛 / 提醒 / 账号 / 关于 各自是独立子页,见侧边栏「设置」分组。)
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const serverInput = ref(appStore.serverUrl);
|
||||
const testing = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const storeLabels: Record<string, string> = {
|
||||
profile: '登录资料',
|
||||
classes: '班级 / 学期',
|
||||
schedules: '课表',
|
||||
homeworks: '作业',
|
||||
notifications: '通知',
|
||||
grades: '成绩',
|
||||
};
|
||||
|
||||
const cacheRows = computed(() =>
|
||||
appStore.cacheStoreStats.map((item) => ({
|
||||
label: storeLabels[item.store] ?? item.store,
|
||||
store: item.store,
|
||||
count: item.count,
|
||||
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
||||
})),
|
||||
);
|
||||
|
||||
async function saveServer(): Promise<void> {
|
||||
const normalized = normalizeServerUrl(serverInput.value);
|
||||
if (!normalized) {
|
||||
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await appStore.applyServerUrl(normalized);
|
||||
serverInput.value = normalized;
|
||||
ElMessage.success('服务器地址已保存');
|
||||
await testConnection();
|
||||
if (auth.token) realtime.connect(normalized, auth.token);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await pingHealth();
|
||||
if (result.ok) ElMessage.success('服务器连接正常');
|
||||
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSync(): Promise<void> {
|
||||
setApiBaseUrl(appStore.serverUrl);
|
||||
await appStore.ping();
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
|
||||
return;
|
||||
}
|
||||
await notifications.load();
|
||||
appStore.markSynced();
|
||||
ElMessage.success('已同步最新数据');
|
||||
}
|
||||
|
||||
async function clearCache(): Promise<void> {
|
||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||
type: 'warning',
|
||||
});
|
||||
await appStore.clearCache();
|
||||
ElMessage.success('离线缓存已清空');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 作业录入快捷短语 */
|
||||
|
||||
/**
|
||||
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
|
||||
*
|
||||
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
|
||||
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
|
||||
*/
|
||||
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
|
||||
const phraseInput = ref('');
|
||||
const savingPhrases = ref(false);
|
||||
|
||||
async function loadPhrases(): Promise<void> {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
|
||||
}
|
||||
|
||||
async function savePhrases(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingPhrases.value = true;
|
||||
try {
|
||||
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
|
||||
phrases.value = [...saved.homeworkPhrases];
|
||||
} finally {
|
||||
savingPhrases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addPhrase(): Promise<void> {
|
||||
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
|
||||
if (!value) return;
|
||||
if (phrases.value.includes(value)) {
|
||||
ElMessage.warning('这条短语已经有了');
|
||||
return;
|
||||
}
|
||||
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
|
||||
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
|
||||
return;
|
||||
}
|
||||
phrases.value = [...phrases.value, value];
|
||||
phraseInput.value = '';
|
||||
await savePhrases();
|
||||
ElMessage.success('已添加');
|
||||
}
|
||||
|
||||
async function removePhrase(value: string): Promise<void> {
|
||||
phrases.value = phrases.value.filter((item) => item !== value);
|
||||
await savePhrases();
|
||||
}
|
||||
|
||||
async function resetPhrases(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
|
||||
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
||||
await savePhrases();
|
||||
ElMessage.success('已恢复默认');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.refreshCacheStats();
|
||||
await loadPhrases();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">通用</h2>
|
||||
<p class="page-subtitle">服务器连接、离线缓存与作业录入</p>
|
||||
</div>
|
||||
<!-- 重看初次启动引导(首启时由 App.vue 自动弹出过一次) -->
|
||||
<el-button @click="onboarding.open()">
|
||||
<el-icon><Guide /></el-icon>
|
||||
使用引导
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>服务器连接</span></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="服务器地址">
|
||||
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
|
||||
<template #prefix>
|
||||
<el-icon><Link /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
|
||||
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
||||
<el-button @click="manualSync">立即同步</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions class="mt-16" :column="1" border size="small">
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
|
||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||
</el-tag>
|
||||
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
|
||||
{{ realtime.statusText }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>作业录入</span></template>
|
||||
<p class="text-muted">
|
||||
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
|
||||
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
|
||||
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
|
||||
</p>
|
||||
<div class="phrase-row">
|
||||
<el-tag
|
||||
v-for="item in phrases"
|
||||
:key="item"
|
||||
closable
|
||||
type="info"
|
||||
effect="plain"
|
||||
@close="removePhrase(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
|
||||
</div>
|
||||
<div class="toolbar mt-12">
|
||||
<el-input
|
||||
v-model="phraseInput"
|
||||
placeholder="新增短语,如 大本"
|
||||
style="width: 200px"
|
||||
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
|
||||
@keyup.enter="addPhrase"
|
||||
/>
|
||||
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
|
||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>离线缓存(IndexedDB)</span></template>
|
||||
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
||||
<el-table-column prop="label" label="数据类型" width="130" />
|
||||
<el-table-column label="条目数" width="90">
|
||||
<template #default="{ row }">{{ row.count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近更新" min-width="150">
|
||||
<template #default="{ row }">{{ row.updatedAt }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="离线可用"
|
||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||
/>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.phrase-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
ISLAND_POSITIONS,
|
||||
ISLAND_STYLE_LABELS,
|
||||
ISLAND_STYLES,
|
||||
type IslandAppearance,
|
||||
type IslandPosition,
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 灵动岛个性化(原设置页「灵动岛 · 个性化」卡整块迁移)。
|
||||
*
|
||||
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
|
||||
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
|
||||
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
|
||||
*/
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const savingIsland = ref(false);
|
||||
|
||||
/**
|
||||
* 左右边距是否有效:只在停靠左 / 右时生效。
|
||||
*
|
||||
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
|
||||
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
|
||||
*/
|
||||
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
|
||||
|
||||
const positionOptions = ISLAND_POSITIONS.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_POSITION_LABELS[value],
|
||||
}));
|
||||
|
||||
const styleOptions = ISLAND_STYLES.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_STYLE_LABELS[value],
|
||||
}));
|
||||
|
||||
/** 读取当前生效的外观(主进程为单一事实来源) */
|
||||
async function loadIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.islandGetAppearance) return;
|
||||
island.value = await window.desktop.islandGetAppearance();
|
||||
}
|
||||
|
||||
/** 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘 */
|
||||
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
|
||||
island.value = { ...island.value, ...patch };
|
||||
window.desktop?.islandSetAppearance({ ...island.value });
|
||||
}
|
||||
|
||||
async function saveIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingIsland.value = true;
|
||||
try {
|
||||
await window.desktop.saveConfig({ island: { ...island.value } });
|
||||
ElMessage.success('个性化设置已保存');
|
||||
} finally {
|
||||
savingIsland.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetIslandAppearance(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
|
||||
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
await saveIslandAppearance();
|
||||
}
|
||||
|
||||
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
|
||||
function testIsland(): void {
|
||||
window.desktop?.islandPush?.({
|
||||
notification: {
|
||||
id: `appearance-test-${Date.now()}`,
|
||||
title: '灵动岛外观预览',
|
||||
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
|
||||
priority: 'NORMAL',
|
||||
createdAt: new Date().toISOString(),
|
||||
courseName: null,
|
||||
teacherName: '本地预览',
|
||||
},
|
||||
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadIslandAppearance();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">灵动岛</h2>
|
||||
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>灵动岛 · 个性化</span></template>
|
||||
<el-form label-width="96px" label-position="left">
|
||||
<el-form-item :label="`高度 ${island.height}px`">
|
||||
<el-slider
|
||||
:model-value="island.height"
|
||||
:min="RANGES.height.min"
|
||||
:max="RANGES.height.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ height: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`宽度 ${island.width}px`">
|
||||
<el-slider
|
||||
:model-value="island.width"
|
||||
:min="RANGES.width.min"
|
||||
:max="RANGES.width.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ width: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`圆角 ${island.radius}px`">
|
||||
<el-slider
|
||||
:model-value="island.radius"
|
||||
:min="RANGES.radius.min"
|
||||
:max="RANGES.radius.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ radius: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
|
||||
<el-slider
|
||||
:model-value="island.opacity"
|
||||
:min="RANGES.opacity.min"
|
||||
:max="RANGES.opacity.max"
|
||||
:step="0.05"
|
||||
@input="(value: number) => applyIslandAppearance({ opacity: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`字号 ${island.fontSize}px`">
|
||||
<el-slider
|
||||
:model-value="island.fontSize"
|
||||
:min="RANGES.fontSize.min"
|
||||
:max="RANGES.fontSize.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
|
||||
<el-slider
|
||||
:model-value="island.speed"
|
||||
:min="RANGES.speed.min"
|
||||
:max="RANGES.speed.max"
|
||||
:step="0.1"
|
||||
@input="(value: number) => applyIslandAppearance({ speed: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="主题色">
|
||||
<el-color-picker
|
||||
:model-value="island.accent"
|
||||
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
|
||||
/>
|
||||
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="视觉风格">
|
||||
<el-select
|
||||
:model-value="island.style"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in styleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示位置">
|
||||
<el-select
|
||||
:model-value="island.position"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`左右边距 ${island.marginX}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginX"
|
||||
:min="RANGES.marginX.min"
|
||||
:max="RANGES.marginX.max"
|
||||
:step="2"
|
||||
:disabled="!marginXApplies"
|
||||
@input="(value: number) => applyIslandAppearance({ marginX: value })"
|
||||
/>
|
||||
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
|
||||
<span v-else class="text-muted ml-8">
|
||||
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`上下边距 ${island.marginY}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginY"
|
||||
:min="RANGES.marginY.min"
|
||||
:max="RANGES.marginY.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ marginY: value })"
|
||||
/>
|
||||
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟随鼠标屏幕">
|
||||
<el-switch
|
||||
:model-value="island.followCursorDisplay"
|
||||
@change="
|
||||
(value: boolean | string | number) =>
|
||||
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="动画">
|
||||
<el-switch
|
||||
:model-value="island.animations"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="始终置顶">
|
||||
<el-switch
|
||||
:model-value="island.alwaysOnTop"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="空闲细缝">
|
||||
<el-switch
|
||||
:model-value="island.idleSliver"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">保存设置</el-button>
|
||||
<el-button @click="testIsland">预览效果</el-button>
|
||||
<el-button @click="resetIslandAppearance">恢复默认</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
} from '@classhelper/shared';
|
||||
import { classIslandStatusApi } from '../../api/index.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
|
||||
/**
|
||||
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
|
||||
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const channelSaving = ref(false);
|
||||
/**
|
||||
* 本班有没有接入 ClassIsland 设备。
|
||||
*
|
||||
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
|
||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||
*/
|
||||
const classIslandConnected = ref(false);
|
||||
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
|
||||
const classIslandStatus = ref<{
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
lastSeenAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const classIslandStatusText = computed(() => {
|
||||
const status = classIslandStatus.value;
|
||||
if (!status || !status.connected) return '未接入';
|
||||
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
||||
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
||||
});
|
||||
|
||||
const channelOptions = computed(() =>
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||
value,
|
||||
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
||||
disabled: value === 'classisland' && !classIslandConnected.value,
|
||||
})),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.loadNotificationChannel(auth.classId);
|
||||
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||
if (auth.classId) {
|
||||
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||
classIslandStatus.value = status;
|
||||
classIslandConnected.value = status?.connected === true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">提醒</h2>
|
||||
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>ClassIsland 联动</span></template>
|
||||
<el-descriptions :column="1" border size="small" class="mb-12">
|
||||
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="插件 / ClassIsland">
|
||||
{{ classIslandStatus?.pluginVersion ?? '—' }} /
|
||||
{{ classIslandStatus?.classIslandVersion ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-muted">
|
||||
<strong>通知模式</strong>
|
||||
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.channel-hint {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user