This commit is contained in:
lincube
2026-02-13 23:21:17 +08:00
parent ef1ccd439b
commit aa0154db25
9 changed files with 3855 additions and 601 deletions

File diff suppressed because one or more lines are too long

View File

@@ -3,8 +3,8 @@ const electron = require("electron");
const path = require("path");
const utils = require("@electron-toolkit/utils");
const elysia = require("elysia");
const fs = require("fs");
const child_process = require("child_process");
const fs = require("fs");
const util = require("util");
const node = require("@elysiajs/node");
const icon = path.join(__dirname, "../../resources/icon.png");
@@ -20,7 +20,7 @@ function getStartMenuRoots() {
const { userProgramsPath, commonProgramsPath } = getStartMenuPaths();
return [userProgramsPath, commonProgramsPath].filter((p) => Boolean(p));
}
const execFileAsync$1 = util.promisify(child_process.execFile);
const execFileAsync$2 = util.promisify(child_process.execFile);
function getWindowsPowerShellExe() {
const systemRoot = process.env["SystemRoot"] ?? process.env["WINDIR"] ?? "C:\\Windows";
return path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
@@ -124,7 +124,7 @@ async function listWindowsStartMenuApps() {
const roots = getStartMenuRoots();
const script = buildPowerShellScript(roots);
const powershellExe = getWindowsPowerShellExe();
const { stdout } = await execFileAsync$1(
const { stdout } = await execFileAsync$2(
powershellExe,
["-NoProfile", "-NonInteractive", "-Sta", "-ExecutionPolicy", "Bypass", "-Command", script],
{ windowsHide: true, maxBuffer: 50 * 1024 * 1024, timeout: 6e4 }
@@ -157,7 +157,7 @@ async function listWindowsStartMenuApps() {
result.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
return result;
}
const execFileAsync = util.promisify(child_process.execFile);
const execFileAsync$1 = util.promisify(child_process.execFile);
function isUnderRoot(filePath, root) {
const normalizedFile = path.resolve(filePath).toLowerCase();
const normalizedRoot = path.resolve(root).toLowerCase();
@@ -166,7 +166,7 @@ function isUnderRoot(filePath, root) {
async function launchStartMenuEntry(filePath) {
if (process.platform !== "win32") return;
if (filePath.startsWith("shell:AppsFolder\\")) {
await execFileAsync("explorer.exe", [filePath], { windowsHide: true });
await execFileAsync$1("explorer.exe", [filePath], { windowsHide: true });
return;
}
const roots = getStartMenuRoots();
@@ -179,6 +179,7 @@ async function launchStartMenuEntry(filePath) {
throw new Error(result);
}
}
const execFileAsync = util.promisify(child_process.execFile);
function createEiysiaApp(deps) {
const iconCache = /* @__PURE__ */ new Map();
const appsCacheFilePath = path.join(electron.app.getPath("userData"), "apps-cache.json");
@@ -328,6 +329,27 @@ function createEiysiaApp(deps) {
if (message === "PathNotAllowed") return new Response("Forbidden", { status: 403 });
return new Response("LaunchFailed", { status: 500 });
}
}).post("/open/external", async ({ body }) => {
const payload = body;
if (typeof payload.url !== "string") {
return new Response("BadRequest", { status: 400 });
}
const url = payload.url.trim();
if (!url) return new Response("BadRequest", { status: 400 });
const lower = url.toLowerCase();
if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("file:")) {
return new Response("Forbidden", { status: 403 });
}
try {
if (process.platform === "win32" && lower.startsWith("shell:")) {
await execFileAsync("explorer.exe", [url], { windowsHide: true });
return { ok: true };
}
await electron.shell.openExternal(url);
return { ok: true };
} catch {
return new Response("OpenFailed", { status: 500 });
}
}).get("/backend/port", () => ({
port: deps.getHttpPort()
})).post("/app/minimize", () => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,450 +0,0 @@
:root {
--ev-c-white: #ffffff;
--ev-c-white-soft: #f8f8f8;
--ev-c-white-mute: #f2f2f2;
--ev-c-black: #1b1b1f;
--ev-c-black-soft: #222222;
--ev-c-black-mute: #282828;
--ev-c-gray-1: #515c67;
--ev-c-gray-2: #414853;
--ev-c-gray-3: #32363f;
--ev-c-text-1: rgba(255, 255, 245, 0.86);
--ev-c-text-2: rgba(235, 235, 245, 0.6);
--ev-c-text-3: rgba(235, 235, 245, 0.38);
--ev-button-alt-border: transparent;
--ev-button-alt-text: var(--ev-c-text-1);
--ev-button-alt-bg: var(--ev-c-gray-3);
--ev-button-alt-hover-border: transparent;
--ev-button-alt-hover-text: var(--ev-c-text-1);
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
}
:root {
--color-background: var(--ev-c-black);
--color-background-soft: var(--ev-c-black-soft);
--color-background-mute: var(--ev-c-black-mute);
--color-text: var(--ev-c-text-1);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
ul {
list-style: none;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%201422%20800'%20opacity='0.3'%3e%3cdefs%3e%3clinearGradient%20x1='50%25'%20y1='0%25'%20x2='50%25'%20y2='100%25'%20id='oooscillate-grad'%3e%3cstop%20stop-color='hsl(206,%2075%25,%2049%25)'%20stop-opacity='1'%20offset='0%25'%3e%3c/stop%3e%3cstop%20stop-color='hsl(331,%2090%25,%2056%25)'%20stop-opacity='1'%20offset='100%25'%3e%3c/stop%3e%3c/linearGradient%3e%3c/defs%3e%3cg%20stroke-width='1'%20stroke='url(%23oooscillate-grad)'%20fill='none'%20stroke-linecap='round'%3e%3cpath%20d='M%200%20448%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20448'%20opacity='0.05'%3e%3c/path%3e%3cpath%20d='M%200%20420%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20420'%20opacity='0.11'%3e%3c/path%3e%3cpath%20d='M%200%20392%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20392'%20opacity='0.18'%3e%3c/path%3e%3cpath%20d='M%200%20364%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20364'%20opacity='0.24'%3e%3c/path%3e%3cpath%20d='M%200%20336%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20336'%20opacity='0.30'%3e%3c/path%3e%3cpath%20d='M%200%20308%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20308'%20opacity='0.37'%3e%3c/path%3e%3cpath%20d='M%200%20280%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20280'%20opacity='0.43'%3e%3c/path%3e%3cpath%20d='M%200%20252%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20252'%20opacity='0.49'%3e%3c/path%3e%3cpath%20d='M%200%20224%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20224'%20opacity='0.56'%3e%3c/path%3e%3cpath%20d='M%200%20196%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20196'%20opacity='0.62'%3e%3c/path%3e%3cpath%20d='M%200%20168%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20168'%20opacity='0.68'%3e%3c/path%3e%3cpath%20d='M%200%20140%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20140'%20opacity='0.75'%3e%3c/path%3e%3cpath%20d='M%200%20112%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%20112'%20opacity='0.81'%3e%3c/path%3e%3cpath%20d='M%200%2084%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%2084'%20opacity='0.87'%3e%3c/path%3e%3cpath%20d='M%200%2056%20Q%20355.5%20-100%20711%20400%20Q%201066.5%20900%201422%2056'%20opacity='0.94'%3e%3c/path%3e%3c/g%3e%3c/svg%3e");
background-size: cover;
user-select: none;
}
#root {
width: 100%;
height: 100%;
}
.screen {
width: 100%;
height: 100%;
}
.touchButton {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.launcher {
position: fixed;
left: 16px;
top: 84px;
width: 520px;
height: calc(100vh - 168px);
padding: 12px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
backdrop-filter: blur(12px);
box-sizing: border-box;
}
.home {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.openAppsButton {
cursor: pointer;
border-radius: 18px;
padding: 20px 28px;
border: 1px solid rgba(255, 255, 255, 0.16);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
font-size: 22px;
line-height: 26px;
font-weight: 800;
}
.openAppsButton:hover {
border-color: rgba(255, 255, 255, 0.26);
background: rgba(0, 0, 0, 0.45);
}
.appsPage {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 16px;
box-sizing: border-box;
}
.backButton {
cursor: pointer;
border-radius: 16px;
padding: 0 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.35);
font-size: 18px;
line-height: 22px;
font-weight: 800;
height: 72px;
min-width: 120px;
}
.backButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.45);
}
.appsTilesArea {
position: fixed;
left: 16px;
right: 16px;
top: 88px;
bottom: 120px;
padding: 12px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
backdrop-filter: blur(12px);
box-sizing: border-box;
overflow: hidden;
}
.appsList {
height: 100%;
overflow-x: auto;
overflow-y: hidden;
padding: 4px 6px;
box-sizing: border-box;
columns: 6 280px;
column-gap: 14px;
column-fill: auto;
touch-action: pan-x;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
-ms-overflow-style: none;
}
.appsList::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.appsLoading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
opacity: 0.9;
}
.appsBottomBar {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
height: 92px;
padding: 10px 12px;
display: grid;
grid-template-columns: 140px 1fr 140px;
gap: 12px;
align-items: center;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
box-sizing: border-box;
}
.appsSearch {
width: 100%;
height: 72px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.35);
color: var(--ev-c-text-1);
padding: 0 16px;
box-sizing: border-box;
outline: none;
font-size: 20px;
line-height: 24px;
user-select: text;
}
.appsExitButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
border-radius: 16px;
padding: 0 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.35);
font-size: 18px;
line-height: 22px;
font-weight: 800;
height: 72px;
min-width: 120px;
}
.appsExitButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.45);
}
.search {
width: 100%;
height: 38px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.35);
color: var(--ev-c-text-1);
padding: 0 12px;
box-sizing: border-box;
outline: none;
}
.error {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 120, 120, 0.25);
background: rgba(120, 0, 0, 0.18);
color: var(--ev-c-text-1);
font-size: 12px;
line-height: 16px;
user-select: text;
white-space: pre-wrap;
word-break: break-word;
}
.errorHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.errorTitle {
font-weight: 700;
font-size: 12px;
line-height: 16px;
}
.errorBody {
white-space: pre-wrap;
word-break: break-word;
}
.copyButton {
cursor: pointer;
border-radius: 10px;
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(0, 0, 0, 0.25);
color: var(--ev-c-text-1);
font-size: 12px;
line-height: 14px;
}
.copyButton:hover {
border-color: rgba(255, 255, 255, 0.26);
background: rgba(0, 0, 0, 0.35);
}
.list {
margin-top: 12px;
height: calc(100% - 50px);
overflow: auto;
display: grid;
gap: 10px;
}
.groupHeader {
display: inline-block;
width: 100%;
box-sizing: border-box;
margin: 0 0 10px;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.4px;
opacity: 0.9;
break-inside: avoid;
-webkit-column-break-inside: avoid;
}
.item {
display: inline-flex;
align-items: center;
gap: 10px;
text-align: left;
cursor: pointer;
width: 100%;
box-sizing: border-box;
min-height: 72px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.25);
color: var(--ev-c-text-1);
margin: 0 0 10px;
break-inside: avoid;
-webkit-column-break-inside: avoid;
}
.item:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.35);
}
.item:active {
border-color: rgba(255, 255, 255, 0.26);
background: rgba(0, 0, 0, 0.5);
}
.name {
font-weight: 700;
font-size: 18px;
line-height: 22px;
}
.icon {
width: 48px;
height: 48px;
border-radius: 10px;
flex: 0 0 auto;
}
.path {
margin-top: 4px;
font-size: 12px;
line-height: 16px;
opacity: 0.75;
word-break: break-all;
}
.clock {
position: fixed;
left: 16px;
top: 16px;
padding: 10px 12px;
font-size: 28px;
line-height: 28px;
font-weight: 700;
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
backdrop-filter: blur(12px);
}
.exitButton {
position: fixed;
right: 16px;
bottom: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
text-align: center;
font-weight: 600;
border-radius: 14px;
padding: 10px 16px;
line-height: 20px;
font-size: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
}
.buttonIcon {
width: 20px;
height: 20px;
flex: 0 0 auto;
}
.exitButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.45);
}

