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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/desktop-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"author": "Class Helper",
|
||||
|
||||
@@ -36,6 +36,12 @@ interface PersistedConfig {
|
||||
notificationChannel?: ClassIslandNotificationChannel;
|
||||
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
||||
homeworkPhrases?: string[];
|
||||
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
|
||||
onboardingDone?: boolean;
|
||||
/** 界面主题(向后兼容:旧配置没有该字段用浅色) */
|
||||
theme?: 'light' | 'dark';
|
||||
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
|
||||
sidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PersistedConfig = {
|
||||
@@ -47,8 +53,16 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
||||
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
|
||||
onboardingDone: false,
|
||||
theme: 'light',
|
||||
sidebarCollapsed: false,
|
||||
};
|
||||
|
||||
/** 主题白名单:非法值一律回落浅色 */
|
||||
function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' {
|
||||
return input === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业快捷短语的合法化:去空白、去重、限长限量。
|
||||
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
|
||||
@@ -181,6 +195,9 @@ function readPersisted(): PersistedConfig {
|
||||
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
||||
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
||||
onboardingDone: parsed.onboardingDone === true,
|
||||
theme: normalizeTheme(parsed.theme),
|
||||
sidebarCollapsed: parsed.sidebarCollapsed === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
@@ -223,6 +240,9 @@ export function getConfig(): DesktopStoredConfig {
|
||||
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
||||
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
||||
onboardingDone: persisted.onboardingDone === true,
|
||||
theme: normalizeTheme(persisted.theme),
|
||||
sidebarCollapsed: persisted.sidebarCollapsed === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,6 +273,17 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
if (patch.notificationChannel !== undefined) {
|
||||
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
||||
}
|
||||
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
|
||||
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
|
||||
if (patch.onboardingDone === true) {
|
||||
persisted.onboardingDone = true;
|
||||
}
|
||||
if (patch.theme !== undefined) {
|
||||
persisted.theme = normalizeTheme(patch.theme);
|
||||
}
|
||||
if (patch.sidebarCollapsed !== undefined) {
|
||||
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
|
||||
}
|
||||
if (patch.token !== undefined) {
|
||||
const encrypted = encryptToken(patch.token);
|
||||
persisted.token = encrypted.token;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import { getConfig } from './config.js';
|
||||
import { registerIpcHandlers } from './ipc.js';
|
||||
import { island, registerIslandIpc } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -49,16 +50,20 @@ function isAllowedNavigation(url: string): boolean {
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
|
||||
const theme = getConfig().theme;
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 980,
|
||||
minHeight: 640,
|
||||
// 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感
|
||||
width: 1242,
|
||||
height: 582,
|
||||
minWidth: 1000,
|
||||
minHeight: 540,
|
||||
center: true,
|
||||
title: '班级小助手',
|
||||
// 冒烟验证时不弹窗,避免打扰使用者
|
||||
show: !isSmokeTest,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#f5f7fa',
|
||||
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
|
||||
webPreferences: {
|
||||
preload: path.join(currentDir, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ export function registerIpcHandlers(): void {
|
||||
node: process.versions.node ?? '',
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
userDataPath: app.getPath('userData'),
|
||||
configPath: getConfigPath(),
|
||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||
}));
|
||||
|
||||
|
||||
@@ -3712,7 +3712,7 @@ async function runSettingsPagePasswordEntryCheck(
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
location.hash = '#/schedule';
|
||||
await wait(250);
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/account';
|
||||
let card = null;
|
||||
for (let i = 0; i < 40 && !card; i += 1) {
|
||||
await wait(100);
|
||||
@@ -3756,7 +3756,7 @@ async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok:
|
||||
window.desktop.islandSetAppearance({ ...before, height: 44 });
|
||||
await wait(300);
|
||||
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/island';
|
||||
let runway = null;
|
||||
for (let i = 0; i < 40 && !runway; i += 1) {
|
||||
await wait(100);
|
||||
@@ -3838,7 +3838,7 @@ async function runSettingsPageMarginCheck(
|
||||
// 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源)
|
||||
location.hash = '#/schedule';
|
||||
await wait(250);
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/island';
|
||||
let item = null;
|
||||
for (let i = 0; i < 40 && !item; i += 1) {
|
||||
await wait(100);
|
||||
@@ -3880,15 +3880,15 @@ async function runSettingsPageMarginCheck(
|
||||
.executeJavaScript(
|
||||
`(async () => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
location.hash = '#/schedule';
|
||||
await wait(200);
|
||||
location.hash = '#/settings';
|
||||
let runway = null;
|
||||
for (let i = 0; i < 40 && !runway; i += 1) {
|
||||
await wait(100);
|
||||
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
|
||||
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
|
||||
);
|
||||
location.hash = '#/schedule';
|
||||
await wait(200);
|
||||
location.hash = '#/settings/island';
|
||||
let runway = null;
|
||||
for (let i = 0; i < 40 && !runway; i += 1) {
|
||||
await wait(100);
|
||||
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
|
||||
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
|
||||
);
|
||||
runway = item?.querySelector('.el-slider__runway') ?? null;
|
||||
}
|
||||
if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' };
|
||||
@@ -3986,7 +3986,7 @@ async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: bo
|
||||
`(async () => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const before = (await window.desktop.getConfig()).notificationChannel;
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/reminder';
|
||||
|
||||
// 找到「通知显示位置」那张卡片里的单选组
|
||||
let group = null;
|
||||
@@ -4151,6 +4151,28 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
`text=${dom?.text ?? ''}`,
|
||||
);
|
||||
|
||||
// 初始窗口尺寸:与 ClassIsland 主窗口对齐(实测其可视区 1242x582,见 main/index.ts)
|
||||
const [initialWidth, initialHeight] = win.getSize();
|
||||
record(
|
||||
'初始窗口尺寸与 ClassIsland 对齐(1242x582)',
|
||||
initialWidth === 1242 && initialHeight === 582,
|
||||
`实际 ${initialWidth}x${initialHeight}`,
|
||||
);
|
||||
|
||||
// 初次启动引导(新装客户端首启自动弹出):真实点击走完一遍,断言完成后写入配置。
|
||||
// 必须放在"登录页不含示例内容"的全文扫描**之前**——那时引导还开着,先把它的文案从屏幕上撤走。
|
||||
const onboarding = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.onboardingSelfTest) return { ok: false, detail: '缺少引导自检钩子' };
|
||||
return await window.__classhelperSmoke__.onboardingSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
'初次启动引导(首启自动弹出、可走完、状态写入配置)',
|
||||
Boolean(onboarding?.ok),
|
||||
String(onboarding?.detail ?? ''),
|
||||
);
|
||||
|
||||
// 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号。
|
||||
// 判据不写死具体示例码:用"演示/示例"字样 + 旧版占位符的**形状**(例如 G101)+ 环境变量传入的凭据,
|
||||
// 这样断言更严(任何"例如 XXX123"都会被抓到),安装包里也不留示例账号字面量。
|
||||
@@ -4308,6 +4330,25 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
);
|
||||
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
|
||||
|
||||
// 主界面外观件(新功能回归):真实点击主题日月按钮与侧边栏汉堡按钮,
|
||||
// 断言 html.dark 翻转、collapse 态生效、两者都写入主进程配置;结束时还原主题
|
||||
const chrome = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.layoutChromeSelfTest) return { ok: false, detail: '缺少外观件自检钩子' };
|
||||
return await window.__classhelperSmoke__.layoutChromeSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record('主题切换与侧边栏折叠(真实点击、写回配置)', Boolean(chrome?.ok), String(chrome?.detail ?? ''));
|
||||
|
||||
// 关于页(新功能回归):/settings/about 渲染 + 诊断信息(getAppInfo 含 configPath)
|
||||
const aboutPage = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.aboutPageSelfTest) return { ok: false, detail: '缺少关于页自检钩子' };
|
||||
return await window.__classhelperSmoke__.aboutPageSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record('关于页渲染(应用信息/诊断信息/鸣谢)', Boolean(aboutPage?.ok), String(aboutPage?.detail ?? ''));
|
||||
|
||||
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
|
||||
const readBadgeGeometry = async (): Promise<{
|
||||
ok: boolean;
|
||||
|
||||
@@ -7,13 +7,18 @@ import {
|
||||
subscribeIslandMarkAllRead,
|
||||
subscribeIslandMarkRead,
|
||||
} from './island/bridge.js';
|
||||
import OnboardingWelcome from './components/OnboardingWelcome.vue';
|
||||
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 { useUiStore } from './stores/ui.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const ui = useUiStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const booting = ref(true);
|
||||
@@ -27,6 +32,10 @@ const bootText = ref('正在读取本地配置...');
|
||||
*/
|
||||
onMounted(async () => {
|
||||
try {
|
||||
bootText.value = '正在读取本地配置...';
|
||||
// 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下
|
||||
await ui.init();
|
||||
|
||||
bootText.value = '正在恢复登录状态...';
|
||||
const session = await auth.restore();
|
||||
|
||||
@@ -52,6 +61,17 @@ onMounted(async () => {
|
||||
|
||||
if (auth.isAuthenticated) await router.replace('/schedule');
|
||||
else await router.replace('/login');
|
||||
|
||||
// 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。
|
||||
// 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。
|
||||
window.setTimeout(() => {
|
||||
void window.desktop
|
||||
?.getConfig()
|
||||
.then((config) => {
|
||||
if (config.onboardingDone !== true) onboarding.open();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 400);
|
||||
} finally {
|
||||
booting.value = false;
|
||||
}
|
||||
@@ -69,6 +89,8 @@ onUnmounted(() => {
|
||||
<p class="boot-text">{{ bootText }}</p>
|
||||
</div>
|
||||
<router-view v-else />
|
||||
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
|
||||
<OnboardingWelcome v-if="!booting" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+254
-9
@@ -93,19 +93,26 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 3000, intervalMs
|
||||
return predicate();
|
||||
}
|
||||
|
||||
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配) */
|
||||
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配;侧边栏是「分组 + 子菜单」结构) */
|
||||
const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
||||
{ label: '课表', path: '/schedule' },
|
||||
{ label: '作业', path: '/homeworks' },
|
||||
{ label: '通知', path: '/notifications' },
|
||||
{ label: '成绩', path: '/grades' },
|
||||
{ label: '设置', path: '/settings' },
|
||||
/** 设置组的代表子项:设置入口改成分组后,用「通用」验证设置子页导航可用 */
|
||||
{ label: '通用', path: '/settings/general' },
|
||||
];
|
||||
|
||||
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项 */
|
||||
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
|
||||
{ title: '学习', labels: ['课表', '作业', '通知', '成绩'] },
|
||||
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* 冒烟自检 3:侧边栏点击导航。
|
||||
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 ——
|
||||
* 这是"点击左侧边栏没反应"这类问题的回归测试。
|
||||
* 冒烟自检 3:侧边栏点击导航(分组结构版)。
|
||||
* 先点分组标题展开,再逐个点击子项,验证每次点击后路由与视图都发生切换 ——
|
||||
* 这是"点击左侧边栏没反应"这类问题的回归测试(侧边栏改为「学习/设置」分组后重写)。
|
||||
*/
|
||||
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
const { router } = await import('../router/index.js');
|
||||
@@ -113,20 +120,43 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
|
||||
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
|
||||
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
|
||||
const findMenuItem = (label: string): HTMLElement | undefined =>
|
||||
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
||||
const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
|
||||
(Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
|
||||
Array.from(element.querySelectorAll('span')).some(
|
||||
(span) => (span.textContent ?? '').trim() === label,
|
||||
),
|
||||
);
|
||||
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
|
||||
const findGroupTitle = (title: string): HTMLElement | undefined =>
|
||||
findByLabel('.el-sub-menu__title', title);
|
||||
|
||||
if (!findMenuItem('课表')) {
|
||||
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' };
|
||||
if (!findGroupTitle('学习') || !findGroupTitle('设置')) {
|
||||
return { ok: false, detail: '侧边栏分组未渲染(当前不在主布局或未登录)' };
|
||||
}
|
||||
|
||||
const visited: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
// 展开分组:默认只展开"当前路由所在分组",其它组需要先点开。
|
||||
// 判据用"子项是否可见"而不是"盲点一次标题" —— 标题点击是 toggle,
|
||||
// 对已经展开的组再点一下会把它**收起**,后续点不到子项。
|
||||
const isVisible = (element: HTMLElement | undefined): boolean =>
|
||||
Boolean(element && element.offsetParent !== null);
|
||||
for (const group of MENU_GROUPS) {
|
||||
const firstItem = findMenuItem(group.labels[0] ?? '');
|
||||
if (!isVisible(firstItem)) {
|
||||
findGroupTitle(group.title)?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
// 分组结构自检:两个分组各自应包含全部子项
|
||||
for (const group of MENU_GROUPS) {
|
||||
for (const label of group.labels) {
|
||||
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { label, path } of EXPECTED_MENU) {
|
||||
const element = findMenuItem(label);
|
||||
if (!element) {
|
||||
@@ -818,6 +848,66 @@ export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检(不需要后端):**初次启动引导**。
|
||||
*
|
||||
* 冒烟每次都用全新配置目录(等于"首次启动"),因此引导必须自动弹出;
|
||||
* 这里真实点击把它一步步走完,断言:遮罩关闭 + `onboardingDone` 已写入配置。
|
||||
* 关闭判据用「元素从 DOM 消失」——引导是 v-if 自绘遮罩(特意不用 el-dialog,
|
||||
* 避免"关闭后 DOM 残留在文档里"的误报,见 AGENTS §7 第 22 条)。
|
||||
*/
|
||||
export async function onboardingSelfTest(): Promise<SmokeCheckResult> {
|
||||
try {
|
||||
// 引导在启动后延迟 ~400ms 弹出,这里轮询等它出现
|
||||
const appearDeadline = Date.now() + 5000;
|
||||
while (Date.now() < appearDeadline && !document.querySelector('.onboarding-welcome')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!document.querySelector('.onboarding-welcome')) {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
return {
|
||||
ok: false,
|
||||
detail: `初次启动引导未自动出现(onboardingDone=${config?.onboardingDone ?? '-'})`,
|
||||
};
|
||||
}
|
||||
|
||||
// 逐页「下一步」到最后一页再点「开始使用」(按钮由 data-action 标识,不依赖文案)
|
||||
const clicks: string[] = [];
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const action = document.querySelector('.onboarding-welcome [data-action="finish"]')
|
||||
? 'finish'
|
||||
: 'next';
|
||||
const button = document.querySelector<HTMLElement>(
|
||||
`.onboarding-welcome [data-action="${action}"]`,
|
||||
);
|
||||
if (!button) break;
|
||||
clicks.push(action);
|
||||
button.click();
|
||||
if (action === 'finish') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
let closed = !document.querySelector('.onboarding-welcome');
|
||||
const closeDeadline = Date.now() + 2000;
|
||||
while (Date.now() < closeDeadline && !closed) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
closed = !document.querySelector('.onboarding-welcome');
|
||||
}
|
||||
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
const done = config?.onboardingDone === true;
|
||||
return {
|
||||
ok: closed && done,
|
||||
detail: `步骤点击=[${clicks.join(',')}] 已关闭=${closed} onboardingDone=${config?.onboardingDone ?? '-'}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `引导自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
|
||||
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
||||
@@ -834,6 +924,158 @@ export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检(不需要后端):主界面外观件 —— 主题切换与侧边栏折叠。
|
||||
* 走**真实 DOM 点击**(顶栏日月按钮 / 汉堡按钮),验证:
|
||||
* 1) html.dark 类与 store 同步翻转,且写入主进程配置;
|
||||
* 2) 侧边栏折叠后 el-menu 进入 collapse 态、宽度收窄,配置同步;
|
||||
* 结束时把主题还原为初始值(后续截图/几何用例依赖固定主题)。
|
||||
*/
|
||||
export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
||||
try {
|
||||
const { router } = await import('../router/index.js');
|
||||
await router.push('/schedule');
|
||||
if (!(await waitUntil(() => Boolean(document.querySelector('[data-test="theme-toggle"]')), 6000))) {
|
||||
return { ok: false, detail: '主布局未渲染(找不到主题切换按钮)' };
|
||||
}
|
||||
|
||||
const clickToggle = (selector: string): boolean => {
|
||||
const node = document.querySelector<HTMLElement>(selector);
|
||||
if (!node) return false;
|
||||
node.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 1) 主题:真实点击日月按钮 → html.dark 翻转 → 配置落盘
|
||||
const initialDark = document.documentElement.classList.contains('dark');
|
||||
if (!clickToggle('[data-test="theme-toggle"]')) return { ok: false, detail: '主题按钮点击失败' };
|
||||
const flipped = await waitUntil(
|
||||
() => document.documentElement.classList.contains('dark') !== initialDark,
|
||||
2500,
|
||||
);
|
||||
const flippedDark = document.documentElement.classList.contains('dark');
|
||||
const configAfterFlip = await window.desktop?.getConfig?.();
|
||||
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
|
||||
|
||||
// 2) 侧边栏:点汉堡折叠 → collapse 态 + 宽度收窄 → 配置落盘 → 再展开还原
|
||||
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const widthBefore = asideBefore?.offsetWidth ?? 0;
|
||||
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
|
||||
const collapsed = await waitUntil(() => Boolean(document.querySelector('.el-menu--collapse')), 2500);
|
||||
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const widthAfter = asideAfter?.offsetWidth ?? 0;
|
||||
const configAfterCollapse = await window.desktop?.getConfig?.();
|
||||
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
|
||||
|
||||
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
|
||||
// a) 菜单里不允许残留任何**可见**的文字(Element Plus 靠宽度归零隐藏,一旦被外层样式
|
||||
// 干扰就会留"半截字");
|
||||
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px),否则看起来"没对齐"。
|
||||
const centerXOf = (element: Element | null | undefined): number | null => {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 ? rect.left + rect.width / 2 : null;
|
||||
};
|
||||
const leftoverText: string[] = [];
|
||||
for (const node of Array.from(
|
||||
document.querySelectorAll('.aside .el-menu-item > span, .aside .el-sub-menu__title > span'),
|
||||
)) {
|
||||
const element = node as HTMLElement;
|
||||
if (element.classList.contains('menu-icon-slot')) continue;
|
||||
const text = (element.textContent ?? '').trim();
|
||||
if (text && element.offsetParent !== null && element.getClientRects().length > 0) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
// 宽高都归零但 display 不为 none 的,正是"0 宽溢出"的残影来源
|
||||
if (rect.width < 1) leftoverText.push(`0宽:${text.slice(0, 6)}`);
|
||||
else leftoverText.push(`可见:${text.slice(0, 6)}`);
|
||||
}
|
||||
}
|
||||
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
|
||||
const iconCenters = Array.from(
|
||||
document.querySelectorAll('.aside .el-menu--collapse .el-sub-menu__title .el-icon'),
|
||||
).map((node) => centerXOf(node));
|
||||
const iconDeltas = iconCenters
|
||||
.filter((value): value is number => value !== null && toggleCenter !== null)
|
||||
.map((value) => Math.abs(value - (toggleCenter as number)));
|
||||
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
|
||||
|
||||
clickToggle('[data-test="sidebar-toggle"]');
|
||||
await waitUntil(() => !document.querySelector('.el-menu--collapse'), 2500);
|
||||
|
||||
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
|
||||
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
|
||||
// 在加这条结构性保证之前,两个分组全展开会把 footer 挤出屏幕(用户反馈)。
|
||||
const footer = document.querySelector<HTMLElement>('.aside-footer');
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerVisible =
|
||||
Boolean(footer && footer.offsetParent !== null) &&
|
||||
Boolean(footerRect && footerRect.bottom <= window.innerHeight + 1);
|
||||
|
||||
// 3) 还原主题,避免影响后续用例
|
||||
clickToggle('[data-test="theme-toggle"]');
|
||||
await waitUntil(() => document.documentElement.classList.contains('dark') === initialDark, 2500);
|
||||
|
||||
const ok =
|
||||
flipped &&
|
||||
themePersisted &&
|
||||
collapsed &&
|
||||
collapsePersisted &&
|
||||
widthAfter < widthBefore &&
|
||||
widthAfter <= 80 &&
|
||||
leftoverText.length === 0 &&
|
||||
iconsAligned &&
|
||||
footerVisible;
|
||||
return {
|
||||
ok,
|
||||
detail:
|
||||
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
|
||||
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 落盘=${collapsePersisted} ` +
|
||||
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
|
||||
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
|
||||
`侧栏底部可见=${footerVisible}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `外观件自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检(不需要后端):关于页(/settings/about)。
|
||||
* 断言页面渲染出标题与「鸣谢 / 诊断信息」区块,且 getAppInfo 返回了新增的 configPath
|
||||
* (诊断信息依赖它)。
|
||||
*/
|
||||
export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
|
||||
try {
|
||||
const { router } = await import('../router/index.js');
|
||||
await router.push('/settings/about');
|
||||
const rendered = await waitUntil(
|
||||
() => (document.querySelector('.page-title')?.textContent ?? '').includes('关于'),
|
||||
6000,
|
||||
);
|
||||
if (!rendered) return { ok: false, detail: '关于页未挂载' };
|
||||
|
||||
const text = document.body.innerText.replace(/\s+/g, ' ');
|
||||
const hasThanks = text.includes('鸣谢');
|
||||
const hasDiagnostics = text.includes('诊断信息');
|
||||
const info = await window.desktop?.getAppInfo?.();
|
||||
const hasConfigPath = Boolean(info?.configPath);
|
||||
|
||||
await router.push('/schedule');
|
||||
return {
|
||||
ok: hasThanks && hasDiagnostics && hasConfigPath,
|
||||
detail: `鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `关于页自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册到 window,供主进程冒烟脚本调用 */
|
||||
export function registerSmokeHooks(): void {
|
||||
window.__classhelperSmoke__ = {
|
||||
@@ -845,6 +1087,9 @@ export function registerSmokeHooks(): void {
|
||||
islandRealtimeCleanup,
|
||||
islandReadState,
|
||||
islandHomeworkSuppressionCheck,
|
||||
onboardingSelfTest,
|
||||
layoutChromeSelfTest,
|
||||
aboutPageSelfTest,
|
||||
scheduleTimelineSelfTest,
|
||||
homeworkBoardSelfTest,
|
||||
sessionCleanup,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useOnboardingStore } from '../stores/onboarding.js';
|
||||
|
||||
/**
|
||||
* 初次启动引导(六步向导)。
|
||||
*
|
||||
* - 首次启动自动弹出(App.vue 按 `onboardingDone` 判定);完成 / 跳过(右上角按钮 / Esc)
|
||||
* 都算看过,由这里写入主进程配置,之后不再自动弹出。
|
||||
* - 登录页与设置页的「使用引导」入口可随时重看(重看关闭时再写一次也无副作用)。
|
||||
* - **用 v-if 自绘遮罩而不是 el-dialog**:关闭即从 DOM 卸载。冒烟断言"引导已关闭"时
|
||||
* 不会踩 el-dialog「DOM 残留在文档里」的坑(AGENTS §7 第 22 条)。
|
||||
* - 操作按钮用 `data-action` 标识(prev/next/finish/skip),冒烟点击不依赖按钮文案。
|
||||
*/
|
||||
const onboarding = useOnboardingStore();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: 'School',
|
||||
title: '欢迎使用班级小助手',
|
||||
lines: [
|
||||
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
|
||||
'第一次使用,先花半分钟看看怎么开始。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Link',
|
||||
title: '第一步 · 连接服务器',
|
||||
lines: [
|
||||
'在登录页填写老师或管理员提供的服务器地址(通常形如 http://192.168.x.x:4000)。',
|
||||
'点「测试连接」确认可达;以后也能在「设置」里随时修改。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Key',
|
||||
title: '第二步 · 登录班级',
|
||||
lines: [
|
||||
'使用班级码 + 班级密码登录(向老师或管理员索取)。',
|
||||
'登录后这台设备就代表全班:作业完成、通知已读都会按全班记录,不需要每个学生单独登录。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Calendar',
|
||||
title: '登录之后',
|
||||
lines: [
|
||||
'左侧菜单切换「课表 / 作业 / 通知 / 成绩」四个页面,作业支持看板全屏展示。',
|
||||
'断网时自动显示最近同步的缓存数据,联网后自动更新。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Bell',
|
||||
title: '灵动岛提醒',
|
||||
lines: [
|
||||
'屏幕上方会有一条「灵动岛」浮窗:新通知、叫人、作业都会在那里弹出。',
|
||||
'上课时段自动隐藏、下课自动弹出;外观与位置可在「设置 → 灵动岛」调整。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'CircleCheckFilled',
|
||||
title: '准备好了',
|
||||
lines: [
|
||||
'按上面的步骤登录就可以开始使用了。',
|
||||
'侧边栏「设置」分组里有通用、外观(深色模式)、灵动岛等页面;这份引导可以在登录页或「设置 → 通用 → 使用引导」随时重看。',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const index = ref(0);
|
||||
const current = computed(() => steps[index.value] ?? steps[0]);
|
||||
const isFirst = computed(() => index.value === 0);
|
||||
const isLast = computed(() => index.value === steps.length - 1);
|
||||
|
||||
/** 每次打开都从第一步开始(重看入口也走完整流程) */
|
||||
watch(
|
||||
() => onboarding.visible,
|
||||
(visible) => {
|
||||
if (visible) index.value = 0;
|
||||
},
|
||||
);
|
||||
|
||||
function goNext(): void {
|
||||
if (isLast.value) finish();
|
||||
else index.value += 1;
|
||||
}
|
||||
|
||||
function goPrev(): void {
|
||||
if (!isFirst.value) index.value -= 1;
|
||||
}
|
||||
|
||||
/** 完成 / 跳过统一走这里:记「已看过」并关闭(重看时重复写一次无副作用) */
|
||||
function finish(): void {
|
||||
const bridge = window.desktop;
|
||||
// 桥接不可用(浏览器调试)也要能正常关掉引导
|
||||
if (!bridge) {
|
||||
onboarding.close();
|
||||
return;
|
||||
}
|
||||
void bridge
|
||||
.saveConfig({ onboardingDone: true })
|
||||
.then(() => onboarding.close())
|
||||
.catch(() => onboarding.close());
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') finish();
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="onboarding-fade">
|
||||
<div
|
||||
v-if="onboarding.visible"
|
||||
class="onboarding-mask"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="初次启动引导"
|
||||
>
|
||||
<div class="onboarding-card onboarding-welcome">
|
||||
<div class="onboarding-head">
|
||||
<span class="onboarding-logo">
|
||||
<el-icon :size="18"><School /></el-icon>
|
||||
</span>
|
||||
<div class="onboarding-heading">
|
||||
<h2 class="onboarding-title">班级小助手</h2>
|
||||
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
|
||||
</div>
|
||||
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-body">
|
||||
<span class="onboarding-icon">
|
||||
<el-icon :size="30"><component :is="current.icon" /></el-icon>
|
||||
</span>
|
||||
<h3 class="onboarding-step-title">{{ current.title }}</h3>
|
||||
<p v-for="(line, lineIndex) in current.lines" :key="lineIndex" class="onboarding-line">
|
||||
{{ line }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-foot">
|
||||
<div class="onboarding-dots">
|
||||
<span
|
||||
v-for="(step, dotIndex) in steps"
|
||||
:key="dotIndex"
|
||||
class="dot"
|
||||
:class="{ active: dotIndex === index }"
|
||||
/>
|
||||
</div>
|
||||
<div class="onboarding-actions">
|
||||
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
|
||||
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
|
||||
下一步
|
||||
</el-button>
|
||||
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.onboarding-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 30, 50, 0.55);
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 520px;
|
||||
max-width: 92vw;
|
||||
max-height: 86vh;
|
||||
overflow-y: auto;
|
||||
background: var(--ch-layer-solid);
|
||||
border-radius: 16px;
|
||||
padding: 22px 24px 18px;
|
||||
box-shadow: 0 24px 64px rgba(15, 30, 50, 0.35);
|
||||
}
|
||||
|
||||
.onboarding-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.onboarding-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
|
||||
}
|
||||
|
||||
.onboarding-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.onboarding-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.onboarding-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.onboarding-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 18px 4px 6px;
|
||||
}
|
||||
|
||||
.onboarding-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 14px;
|
||||
color: var(--ch-accent);
|
||||
background: var(--ch-accent-soft);
|
||||
}
|
||||
|
||||
.onboarding-step-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.onboarding-line {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--ch-text-regular, #606266);
|
||||
}
|
||||
|
||||
.onboarding-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--ch-divider);
|
||||
}
|
||||
|
||||
.onboarding-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--ch-border-strong);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
width: 20px;
|
||||
background: var(--ch-accent);
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 进出场:淡入 + 轻微上浮,跟随系统"动画"开关无强依赖 */
|
||||
.onboarding-fade-enter-active,
|
||||
.onboarding-fade-leave-active {
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.onboarding-fade-enter-active .onboarding-card,
|
||||
.onboarding-fade-leave-active .onboarding-card {
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
.onboarding-fade-enter-from,
|
||||
.onboarding-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.onboarding-fade-enter-from .onboarding-card,
|
||||
.onboarding-fade-leave-to .onboarding-card {
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import { startIslandBridge, stopIslandBridge } from '../island/bridge.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';
|
||||
import { useUiStore } from '../stores/ui.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -15,18 +16,75 @@ const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
||||
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
||||
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
||||
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||
{ path: '/settings', title: '设置', icon: 'Setting' },
|
||||
/**
|
||||
* 主侧边栏:ClassIsland 式「分组 + 子菜单」。
|
||||
* - 学习:课表 / 作业 / 通知 / 成绩
|
||||
* - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*)
|
||||
* 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。
|
||||
*/
|
||||
const menuGroups = [
|
||||
{
|
||||
title: '学习',
|
||||
icon: 'Reading',
|
||||
items: [
|
||||
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
||||
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
||||
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
||||
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '设置',
|
||||
icon: 'Setting',
|
||||
items: [
|
||||
{ path: '/settings/general', title: '通用', icon: 'Tools' },
|
||||
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
|
||||
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
|
||||
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
|
||||
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
|
||||
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 未读红点挂在「通知」项的图标右上角 */
|
||||
function showBadge(path: string): boolean {
|
||||
return path === '/notifications' && notifications.unreadCount > 0;
|
||||
}
|
||||
|
||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||
const activeMenu = computed(() => route.path);
|
||||
|
||||
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
|
||||
const activeGroupTitle = computed(() => {
|
||||
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
|
||||
return group?.title ?? '学习';
|
||||
});
|
||||
|
||||
/**
|
||||
* 分组展开控制:默认只展开**当前路由所在的分组**。
|
||||
*
|
||||
* 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题)
|
||||
* 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的
|
||||
* 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。
|
||||
* 用户手动展开其它分组不受限制(可多个同时展开)。
|
||||
*/
|
||||
const menuRef = ref<MenuInstance>();
|
||||
const openedGroups = ref<string[]>([activeGroupTitle.value]);
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
async (path) => {
|
||||
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
|
||||
if (!group) return;
|
||||
// 展开当前组(不主动关闭用户已展开的其它组)
|
||||
await nextTick();
|
||||
menuRef.value?.open(group.title);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 菜单点击回调。
|
||||
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
|
||||
@@ -44,9 +102,11 @@ function go(name: string): void {
|
||||
/** 开发期自检:菜单路径必须存在于路由表中 */
|
||||
if (import.meta.env.DEV) {
|
||||
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
||||
for (const item of menuItems) {
|
||||
if (!knownPaths.has(item.path)) {
|
||||
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||
for (const group of menuGroups) {
|
||||
for (const item of group.items) {
|
||||
if (!knownPaths.has(item.path)) {
|
||||
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,29 +166,50 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="200px" class="aside ch-nav">
|
||||
<el-aside :width="ui.sidebarCollapsed ? '64px' : '200px'" class="aside ch-nav">
|
||||
<div class="brand">
|
||||
<span class="brand-logo">
|
||||
<el-icon :size="16"><School /></el-icon>
|
||||
</span>
|
||||
<span class="brand-text">班级小助手</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||
<span class="menu-icon-slot">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<el-badge
|
||||
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
||||
:value="notifications.unreadCount"
|
||||
:max="99"
|
||||
class="menu-badge"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
aria-label="折叠或展开侧边栏"
|
||||
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
|
||||
data-test="sidebar-toggle"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
|
||||
</button>
|
||||
<template v-if="!ui.sidebarCollapsed">
|
||||
<span class="brand-logo">
|
||||
<el-icon :size="16"><School /></el-icon>
|
||||
</span>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
<span class="brand-text">班级小助手</span>
|
||||
</template>
|
||||
</div>
|
||||
<el-menu
|
||||
ref="menuRef"
|
||||
:default-active="activeMenu"
|
||||
:default-openeds="openedGroups"
|
||||
:collapse="ui.sidebarCollapsed"
|
||||
:collapse-width="64"
|
||||
class="menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
|
||||
<template #title>
|
||||
<el-icon><component :is="group.icon" /></el-icon>
|
||||
<span>{{ group.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
||||
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||
<span class="menu-icon-slot">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<el-badge v-if="showBadge(item.path)" :value="notifications.unreadCount" :max="99" class="menu-badge" />
|
||||
</span>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
<div class="aside-footer">
|
||||
<div v-if="!ui.sidebarCollapsed" class="aside-footer">
|
||||
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
|
||||
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
|
||||
</div>
|
||||
@@ -142,6 +223,19 @@ onUnmounted(() => {
|
||||
<span class="server-url">{{ appStore.serverUrl }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
|
||||
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
|
||||
<el-button
|
||||
class="theme-toggle"
|
||||
text
|
||||
circle
|
||||
data-test="theme-toggle"
|
||||
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
|
||||
@click="ui.toggleTheme()"
|
||||
>
|
||||
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-dropdown trigger="click">
|
||||
<span class="user-chip">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
@@ -150,7 +244,7 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="go('settings')">
|
||||
<el-dropdown-item @click="go('settings-general')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
设置
|
||||
</el-dropdown-item>
|
||||
@@ -198,12 +292,38 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
padding: 0 10px 0 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
|
||||
.nav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
border-radius: var(--ch-radius-control);
|
||||
background: transparent;
|
||||
color: var(--ch-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-toggle:hover {
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
/* 折叠态:侧栏只剩 64px,汉堡按钮居中 */
|
||||
.aside.ch-nav:has(.el-menu--collapse) .brand {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
@@ -222,10 +342,14 @@ onUnmounted(() => {
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
--el-menu-bg-color: transparent;
|
||||
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
|
||||
--el-menu-hover-bg-color: var(--ch-hover-soft);
|
||||
--el-menu-text-color: var(--ch-text);
|
||||
--el-menu-active-color: var(--ch-text);
|
||||
}
|
||||
@@ -279,7 +403,7 @@ onUnmounted(() => {
|
||||
|
||||
.header {
|
||||
height: 52px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
background: var(--ch-header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--ch-border);
|
||||
display: flex;
|
||||
@@ -313,7 +437,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.user-chip:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
margin-right: 2px;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.main {
|
||||
|
||||
@@ -4,6 +4,8 @@ import ElementPlus from 'element-plus';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
||||
import 'element-plus/dist/index.css';
|
||||
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router/index.js';
|
||||
|
||||
@@ -41,11 +41,46 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/GradeView.vue'),
|
||||
meta: { title: '成绩' },
|
||||
},
|
||||
/* 设置:主侧边栏「设置」分组下的六个子页(ClassIsland 式 主菜单+子菜单) */
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
meta: { title: '设置' },
|
||||
redirect: '/settings/general',
|
||||
},
|
||||
{
|
||||
path: 'settings/general',
|
||||
name: 'settings-general',
|
||||
component: () => import('../views/settings/GeneralSettingsView.vue'),
|
||||
meta: { title: '通用设置' },
|
||||
},
|
||||
{
|
||||
path: 'settings/appearance',
|
||||
name: 'settings-appearance',
|
||||
component: () => import('../views/settings/AppearanceView.vue'),
|
||||
meta: { title: '外观' },
|
||||
},
|
||||
{
|
||||
path: 'settings/island',
|
||||
name: 'settings-island',
|
||||
component: () => import('../views/settings/IslandSettingsView.vue'),
|
||||
meta: { title: '灵动岛' },
|
||||
},
|
||||
{
|
||||
path: 'settings/reminder',
|
||||
name: 'settings-reminder',
|
||||
component: () => import('../views/settings/ReminderSettingsView.vue'),
|
||||
meta: { title: '提醒' },
|
||||
},
|
||||
{
|
||||
path: 'settings/account',
|
||||
name: 'settings-account',
|
||||
component: () => import('../views/settings/AccountSettingsView.vue'),
|
||||
meta: { title: '账号' },
|
||||
},
|
||||
{
|
||||
path: 'settings/about',
|
||||
name: 'settings-about',
|
||||
component: () => import('../views/settings/AboutView.vue'),
|
||||
meta: { title: '关于' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
/**
|
||||
* 初次启动引导的显示状态。
|
||||
*
|
||||
* 首次启动由 App.vue 按「配置里是否记过 onboardingDone」自动弹出;
|
||||
* 登录页与设置页的「使用引导」入口调用 open() 随时重看。
|
||||
* 单独一个 store 而不是组件内部 ref:触发入口(登录页 / 设置页)与
|
||||
* 渲染点(App.vue 顶层)分处两棵互不相干的子树。
|
||||
*/
|
||||
export const useOnboardingStore = defineStore('onboarding', () => {
|
||||
const visible = ref(false);
|
||||
|
||||
function open(): void {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
return { visible, open, close };
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { DesktopStoredConfig } from '../../types/desktop.js';
|
||||
|
||||
/**
|
||||
* 界面偏好(主题 / 侧边栏折叠)。
|
||||
*
|
||||
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
|
||||
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
|
||||
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
|
||||
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
|
||||
*/
|
||||
|
||||
const THEME_KEY = 'classhelper.theme';
|
||||
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
|
||||
|
||||
function applyThemeClass(theme: 'light' | 'dark'): void {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const theme = ref<'light' | 'dark'>('light');
|
||||
const sidebarCollapsed = ref(false);
|
||||
|
||||
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
|
||||
async function init(): Promise<void> {
|
||||
if (window.desktop) {
|
||||
const config = await window.desktop.getConfig().catch(() => null);
|
||||
if (config) {
|
||||
theme.value = config.theme === 'dark' ? 'dark' : 'light';
|
||||
sidebarCollapsed.value = config.sidebarCollapsed === true;
|
||||
}
|
||||
} else {
|
||||
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
|
||||
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
|
||||
}
|
||||
applyThemeClass(theme.value);
|
||||
}
|
||||
|
||||
/** 切换主题(外观页单选 / 顶栏快捷切换共用),写回配置 */
|
||||
async function setTheme(next: 'light' | 'dark'): Promise<void> {
|
||||
theme.value = next;
|
||||
applyThemeClass(next);
|
||||
if (window.desktop) {
|
||||
await window.desktop.saveConfig({ theme: next }).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme(): void {
|
||||
void setTheme(theme.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
/** 主侧边栏折叠/展开(汉堡按钮),写回配置 */
|
||||
async function setSidebarCollapsed(collapsed: boolean): Promise<void> {
|
||||
sidebarCollapsed.value = collapsed;
|
||||
if (window.desktop) {
|
||||
await window.desktop.saveConfig({ sidebarCollapsed: collapsed }).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar(): void {
|
||||
void setSidebarCollapsed(!sidebarCollapsed.value);
|
||||
}
|
||||
|
||||
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
|
||||
});
|
||||
|
||||
/** 供类型检查使用(仅引用类型) */
|
||||
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
|
||||
@@ -1,12 +1,19 @@
|
||||
/* ============================================================================
|
||||
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
|
||||
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
|
||||
*
|
||||
* 参考 ClassIsland(Windows 课件表应用)的观感:
|
||||
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
|
||||
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
|
||||
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
|
||||
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
|
||||
*
|
||||
* 主题机制:
|
||||
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
|
||||
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
|
||||
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
|
||||
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
|
||||
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
|
||||
* ========================================================================== */
|
||||
|
||||
:root {
|
||||
@@ -33,6 +40,13 @@
|
||||
--ch-text-secondary: #5c5c5c;
|
||||
--ch-text-tertiary: #8a8a8a;
|
||||
|
||||
/* 交互态(悬停底色 / 侧栏与顶栏的亚克力底 / 滚动条) */
|
||||
--ch-hover-soft: rgba(0, 0, 0, 0.04);
|
||||
--ch-nav-bg: rgba(255, 255, 255, 0.65);
|
||||
--ch-header-bg: rgba(255, 255, 255, 0.6);
|
||||
--ch-scrollbar: rgba(0, 0, 0, 0.18);
|
||||
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* 圆角与阴影 */
|
||||
--ch-radius-card: 8px;
|
||||
--ch-radius-control: 4px;
|
||||
@@ -62,6 +76,55 @@
|
||||
--el-font-size-base: 13px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 深色主题 */
|
||||
|
||||
html.dark {
|
||||
--ch-accent: #5aabec;
|
||||
--ch-accent-hover: #7cc0f4;
|
||||
--ch-accent-soft: rgba(90, 171, 236, 0.16);
|
||||
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
|
||||
|
||||
--ch-bg: #161719;
|
||||
--ch-layer: rgba(32, 33, 36, 0.72);
|
||||
--ch-layer-solid: #1e1f23;
|
||||
--ch-layer-alt: rgba(28, 29, 32, 0.5);
|
||||
--ch-card-subtle: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--ch-border: rgba(255, 255, 255, 0.08);
|
||||
--ch-border-strong: rgba(255, 255, 255, 0.14);
|
||||
--ch-divider: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--ch-text: #e8e8ea;
|
||||
--ch-text-secondary: #b0b1b5;
|
||||
--ch-text-tertiary: #7e7f85;
|
||||
|
||||
--ch-hover-soft: rgba(255, 255, 255, 0.07);
|
||||
--ch-nav-bg: rgba(28, 29, 32, 0.65);
|
||||
--ch-header-bg: rgba(30, 31, 34, 0.6);
|
||||
--ch-scrollbar: rgba(255, 255, 255, 0.22);
|
||||
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
|
||||
|
||||
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
|
||||
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
|
||||
--ch-sidebar: #1e1f23;
|
||||
|
||||
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
|
||||
--el-color-primary: #4a9eff;
|
||||
--el-color-primary-light-3: #337fd6;
|
||||
--el-color-primary-light-5: #2563ab;
|
||||
--el-color-primary-light-7: #1b4a82;
|
||||
--el-color-primary-light-8: #163c6b;
|
||||
--el-color-primary-light-9: #122f55;
|
||||
--el-color-primary-dark-2: #6cb4ff;
|
||||
--el-text-color-primary: #e8e8ea;
|
||||
--el-text-color-regular: #c9cacd;
|
||||
--el-text-color-secondary: #9a9b9f;
|
||||
--el-border-color: rgba(255, 255, 255, 0.12);
|
||||
--el-border-color-light: rgba(255, 255, 255, 0.08);
|
||||
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -179,12 +242,12 @@ textarea {
|
||||
}
|
||||
|
||||
.el-button--default {
|
||||
background: rgba(255, 255, 255, 0.7) !important;
|
||||
background: var(--ch-layer) !important;
|
||||
border-color: var(--ch-border-strong) !important;
|
||||
}
|
||||
|
||||
.el-button--default:hover {
|
||||
background: rgba(0, 0, 0, 0.04) !important;
|
||||
background: var(--ch-hover-soft) !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
@@ -195,7 +258,7 @@ textarea {
|
||||
.el-textarea__inner,
|
||||
.el-select__wrapper {
|
||||
border-radius: var(--ch-radius-control) !important;
|
||||
background: rgba(255, 255, 255, 0.7) !important;
|
||||
background: var(--ch-layer) !important;
|
||||
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
|
||||
}
|
||||
|
||||
@@ -224,12 +287,38 @@ textarea {
|
||||
box-shadow: var(--ch-shadow-flyout) !important;
|
||||
}
|
||||
|
||||
/* 关于页的折叠卡片:与 el-card 同一套观感(分割线代替卡片描边) */
|
||||
.el-collapse {
|
||||
--el-collapse-border-color: var(--ch-divider);
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.el-collapse-item__header {
|
||||
background: var(--ch-layer) !important;
|
||||
border: 1px solid var(--ch-border) !important;
|
||||
border-radius: var(--ch-radius-card) !important;
|
||||
padding: 0 16px;
|
||||
margin-top: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.el-collapse-item__wrap {
|
||||
background: transparent !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.el-collapse-item__content {
|
||||
padding: 12px 6px 4px !important;
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 表格 */
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: var(--ch-divider);
|
||||
--el-table-header-bg-color: transparent;
|
||||
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
|
||||
--el-table-row-hover-bg-color: var(--ch-hover-soft);
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -251,12 +340,13 @@ textarea {
|
||||
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
|
||||
|
||||
.ch-nav {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
background: var(--ch-nav-bg);
|
||||
backdrop-filter: blur(24px) saturate(1.2);
|
||||
border-right: 1px solid var(--ch-border);
|
||||
}
|
||||
|
||||
.ch-nav .el-menu-item {
|
||||
.ch-nav .el-menu-item,
|
||||
.ch-nav .el-sub-menu__title {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
margin: 2px 8px;
|
||||
@@ -266,8 +356,19 @@ textarea {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ch-nav .el-menu-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
.ch-nav .el-menu-item:hover,
|
||||
.ch-nav .el-sub-menu__title:hover {
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
|
||||
.ch-nav .el-sub-menu__title {
|
||||
font-weight: 600;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.ch-nav .el-sub-menu .el-menu {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
|
||||
@@ -288,6 +389,58 @@ textarea {
|
||||
background: var(--ch-accent);
|
||||
}
|
||||
|
||||
/* 折叠态:图标居中(el-menu collapse 自带收窄,这里压掉分组缩进) */
|
||||
.ch-nav .el-menu--collapse .el-menu-item,
|
||||
.ch-nav .el-menu--collapse .el-sub-menu__title {
|
||||
margin: 2px 8px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* 折叠态:把菜单文字**彻底不渲染**,只留图标槽。
|
||||
*
|
||||
* Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 + overflow",一旦外层样式
|
||||
* (缩进、margin、transition)稍有干扰,文字就会以 0 宽溢出的形式留在画面上 ——
|
||||
* 表现为折叠后菜单里残留"半截字"。这里直接 display:none,从根上消除残影。
|
||||
* 图标槽(.menu-icon-slot)必须保留(它同时承载未读红点)。
|
||||
*/
|
||||
.ch-nav .el-menu--collapse .el-menu-item > span:not(.menu-icon-slot),
|
||||
.ch-nav .el-menu--collapse .el-sub-menu__title > span:not(.menu-icon-slot) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 折叠态:图标在 64px 栏内严格水平居中,且与顶部的汉堡按钮同一竖列(中心都在 32px) */
|
||||
.ch-nav .el-menu--collapse .el-menu-item,
|
||||
.ch-nav .el-menu--collapse .el-sub-menu__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* 折叠态点分组图标会弹出子菜单浮层 —— 它被 teleport 到 body 下,
|
||||
* 不受 .ch-nav 作用域影响,所以这些规则写在全局;不写的话深色主题下会是浅色浮层。
|
||||
*/
|
||||
.el-menu--popup {
|
||||
background: var(--ch-layer-solid);
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: var(--ch-radius-card);
|
||||
box-shadow: var(--ch-shadow-flyout);
|
||||
}
|
||||
|
||||
.el-menu--popup .el-menu-item {
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
.el-menu--popup .el-menu-item:hover {
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
.el-menu--popup .el-menu-item.is-active {
|
||||
background: var(--ch-accent-soft);
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 课表时间轴 */
|
||||
|
||||
.timetable-cell {
|
||||
@@ -320,14 +473,14 @@ textarea {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
background: var(--ch-scrollbar);
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--ch-scrollbar-hover);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
+19
@@ -47,6 +47,17 @@ export interface DesktopStoredConfig {
|
||||
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
|
||||
*/
|
||||
notificationChannel: ClassIslandNotificationChannel;
|
||||
/**
|
||||
* 是否已看过「初次启动引导」。
|
||||
*
|
||||
* 首次启动自动弹出一次(完成或跳过即记为看过并写入配置,之后不再自动弹出);
|
||||
* 登录页与设置页的「使用引导」入口可随时重看(重看不改变该值)。
|
||||
*/
|
||||
onboardingDone: boolean;
|
||||
/** 界面主题:light=浅色(默认)/ dark=深色(黑夜模式) */
|
||||
theme: 'light' | 'dark';
|
||||
/** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */
|
||||
sidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopAppInfo {
|
||||
@@ -56,6 +67,8 @@ export interface DesktopAppInfo {
|
||||
node: string;
|
||||
platform: string;
|
||||
userDataPath: string;
|
||||
/** 主进程配置文件(config.json)的绝对路径(关于页「诊断信息」展示) */
|
||||
configPath: string;
|
||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
|
||||
smokeTest: boolean;
|
||||
}
|
||||
@@ -209,6 +222,12 @@ declare global {
|
||||
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
|
||||
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 初次启动引导自检(首启自动出现 → 真实点击走完 → 完成状态写入配置) */
|
||||
onboardingSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 主界面外观件自检(真实点击:主题切换 html.dark + 落盘、侧边栏折叠 + 落盘) */
|
||||
layoutChromeSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 关于页自检(/settings/about 渲染 + getAppInfo 含 configPath) */
|
||||
aboutPageSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user