View File

@@ -8,8 +8,8 @@
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
<script type="module" crossorigin src="./assets/index-C9aAt2YI.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DAQiHmWm.css">
<script type="module" crossorigin src="./assets/index-CkK6wThO.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BOqh2SqZ.css">
</head>
<body>

View File

@@ -1,7 +1,9 @@
import { app as electronApp, BrowserWindow } from 'electron'
import { app as electronApp, BrowserWindow, shell } from 'electron'
import { Elysia } from 'elysia'
import { execFile } from 'child_process'
import { promises as fs } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { launchStartMenuEntry, listWindowsStartMenuApps } from '../app_list'
export interface EiysiaDependencies {
@@ -9,6 +11,8 @@ export interface EiysiaDependencies {
getHttpPort: () => number | null
}
const execFileAsync = promisify(execFile)
export function createEiysiaApp(deps: EiysiaDependencies): {
handle: (request: Request) => Response | Promise<Response>
} {
@@ -216,6 +220,36 @@ export function createEiysiaApp(deps: EiysiaDependencies): {
return new Response('LaunchFailed', { status: 500 })
}
})
.post('/open/external', async ({ body }) => {
const payload = body as { url?: unknown }
if (typeof payload.url !== 'string') {
return new Response('BadRequest', { status: 400 })
}
const url = payload.url.trim()
if (!url) return new Response('BadRequest', { status: 400 })
const lower = url.toLowerCase()
if (
lower.startsWith('javascript:') ||
lower.startsWith('data:') ||
lower.startsWith('file:')
) {
return new Response('Forbidden', { status: 403 })
}
try {
if (process.platform === 'win32' && lower.startsWith('shell:')) {
await execFileAsync('explorer.exe', [url], { windowsHide: true })
return { ok: true }
}
await shell.openExternal(url)
return { ok: true }
} catch {
return new Response('OpenFailed', { status: 500 })
}
})
.get('/backend/port', () => ({
port: deps.getHttpPort()
}))

View File

@@ -1,54 +1,332 @@
<template>
<div class="screen">
<div class="clock">{{ timeText }}</div>
<div v-if="page === 'home'" class="home">
<button
class="openAppsButton touchButton"
type="button"
@pointerup="openApps"
@keydown.enter.prevent="openApps"
@keydown.space.prevent="openApps"
>
应用列表
</button>
</div>
<div
class="pagesViewport"
@pointerdown="handlePagePointerDown"
@pointermove="handlePagePointerMove"
@pointerup="handlePagePointerUp"
@pointercancel="handlePagePointerCancel"
>
<div class="pagesTrack" :data-active="mainPage">
<div class="page page--home">
<div class="home">
<div class="desktopGridShell">
<div class="desktopGrid">
<component
:is="tile.clickable ? 'button' : 'div'"
v-for="tile in placedDesktopTiles"
:key="tile.id"
class="deskTile touchButton"
:class="[
tile.variant ? `deskTile--${tile.variant}` : '',
tile.id === 'writingPanel' ? 'deskTile--panel' : ''
]"
:style="{
gridColumn: `${tile.col} / span ${tile.colSpan}`,
gridRow: `${tile.row} / span ${tile.rowSpan}`
}"
:type="tile.clickable ? 'button' : undefined"
@pointerup="tile.clickable ? handleTilePointerUp(tile) : undefined"
>
<template v-if="tile.id === 'writingPanel'">
<div class="writingPanelTitle">书写</div>
<div class="writingPanelButtons">
<button
v-for="a in writingActions"
:key="a.id"
class="writingActionButton touchButton"
type="button"
@pointerup="handleWritingActionPointerUp(a.id)"
>
<svg
class="writingActionIcon"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
>
<path fill="currentColor" :d="iconPath(a.icon)" />
</svg>
<div class="writingActionText">{{ a.label }}</div>
</button>
</div>
</template>
<template v-else>
<div class="deskTileRow">
<svg
class="deskTileIcon"
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
>
<path fill="currentColor" :d="iconPath(tile.icon)" />
</svg>
<div class="deskTileText">{{ tile.title }}</div>
</div>
</template>
</component>
</div>
</div>
<div v-else class="appsPage">
<div class="appsTilesArea">
<div v-if="isLoadingApps" class="appsLoading">加载中...</div>
<div v-else-if="loadError" class="error">
<div class="errorHeader">
<div class="errorTitle">加载失败</div>
<button class="copyButton touchButton" type="button" @pointerup="copyLoadError">复制错误</button>
<div class="notesPanel">
<div class="notesHeader">
<div class="notesHeaderTitle">作业版</div>
<button class="notesAddButton touchButton" type="button" @pointerup="addNote">新建</button>
</div>
<div class="notesGrid">
<div v-for="note in notes" :key="note.id" class="noteCard">
<textarea v-model="note.text" class="noteTextarea" placeholder="写点什么..." />
</div>
</div>
</div>
</div>
<div class="errorBody">{{ loadError }}</div>
</div>
<div v-else class="appsList" @scroll="handleAppsListScroll">
<template v-for="group in groupedApps" :key="group.key">
<div class="groupHeader">{{ group.label }}</div>
<button
v-for="app in group.items"
:key="app.id"
class="item touchButton"
type="button"
@pointerdown="handleAppPointerDown($event, app.filePath)"
@pointermove="handleAppPointerMove($event)"
@pointerup="handleAppPointerUp($event)"
@pointercancel="handleAppPointerCancel($event)"
@keydown.enter.prevent="launch(app.filePath)"
@keydown.space.prevent="launch(app.filePath)"
>
<img class="icon" :src="app.iconDataUrl" alt="" />
<div class="name">{{ app.name }}</div>
</button>
</template>
<div class="page page--apps">
<div class="appsPage">
<div class="appsTilesArea">
<div v-if="isLoadingApps" class="appsLoading">加载中...</div>
<div v-else-if="loadError" class="error">
<div class="errorHeader">
<div class="errorTitle">加载失败</div>
<button class="copyButton touchButton" type="button" @pointerup="copyLoadError">复制错误</button>
</div>
<div class="errorBody">{{ loadError }}</div>
</div>
<div v-else class="appsList" @scroll="handleAppsListScroll">
<template v-for="group in groupedApps" :key="group.key">
<div class="groupHeader">{{ group.label }}</div>
<button
v-for="app in group.items"
:key="app.id"
class="item touchButton"
type="button"
@pointerdown="handleAppPointerDown($event, app.filePath)"
@pointermove="handleAppPointerMove($event)"
@pointerup="handleAppPointerUp($event)"
@pointercancel="handleAppPointerCancel($event)"
@keydown.enter.prevent="launch(app.filePath)"
@keydown.space.prevent="launch(app.filePath)"
>
<img class="icon" :src="app.iconDataUrl" alt="" />
<div class="name">{{ app.name }}</div>
</button>
</template>
</div>
</div>
<div class="appsBottomBar">
<button class="backButton touchButton" type="button" @pointerup="closeApps">返回</button>
<input v-model="query" class="appsSearch" type="text" placeholder="搜索应用..." />
<button class="appsExitButton touchButton" type="button" @pointerup="handleExit">
<svg
class="buttonIcon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M8.5 9A1.5 1.5 0 0 0 10 7.5v-4A1.5 1.5 0 0 0 8.5 2h-6A1.5 1.5 0 0 0 1 3.5v4a1.5 1.5 0 0 0 1 1.415l.019.006c.15.051.313.079.481.079zm6.75-3H11V5h4.25A2.75 2.75 0 0 1 18 7.75v6.5A2.75 2.75 0 0 1 15.25 17H4.75A2.75 2.75 0 0 1 2 14.25v-4.3q.243.05.5.05H3v4.25c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0 0 17 14.25v-6.5A1.75 1.75 0 0 0 15.25 6M14 12.293l-2.646-2.647a.5.5 0 0 0-.708.708L13.293 13H11.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 .5-.497V10.5a.5.5 0 0 0-1 0z"
/>
</svg>
回到Window
</button>
</div>
</div>
</div>
</div>
</div>
<div class="appsBottomBar">
<button class="backButton touchButton" type="button" @pointerup="closeApps">返回</button>
<input v-model="query" class="appsSearch" type="text" placeholder="搜索应用..." />
<div v-if="isSettingsOpen" class="settingsPage">
<div class="settingsContentArea">
<div class="settingsSidebar">
<div class="settingsSidebarTitle">设置</div>
<button
class="settingsTabButton touchButton"
type="button"
:data-active="settingsTab === 'homeEdit'"
@pointerup="selectSettingsTab('homeEdit')"
>
主页编辑
</button>
</div>
<div class="settingsDetail">
<div class="settingsDetailHeader">
<div class="settingsDetailTitle">主页编辑</div>
<div class="settingsDetailHeaderActions">
<button
v-if="!isHomeEditMode"
class="settingsPrimaryButton touchButton"
type="button"
@pointerup="enterHomeEdit"
>
进入主页编辑
</button>
<button
v-else
class="settingsPrimaryButton touchButton"
type="button"
@pointerup="exitHomeEdit"
>
退出编辑
</button>
<button class="settingsResetButton touchButton" type="button" @pointerup="restoreDefaultWidgets">
恢复默认
</button>
</div>
</div>
<div class="settingsDetailBody">
<template v-if="!isHomeEditMode">
<div class="settingsSection">
<div class="settingsSectionTitle">组件与点击行为</div>
<div class="settingsPreviewList">
<div v-for="w in settingsWidgets" :key="w.id" class="settingsPreviewItem">
<div class="settingsPreviewMain">
<div class="settingsPreviewTitle">{{ w.title }}</div>
<div class="settingsPreviewMeta">
{{ w.sizeLabel }} · {{ w.enabled ? '已添加' : '未添加' }} · {{ w.visible ? '显示中' : '未显示' }}
</div>
</div>
<div class="settingsPreviewBehavior">
<template v-if="w.id === 'writingPanel'">
<div class="settingsPreviewBehaviorTitle">内置按钮</div>
<div v-for="a in writingActions" :key="a.id" class="settingsPreviewBehaviorRow">
<div class="settingsPreviewBehaviorLabel">{{ a.label }}</div>
<div class="settingsPreviewBehaviorValue">{{ actionSummary(a.id) }}</div>
</div>
</template>
<template v-else>
<div class="settingsPreviewBehaviorTitle">点击行为</div>
<div class="settingsPreviewBehaviorValue">{{ actionSummary(w.id) }}</div>
</template>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="settingsSection">
<div class="settingsSectionTitle">主页组件点按添加/移除</div>
<div class="homeEditGrid">
<button
v-for="w in widgetsInOrder"
:key="w.id"
class="homeWidgetCard touchButton"
type="button"
:data-active="widgetsEnabled[w.id] !== false"
:data-selected="homeEditSelectedId === w.id"
@pointerup="toggleHomeWidget(w.id)"
>
<svg
class="homeWidgetCardIcon"
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
>
<path fill="currentColor" :d="iconPath(w.icon)" />
</svg>
<div class="homeWidgetCardTitle">{{ w.title }}</div>
<div class="homeWidgetCardMeta">{{ w.size === '2x4' ? '2×4' : w.size === '4x2' ? '4×2' : '2×2' }}</div>
</button>
</div>
</div>
<div class="settingsSection">
<div class="settingsSectionTitle">排序仅已添加</div>
<div class="homeEditOrderList">
<div v-for="id in enabledWidgetOrder" :key="id" class="homeEditOrderRow">
<div class="homeEditOrderTitle">{{ widgetTitle(id) }}</div>
<div class="homeEditOrderActions">
<button class="settingsActionButton touchButton" type="button" @pointerup="moveEnabledWidget(id, -1)">
上移
</button>
<button class="settingsActionButton touchButton" type="button" @pointerup="moveEnabledWidget(id, 1)">
下移
</button>
<button class="settingsActionButton touchButton" type="button" @pointerup="selectHomeWidget(id)">
设置
</button>
</div>
</div>
</div>
</div>
<div class="settingsSection">
<div class="settingsSectionTitle">详细设置{{ widgetTitle(homeEditSelectedId) }}</div>
<div class="settingsConfigArea">
<template v-if="homeEditSelectedId === 'writingPanel'">
<div v-for="a in writingActions" :key="a.id" class="settingsSubAction">
<div class="settingsSubActionTitle">{{ a.label }}</div>
<select
class="settingsSelect touchButton"
:value="actionConfigs[a.id]?.kind ?? 'url'"
@change="setActionKind(a.id, ($event.target as HTMLSelectElement).value as ActionKind)"
>
<option value="app">打开应用</option>
<option value="url">打开URL</option>
</select>
<input
class="settingsInput"
type="text"
:value="actionConfigs[a.id]?.target ?? ''"
@input="setActionTarget(a.id, ($event.target as HTMLInputElement).value)"
placeholder="输入开始菜单路径或URL"
/>
<button class="settingsMiniButton touchButton" type="button" @pointerup="clearAction(a.id)">
清空
</button>
</div>
</template>
<template v-else>
<div class="settingsSingleActionRow">
<select
class="settingsSelect touchButton"
:value="actionConfigs[homeEditSelectedId]?.kind ?? 'url'"
@change="
setActionKind(homeEditSelectedId, ($event.target as HTMLSelectElement).value as ActionKind)
"
>
<option value="app">打开应用</option>
<option value="url">打开URL</option>
</select>
<input
class="settingsInput"
type="text"
:value="actionConfigs[homeEditSelectedId]?.target ?? ''"
@input="setActionTarget(homeEditSelectedId, ($event.target as HTMLInputElement).value)"
placeholder="输入开始菜单路径或URL"
/>
<button class="settingsMiniButton touchButton" type="button" @pointerup="clearAction(homeEditSelectedId)">
清空
</button>
</div>
<div class="settingsActionHint">当前{{ actionSummary(homeEditSelectedId) }}</div>
</template>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="settingsBottomBar">
<button class="backButton touchButton" type="button" @pointerup="closeSettings">返回</button>
<button class="appsExitButton touchButton" type="button" @pointerup="handleExit">
<svg
class="buttonIcon"
@@ -64,11 +342,11 @@
d="M8.5 9A1.5 1.5 0 0 0 10 7.5v-4A1.5 1.5 0 0 0 8.5 2h-6A1.5 1.5 0 0 0 1 3.5v4a1.5 1.5 0 0 0 1 1.415l.019.006c.15.051.313.079.481.079zm6.75-3H11V5h4.25A2.75 2.75 0 0 1 18 7.75v6.5A2.75 2.75 0 0 1 15.25 17H4.75A2.75 2.75 0 0 1 2 14.25v-4.3q.243.05.5.05H3v4.25c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0 0 17 14.25v-6.5A1.75 1.75 0 0 0 15.25 6M14 12.293l-2.646-2.647a.5.5 0 0 0-.708.708L13.293 13H11.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 .5-.497V10.5a.5.5 0 0 0-1 0z"
/>
</svg>
Windows
回到Window
</button>
</div>
</div>
<button v-if="page === 'home'" class="exitButton touchButton" type="button" @pointerup="handleExit">
<button v-if="mainPage === 'home' && !isSettingsOpen" class="exitButton touchButton" type="button" @pointerup="handleExit">
<svg
class="buttonIcon"
xmlns="http://www.w3.org/2000/svg"
@@ -83,27 +361,301 @@
d="M8.5 9A1.5 1.5 0 0 0 10 7.5v-4A1.5 1.5 0 0 0 8.5 2h-6A1.5 1.5 0 0 0 1 3.5v4a1.5 1.5 0 0 0 1 1.415l.019.006c.15.051.313.079.481.079zm6.75-3H11V5h4.25A2.75 2.75 0 0 1 18 7.75v6.5A2.75 2.75 0 0 1 15.25 17H4.75A2.75 2.75 0 0 1 2 14.25v-4.3q.243.05.5.05H3v4.25c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0 0 17 14.25v-6.5A1.75 1.75 0 0 0 15.25 6M14 12.293l-2.646-2.647a.5.5 0 0 0-.708.708L13.293 13H11.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 .5-.497V10.5a.5.5 0 0 0-1 0z"
/>
</svg>
Windows
回到Window
</button>
<button
v-if="mainPage === 'home' && !isSettingsOpen"
class="settingsButton touchButton"
type="button"
@pointerup="openSettings"
>
设置
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
type Page = 'home' | 'apps'
type MainPage = 'home' | 'apps'
const now = ref(new Date())
const page = ref<Page>('home')
const mainPage = ref<MainPage>('home')
const isSettingsOpen = ref(false)
const apps = ref<Array<{ id: string; name: string; filePath: string; iconDataUrl: string }>>([])
const query = ref('')
const loadError = ref<string | null>(null)
const isLoadingApps = ref(false)
const isAppsScrolling = ref(false)
type Note = { id: string; text: string }
const notes = ref<Note[]>([])
type ActionKind = 'app' | 'url'
type ActionConfig = { kind: ActionKind; target: string }
type WidgetSize = '2x2' | '4x2' | '2x4'
type TileIcon = 'pen' | 'folder' | 'camera' | 'globe' | 'apps' | 'note' | 'doc' | 'comment'
type DesktopTile = {
id: string
title: string
size: WidgetSize
icon: TileIcon
clickable: boolean
onActivate?: () => void
variant?: 'accent' | 'soft'
}
type PlacedDesktopTile = DesktopTile & {
row: number
col: number
rowSpan: number
colSpan: number
}
const iconPath = (icon: TileIcon): string => {
switch (icon) {
case 'pen':
return 'M14.1 2.65a2.25 2.25 0 0 1 3.182 3.182l-8.6 8.6a2.25 2.25 0 0 1-1.04.576l-3.1.775a.75.75 0 0 1-.91-.91l.775-3.1a2.25 2.25 0 0 1 .576-1.04zm2.121 1.06a.75.75 0 0 0-1.06 0L6.7 12.174a.75.75 0 0 0-.192.346l-.44 1.76 1.76-.44a.75.75 0 0 0 .346-.192l8.462-8.462a.75.75 0 0 0 0-1.06z'
case 'folder':
return 'M3.5 4.5A2.5 2.5 0 0 1 6 2h2.25c.43 0 .84.184 1.126.505L10.2 3.5H14A2.5 2.5 0 0 1 16.5 6v7.5A2.5 2.5 0 0 1 14 16H6A2.5 2.5 0 0 1 3.5 13.5zM6 3.5A1 1 0 0 0 5 4.5V5h10v-1a1 1 0 0 0-1-1h-4.1a.75.75 0 0 1-.562-.253L8.086 3.5zm9 3H5v7a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1z'
case 'camera':
return 'M7 4.5h6l.6 1.2c.127.254.387.414.67.414H15A2.5 2.5 0 0 1 17.5 8.6v6.9A2.5 2.5 0 0 1 15 18H5A2.5 2.5 0 0 1 2.5 15.5V8.6A2.5 2.5 0 0 1 5 6.114h.73c.283 0 .543-.16.67-.414zM10 15.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'
case 'globe':
return 'M10 2a8 8 0 1 1 0 16 8 8 0 0 1 0-16m5.93 7H13.6a12 12 0 0 0-1.03-4.09A6.5 6.5 0 0 1 15.93 9M10 3.5c-.9 0-1.98 1.6-2.45 5.5h4.9c-.47-3.9-1.55-5.5-2.45-5.5m-2.57 1.41A12 12 0 0 0 6.4 9H4.07a6.5 6.5 0 0 1 3.36-4.09M4.07 11H6.4c.17 1.5.56 2.92 1.03 4.09A6.5 6.5 0 0 1 4.07 11m3.48 0c.47 3.9 1.55 5.5 2.45 5.5s1.98-1.6 2.45-5.5zm5.02 4.09c.47-1.17.86-2.59 1.03-4.09h2.33a6.5 6.5 0 0 1-3.36 4.09'
case 'apps':
return 'M4.5 3A1.5 1.5 0 0 0 3 4.5v2A1.5 1.5 0 0 0 4.5 8h2A1.5 1.5 0 0 0 8 6.5v-2A1.5 1.5 0 0 0 6.5 3zm9 0A1.5 1.5 0 0 0 12 4.5v2A1.5 1.5 0 0 0 13.5 8h2A1.5 1.5 0 0 0 17 6.5v-2A1.5 1.5 0 0 0 15.5 3zM3 13.5A1.5 1.5 0 0 1 4.5 12h2A1.5 1.5 0 0 1 8 13.5v2A1.5 1.5 0 0 1 6.5 17h-2A1.5 1.5 0 0 1 3 15.5zm10.5-1.5A1.5 1.5 0 0 0 12 13.5v2a1.5 1.5 0 0 0 1.5 1.5h2a1.5 1.5 0 0 0 1.5-1.5v-2a1.5 1.5 0 0 0-1.5-1.5z'
case 'note':
return 'M6 2.5A2.5 2.5 0 0 0 3.5 5v10A2.5 2.5 0 0 0 6 17.5h5.5a.75.75 0 0 0 .53-.22l3.25-3.25a.75.75 0 0 0 .22-.53V5A2.5 2.5 0 0 0 13 2.5zm6.5 12.94V14a.5.5 0 0 1 .5-.5h1.44z'
case 'doc':
return 'M6 2.5A2.5 2.5 0 0 0 3.5 5v10A2.5 2.5 0 0 0 6 17.5h8A2.5 2.5 0 0 0 16.5 15V7.75a.75.75 0 0 0-.22-.53l-3.5-3.5a.75.75 0 0 0-.53-.22zM12.5 4.56 14.44 6.5H13a.5.5 0 0 1-.5-.5z'
case 'comment':
return 'M5.5 3.5A2.5 2.5 0 0 0 3 6v6a2.5 2.5 0 0 0 2.5 2.5H7.3l2.2 1.76a.75.75 0 0 0 .94 0l2.2-1.76H14.5A2.5 2.5 0 0 0 17 12V6a2.5 2.5 0 0 0-2.5-2.5z'
}
}
const writingActions: Array<{ id: string; label: string; icon: TileIcon }> = [
{ id: 'writing.whiteboard', label: '白板书写', icon: 'pen' },
{ id: 'writing.annotate', label: '智能批注', icon: 'comment' },
{ id: 'writing.historyNotes', label: '历史笔记', icon: 'note' },
{ id: 'writing.classNotes', label: '课堂笔记', icon: 'note' },
{ id: 'writing.historyDocs', label: '历史文档', icon: 'doc' }
]
const actionConfigs = ref<Record<string, ActionConfig>>({})
let actionSaveTimerId: number | undefined
type SettingsTab = 'homeEdit'
const settingsTab = ref<SettingsTab>('homeEdit')
const isHomeEditMode = ref(false)
const homeEditSelectedId = ref<string>('whiteboard')
let pageSwipePointerId: number | null = null
let pageSwipeStartX = 0
let pageSwipeStartY = 0
let pageSwipeStartTime = 0
let pageSwipeStartMainPage: MainPage = 'home'
let suppressTapUntil = 0
const availableWidgets: DesktopTile[] = [
{ id: 'writingPanel', title: '书写', size: '2x4', icon: 'pen', clickable: false, variant: 'soft' },
{
id: 'whiteboard',
title: '白板书写',
size: '2x2',
icon: 'pen',
clickable: true,
onActivate: () => void activateAction('whiteboard'),
variant: 'soft'
},
{
id: 'fileManager',
title: '文件管理',
size: '2x2',
icon: 'folder',
clickable: true,
onActivate: () => void activateAction('fileManager')
},
{
id: 'visualPresenter',
title: '视频展台',
size: '2x2',
icon: 'camera',
clickable: true,
onActivate: () => void activateAction('visualPresenter')
},
{
id: 'browser',
title: '浏览器',
size: '2x2',
icon: 'globe',
clickable: true,
onActivate: () => void activateAction('browser')
},
{
id: 'moreApps',
title: '更多应用',
size: '2x2',
icon: 'apps',
clickable: true,
onActivate: () => void activateAction('moreApps', () => void openApps()),
variant: 'accent'
}
]
const widgetsOrder = ref<string[]>(['writingPanel', 'whiteboard', 'fileManager', 'visualPresenter', 'browser', 'moreApps'])
const widgetsEnabled = ref<Record<string, boolean>>({
writingPanel: true,
whiteboard: true,
fileManager: true,
visualPresenter: true,
browser: true,
moreApps: true
})
let widgetsSaveTimerId: number | undefined
const widgetsInOrder = computed<DesktopTile[]>(() => {
const map = new Map(availableWidgets.map((w) => [w.id, w]))
const seen = new Set<string>()
const ordered: DesktopTile[] = []
for (const id of widgetsOrder.value) {
const w = map.get(id)
if (!w) continue
if (seen.has(id)) continue
seen.add(id)
ordered.push(w)
}
for (const w of availableWidgets) {
if (seen.has(w.id)) continue
ordered.push(w)
}
return ordered
})
const enabledWidgets = computed<DesktopTile[]>(() => {
return widgetsInOrder.value.filter((w) => widgetsEnabled.value[w.id] !== false)
})
const placedDesktopTiles = computed<PlacedDesktopTile[]>(() => {
const cols = 4
const rows = 4
const occupied = Array.from({ length: rows }, () => Array.from({ length: cols }, () => false))
const tryPlace = (tile: DesktopTile): PlacedDesktopTile | null => {
const rowSpan = tile.size === '2x4' ? 4 : 2
const colSpan = tile.size === '4x2' ? 4 : 2
for (let r = 1; r <= rows - rowSpan + 1; r += 1) {
for (let c = 1; c <= cols - colSpan + 1; c += 1) {
let ok = true
for (let rr = r; rr < r + rowSpan; rr += 1) {
for (let cc = c; cc < c + colSpan; cc += 1) {
if (occupied[rr - 1]?.[cc - 1]) {
ok = false
break
}
}
if (!ok) break
}
if (!ok) continue
for (let rr = r; rr < r + rowSpan; rr += 1) {
for (let cc = c; cc < c + colSpan; cc += 1) {
occupied[rr - 1][cc - 1] = true
}
}
return { ...tile, row: r, col: c, rowSpan, colSpan }
}
}
return null
}
const out: PlacedDesktopTile[] = []
for (const tile of enabledWidgets.value) {
const placed = tryPlace(tile)
if (placed) out.push(placed)
}
return out
})
const placedWidgetIds = computed(() => new Set(placedDesktopTiles.value.map((t) => t.id)))
const settingsWidgets = computed(() => {
return widgetsInOrder.value.map((w) => {
const sizeLabel = w.size === '2x4' ? '2×4' : w.size === '4x2' ? '4×2' : '2×2'
const enabled = widgetsEnabled.value[w.id] !== false
const visible = placedWidgetIds.value.has(w.id)
return { id: w.id, title: w.title, sizeLabel, enabled, visible }
})
})
const widgetTitle = (id: string): string => {
if (id === 'writingPanel') return '书写'
const found = availableWidgets.find((w) => w.id === id)
if (found) return found.title
const foundAction = writingActions.find((a) => a.id === id)
if (foundAction) return foundAction.label
return id
}
const actionSummary = (id: string): string => {
const config = actionConfigs.value[id]
const target = config?.target?.trim() ?? ''
if (!config || !target) {
if (id === 'moreApps') return '默认:打开应用列表'
return '未配置'
}
return config.kind === 'app' ? `打开应用:${target}` : `打开URL${target}`
}
const enabledWidgetOrder = computed(() => {
return widgetsOrder.value.filter((id) => widgetsEnabled.value[id] !== false)
})
const moveEnabledWidget = (id: string, delta: -1 | 1): void => {
const enabled = enabledWidgetOrder.value
const index = enabled.indexOf(id)
if (index < 0) return
const nextIndex = index + delta
if (nextIndex < 0 || nextIndex >= enabled.length) return
const otherId = enabled[nextIndex]
const full = [...widgetsOrder.value]
const a = full.indexOf(id)
const b = full.indexOf(otherId)
if (a < 0 || b < 0) return
full[a] = otherId
full[b] = id
widgetsOrder.value = full
}
const selectSettingsTab = (tab: SettingsTab): void => {
settingsTab.value = tab
isHomeEditMode.value = false
}
const enterHomeEdit = (): void => {
settingsTab.value = 'homeEdit'
isHomeEditMode.value = true
if (!homeEditSelectedId.value) {
homeEditSelectedId.value = 'whiteboard'
}
}
const exitHomeEdit = (): void => {
isHomeEditMode.value = false
}
const selectHomeWidget = (id: string): void => {
homeEditSelectedId.value = id
}
const toggleHomeWidget = (id: string): void => {
homeEditSelectedId.value = id
toggleWidgetEnabled(id)
}
let timerId: number | undefined
let appsScrollTimerId: number | undefined
let launchTimerId: number | undefined
let notesSaveTimerId: number | undefined
const activeAppPointer = ref<{
pointerId: number
@@ -119,8 +671,121 @@ onMounted(() => {
timerId = window.setInterval(() => {
now.value = new Date()
}, 1000)
try {
const savedV2 = window.localStorage.getItem('lanmountain.notes.v2')
if (typeof savedV2 === 'string' && savedV2) {
const parsed = JSON.parse(savedV2) as { notes?: unknown }
const rawNotes = Array.isArray(parsed.notes) ? parsed.notes : []
const loaded: Note[] = rawNotes
.map((n) => {
const id = typeof (n as { id?: unknown }).id === 'string' ? (n as { id: string }).id : ''
const text = typeof (n as { text?: unknown }).text === 'string' ? (n as { text: string }).text : ''
if (!id) return null
return { id, text }
})
.filter(Boolean) as Note[]
notes.value = loaded
} else {
const savedV1 = window.localStorage.getItem('lanmountain.notes.v1')
if (typeof savedV1 === 'string' && savedV1.trim()) {
notes.value = [{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, text: savedV1 }]
window.localStorage.removeItem('lanmountain.notes.v1')
} else {
notes.value = []
}
}
} catch {}
try {
const raw = window.localStorage.getItem('lanmountain.desktop.widgets.v1')
if (typeof raw !== 'string' || !raw) return
const parsed = JSON.parse(raw) as {
order?: unknown
enabled?: unknown
}
const order = Array.isArray(parsed.order) ? parsed.order.filter((x) => typeof x === 'string') : null
const enabled =
parsed.enabled && typeof parsed.enabled === 'object' && !Array.isArray(parsed.enabled) ? parsed.enabled : null
if (order) widgetsOrder.value = order as string[]
if (enabled) {
const next: Record<string, boolean> = { ...widgetsEnabled.value }
for (const [k, v] of Object.entries(enabled as Record<string, unknown>)) {
if (typeof v === 'boolean') next[k] = v
}
widgetsEnabled.value = next
}
} catch {}
try {
const raw = window.localStorage.getItem('lanmountain.desktop.actions.v1')
if (typeof raw === 'string' && raw) {
const parsed = JSON.parse(raw) as { actions?: unknown }
const actions =
parsed.actions && typeof parsed.actions === 'object' && !Array.isArray(parsed.actions) ? parsed.actions : null
if (actions) {
const next: Record<string, ActionConfig> = {}
for (const [k, v] of Object.entries(actions as Record<string, unknown>)) {
if (!v || typeof v !== 'object' || Array.isArray(v)) continue
const kind = (v as { kind?: unknown }).kind
const target = (v as { target?: unknown }).target
if ((kind === 'app' || kind === 'url') && typeof target === 'string') {
next[k] = { kind, target }
}
}
actionConfigs.value = next
}
}
} catch {}
})
watch(
notes,
(next) => {
if (notesSaveTimerId) window.clearTimeout(notesSaveTimerId)
notesSaveTimerId = window.setTimeout(() => {
try {
window.localStorage.setItem('lanmountain.notes.v2', JSON.stringify({ notes: next }))
} catch {}
}, 250)
},
{ deep: true }
)
const addNote = (): void => {
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
notes.value = [{ id, text: '' }, ...notes.value]
}
watch(
[widgetsOrder, widgetsEnabled],
() => {
if (widgetsSaveTimerId) window.clearTimeout(widgetsSaveTimerId)
widgetsSaveTimerId = window.setTimeout(() => {
try {
window.localStorage.setItem(
'lanmountain.desktop.widgets.v1',
JSON.stringify({ order: widgetsOrder.value, enabled: widgetsEnabled.value })
)
} catch {}
}, 250)
},
{ deep: true }
)
watch(
actionConfigs,
(next) => {
if (actionSaveTimerId) window.clearTimeout(actionSaveTimerId)
actionSaveTimerId = window.setTimeout(() => {
try {
window.localStorage.setItem('lanmountain.desktop.actions.v1', JSON.stringify({ actions: next }))
} catch {}
}, 250)
},
{ deep: true }
)
const handleAppsListScroll = (): void => {
isAppsScrolling.value = true
if (appsScrollTimerId) window.clearTimeout(appsScrollTimerId)
@@ -153,21 +818,159 @@ const loadApps = async (): Promise<void> => {
}
const openApps = async (): Promise<void> => {
page.value = 'apps'
mainPage.value = 'apps'
if (apps.value.length === 0 || loadError.value) {
await loadApps()
}
}
const closeApps = (): void => {
page.value = 'home'
mainPage.value = 'home'
query.value = ''
}
const openExternal = async (url: string): Promise<void> => {
await window.api.call({ method: 'POST', path: '/open/external', body: { url } })
}
const activateAction = async (actionId: string, fallback?: () => void): Promise<void> => {
const config = actionConfigs.value[actionId]
const target = config?.target?.trim() ?? ''
if (!config || !target) {
fallback?.()
return
}
if (config.kind === 'app') {
await launch(target)
return
}
await openExternal(target)
}
const openSettings = (): void => {
isSettingsOpen.value = true
settingsTab.value = 'homeEdit'
isHomeEditMode.value = false
}
const closeSettings = (): void => {
isSettingsOpen.value = false
isHomeEditMode.value = false
}
const handleTilePointerUp = async (tile: DesktopTile): Promise<void> => {
if (Date.now() < suppressTapUntil) return
await tile.onActivate?.()
}
const handleWritingActionPointerUp = async (actionId: string): Promise<void> => {
if (Date.now() < suppressTapUntil) return
await activateAction(actionId)
}
const handlePagePointerDown = (event: PointerEvent): void => {
if (isSettingsOpen.value) return
if (pageSwipePointerId !== null) return
const tag = (event.target as HTMLElement | null)?.tagName?.toLowerCase() ?? ''
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
if (mainPage.value === 'apps' && event.clientX > 36) {
return
}
pageSwipePointerId = event.pointerId
pageSwipeStartX = event.clientX
pageSwipeStartY = event.clientY
pageSwipeStartTime = Date.now()
pageSwipeStartMainPage = mainPage.value
}
const handlePagePointerMove = (event: PointerEvent): void => {
if (pageSwipePointerId === null) return
if (event.pointerId !== pageSwipePointerId) return
const dx = event.clientX - pageSwipeStartX
const dy = event.clientY - pageSwipeStartY
if (Math.abs(dx) < 12 && Math.abs(dy) < 12) return
if (Math.abs(dx) > Math.abs(dy) + 24 && Math.abs(dx) > 60) {
suppressTapUntil = Date.now() + 350
}
}
const handlePagePointerUp = (event: PointerEvent): void => {
if (pageSwipePointerId === null) return
if (event.pointerId !== pageSwipePointerId) return
const dt = Date.now() - pageSwipeStartTime
const dx = event.clientX - pageSwipeStartX
const dy = event.clientY - pageSwipeStartY
pageSwipePointerId = null
if (dt > 900) return
if (Math.abs(dx) < 90) return
if (Math.abs(dx) <= Math.abs(dy) + 28) return
suppressTapUntil = Date.now() + 450
if (pageSwipeStartMainPage === 'home' && dx < -90) {
void openApps()
return
}
if (pageSwipeStartMainPage === 'apps' && dx > 90 && pageSwipeStartX <= 36) {
closeApps()
}
}
const handlePagePointerCancel = (event: PointerEvent): void => {
if (pageSwipePointerId === null) return
if (event.pointerId !== pageSwipePointerId) return
pageSwipePointerId = null
}
const restoreDefaultWidgets = (): void => {
widgetsOrder.value = ['writingPanel', 'whiteboard', 'fileManager', 'visualPresenter', 'browser', 'moreApps']
widgetsEnabled.value = {
writingPanel: true,
whiteboard: true,
fileManager: true,
visualPresenter: true,
browser: true,
moreApps: true
}
}
const toggleWidgetEnabled = (id: string): void => {
const next = { ...widgetsEnabled.value }
next[id] = !(next[id] !== false)
widgetsEnabled.value = next
}
const setActionKind = (id: string, kind: ActionKind): void => {
const current = actionConfigs.value[id] ?? { kind, target: '' }
actionConfigs.value = { ...actionConfigs.value, [id]: { ...current, kind } }
}
const setActionTarget = (id: string, target: string): void => {
const current = actionConfigs.value[id] ?? { kind: 'url', target: '' }
actionConfigs.value = { ...actionConfigs.value, [id]: { ...current, target } }
}
const clearAction = (id: string): void => {
const next = { ...actionConfigs.value }
delete next[id]
actionConfigs.value = next
}
onBeforeUnmount(() => {
if (timerId) window.clearInterval(timerId)
if (appsScrollTimerId) window.clearTimeout(appsScrollTimerId)
if (launchTimerId) window.clearTimeout(launchTimerId)
if (notesSaveTimerId) window.clearTimeout(notesSaveTimerId)
if (widgetsSaveTimerId) window.clearTimeout(widgetsSaveTimerId)
if (actionSaveTimerId) window.clearTimeout(actionSaveTimerId)
})
const timeText = computed(() => {

View File

@@ -20,6 +20,36 @@ body {
height: 100%;
}
.pagesViewport {
width: 100%;
height: 100%;
overflow: hidden;
}
.pagesTrack {
width: 200%;
height: 100%;
display: flex;
flex-direction: row;
transition: transform 220ms ease;
will-change: transform;
}
.pagesTrack[data-active='home'] {
transform: translateX(0);
}
.pagesTrack[data-active='apps'] {
transform: translateX(-50%);
}
.page {
width: 50%;
height: 100%;
transform: translateZ(0);
position: relative;
}
.touchButton {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
@@ -46,8 +76,257 @@ body {
width: 100%;
height: 100%;
display: flex;
align-items: center;
align-items: stretch;
justify-content: flex-start;
}
.desktopGridShell {
position: fixed;
left: 16px;
top: 88px;
bottom: 96px;
right: calc(50vw + 8px);
display: block;
box-sizing: border-box;
}
.desktopGrid {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
grid-template-rows: repeat(4, minmax(120px, 1fr));
gap: 12px;
box-sizing: border-box;
}
.deskTile {
cursor: default;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.25);
color: var(--ev-c-text-1);
padding: 14px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
text-align: left;
overflow: hidden;
}
button.deskTile {
cursor: pointer;
}
.deskTile:hover {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.35);
}
.deskTile:active {
border-color: rgba(255, 255, 255, 0.26);
background: rgba(0, 0, 0, 0.5);
}
.deskTileTitle {
font-size: 20px;
line-height: 24px;
font-weight: 900;
}
.deskTileSubtitle {
font-size: 14px;
line-height: 18px;
font-weight: 700;
opacity: 0.85;
}
.deskTileRow {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
}
.deskTileIcon {
width: 34px;
height: 34px;
flex: 0 0 auto;
opacity: 0.95;
}
.deskTileText {
font-size: 20px;
line-height: 24px;
font-weight: 900;
}
.deskTile--panel {
justify-content: flex-start;
padding: 12px;
}
.writingPanelTitle {
font-size: 20px;
line-height: 24px;
font-weight: 900;
margin-bottom: 10px;
}
.writingPanelButtons {
width: 100%;
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.writingActionButton {
width: 100%;
height: 52px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
border-radius: 14px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: var(--ev-c-text-1);
box-sizing: border-box;
}
.writingActionButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.3);
}
.writingActionButton:active {
border-color: rgba(255, 255, 255, 0.26);
background: rgba(0, 0, 0, 0.45);
}
.writingActionIcon {
width: 20px;
height: 20px;
flex: 0 0 auto;
opacity: 0.95;
}
.writingActionText {
font-size: 16px;
line-height: 18px;
font-weight: 900;
}
.deskTile--accent {
background: rgba(0, 0, 0, 0.45);
}
.deskTile--soft {
background: rgba(0, 0, 0, 0.18);
}
.notesPanel {
position: fixed;
top: 88px;
right: 16px;
bottom: 96px;
left: calc(50vw + 8px);
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
backdrop-filter: blur(12px);
box-sizing: border-box;
overflow: hidden;
display: grid;
grid-template-rows: 56px 1fr;
}
.notesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
font-size: 18px;
line-height: 22px;
font-weight: 800;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.2);
}
.notesHeaderTitle {
font-size: 18px;
line-height: 22px;
font-weight: 900;
}
.notesAddButton {
cursor: pointer;
border-radius: 14px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.18);
font-size: 16px;
line-height: 18px;
font-weight: 800;
height: 40px;
}
.notesAddButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.32);
}
.notesGrid {
height: 100%;
overflow: auto;
padding: 12px;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: 200px;
gap: 12px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.notesGrid::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.noteCard {
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.22);
overflow: hidden;
box-sizing: border-box;
}
.noteTextarea {
width: 100%;
height: 100%;
resize: none;
border: none;
outline: none;
background: transparent;
color: var(--ev-c-text-1);
padding: 12px;
box-sizing: border-box;
font-size: 16px;
line-height: 22px;
font-weight: 700;
user-select: text;
scrollbar-width: none;
-ms-overflow-style: none;
}
.noteTextarea::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.openAppsButton {
@@ -78,6 +357,468 @@ body {
box-sizing: border-box;
}
.settingsPage {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 16px;
box-sizing: border-box;
}
.settingsContentArea {
position: fixed;
left: 16px;
right: 16px;
top: 88px;
bottom: 120px;
padding: 12px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
backdrop-filter: blur(12px);
box-sizing: border-box;
overflow: hidden;
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 1fr;
gap: 12px;
}
.settingsSidebar {
height: 100%;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
}
.settingsSidebarTitle {
font-size: 18px;
line-height: 22px;
font-weight: 900;
opacity: 0.95;
padding: 4px 4px 8px;
}
.settingsTabButton {
cursor: pointer;
height: 56px;
border-radius: 14px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.22);
font-size: 16px;
line-height: 18px;
font-weight: 900;
text-align: left;
}
.settingsTabButton[data-active='true'] {
background: rgba(0, 0, 0, 0.4);
border-color: rgba(255, 255, 255, 0.22);
}
.settingsTabButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.32);
}
.settingsDetail {
height: 100%;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.12);
padding: 12px;
box-sizing: border-box;
display: grid;
grid-template-rows: 64px 1fr;
gap: 10px;
}
.settingsDetailHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
}
.settingsDetailTitle {
font-size: 20px;
line-height: 24px;
font-weight: 900;
}
.settingsDetailHeaderActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.settingsDetailBody {
height: 100%;
overflow: auto;
padding: 0 4px 4px;
box-sizing: border-box;
scrollbar-width: none;
-ms-overflow-style: none;
}
.settingsDetailBody::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.settingsSection {
padding: 8px 2px;
}
.settingsSectionTitle {
font-size: 16px;
line-height: 18px;
font-weight: 900;
opacity: 0.9;
margin-bottom: 10px;
}
.settingsPrimaryButton {
cursor: pointer;
border-radius: 14px;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.35);
font-size: 16px;
line-height: 18px;
font-weight: 900;
height: 46px;
}
.settingsPrimaryButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.48);
}
.settingsResetButton {
cursor: pointer;
border-radius: 14px;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.22);
font-size: 16px;
line-height: 18px;
font-weight: 800;
height: 46px;
}
.settingsResetButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.35);
}
.settingsPreviewList {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.settingsPreviewItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.22);
flex-wrap: wrap;
}
.settingsPreviewMain {
min-width: 220px;
}
.settingsPreviewTitle {
font-size: 18px;
line-height: 22px;
font-weight: 900;
}
.settingsPreviewMeta {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
font-weight: 700;
opacity: 0.85;
}
.settingsPreviewBehavior {
width: 100%;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 10px;
display: grid;
gap: 8px;
}
.settingsPreviewBehaviorTitle {
font-size: 14px;
line-height: 18px;
font-weight: 900;
opacity: 0.9;
}
.settingsPreviewBehaviorRow {
display: grid;
grid-template-columns: 160px 1fr;
gap: 10px;
}
.settingsPreviewBehaviorLabel {
font-size: 14px;
line-height: 18px;
font-weight: 800;
opacity: 0.9;
}
.settingsPreviewBehaviorValue {
font-size: 14px;
line-height: 18px;
font-weight: 700;
opacity: 0.9;
word-break: break-all;
}
.homeEditGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.homeWidgetCard {
cursor: pointer;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
color: var(--ev-c-text-1);
padding: 12px;
box-sizing: border-box;
text-align: left;
display: grid;
grid-template-rows: 24px 1fr 18px;
gap: 8px;
height: 112px;
}
.homeWidgetCard[data-active='true'] {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.35);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.12),
0 0 0 1px rgba(255, 255, 255, 0.12) inset;
}
.homeWidgetCard[data-selected='true'] {
outline: 2px solid rgba(255, 255, 255, 0.3);
outline-offset: 2px;
}
.homeWidgetCardIcon {
width: 22px;
height: 22px;
opacity: 0.95;
}
.homeWidgetCardTitle {
font-size: 16px;
line-height: 18px;
font-weight: 900;
align-self: end;
}
.homeWidgetCardMeta {
font-size: 13px;
line-height: 16px;
font-weight: 700;
opacity: 0.75;
}
.homeEditOrderList {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.homeEditOrderRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
flex-wrap: wrap;
}
.homeEditOrderTitle {
font-size: 16px;
line-height: 18px;
font-weight: 900;
}
.homeEditOrderActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.settingsConfigArea {
width: 100%;
display: grid;
gap: 10px;
}
.settingsSingleActionRow {
width: 100%;
display: grid;
grid-template-columns: 140px 1fr 92px;
gap: 10px;
}
.settingsActionHint {
font-size: 13px;
line-height: 16px;
font-weight: 700;
opacity: 0.8;
word-break: break-all;
}
.settingsSelect {
height: 46px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: var(--ev-c-text-1);
padding: 0 12px;
box-sizing: border-box;
outline: none;
font-size: 16px;
line-height: 18px;
font-weight: 800;
}
.settingsInput {
height: 46px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: var(--ev-c-text-1);
padding: 0 12px;
box-sizing: border-box;
outline: none;
font-size: 16px;
line-height: 18px;
font-weight: 700;
user-select: text;
}
.settingsMiniButton {
cursor: pointer;
height: 46px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: var(--ev-c-text-1);
font-size: 16px;
line-height: 18px;
font-weight: 800;
}
.settingsMiniButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.32);
}
.settingsSubAction {
width: 100%;
display: grid;
grid-template-columns: 180px 140px 1fr 92px;
gap: 10px;
align-items: center;
}
.settingsSubActionTitle {
font-size: 16px;
line-height: 18px;
font-weight: 900;
opacity: 0.9;
}
.settingsActionButton {
cursor: pointer;
border-radius: 14px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.18);
font-size: 16px;
line-height: 18px;
font-weight: 800;
height: 46px;
min-width: 92px;
}
.settingsActionButton[data-active='true'] {
background: rgba(0, 0, 0, 0.4);
border-color: rgba(255, 255, 255, 0.22);
}
.settingsActionButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.32);
}
.settingsBottomBar {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
height: 92px;
padding: 10px 12px;
display: grid;
grid-template-columns: 140px 1fr 180px;
gap: 12px;
align-items: center;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
box-sizing: border-box;
}
.settingsBottomBar .backButton {
height: 72px;
}
.settingsBottomBar .appsExitButton {
height: 72px;
}
.backButton {
cursor: pointer;
border-radius: 16px;
@@ -372,6 +1113,31 @@ body {
backdrop-filter: blur(12px);
}
.settingsButton {
position: fixed;
left: 16px;
bottom: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-align: center;
font-weight: 800;
border-radius: 14px;
padding: 10px 18px;
line-height: 20px;
font-size: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--ev-c-text-1);
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
}
.settingsButton:hover {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.45);
}
.buttonIcon {
width: 20px;
height: 20px;