feat: 重构 settings — Vue 组件化(12 pane: Wi-Fi/蓝牙/外观/墙纸等全部声明式)+ 9 测试
This commit is contained in:
parent
6cb0da5b77
commit
c92aca7804
@ -1 +1,176 @@
|
|||||||
<template><div></div></template>
|
<template>
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="set-sidebar">
|
||||||
|
<div style="padding:8px 8px 4px">
|
||||||
|
<input class="text-input set-search" type="search" placeholder="搜索" v-model="query" @input="onSearch">
|
||||||
|
</div>
|
||||||
|
<div class="set-nav">
|
||||||
|
<template v-for="g in filteredGroups" :key="g.name">
|
||||||
|
<div class="fb-side-title">{{ g.name }}</div>
|
||||||
|
<div v-for="p in g.panes" :key="p.id"
|
||||||
|
class="fb-side-item set-nav-item" :class="{ sel: pane === p.id }"
|
||||||
|
@click="go(p.id)"
|
||||||
|
>
|
||||||
|
<span class="fb-side-ico">{{ p.ico }}</span>
|
||||||
|
<span>{{ p.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="set-content" ref="contentEl">
|
||||||
|
<template v-if="currentPane">
|
||||||
|
<div class="set-pane-title">
|
||||||
|
<span style="font-size:20px">{{ currentPane.ico }}</span>
|
||||||
|
<span>{{ currentPane.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wi-Fi -->
|
||||||
|
<template v-if="pane === 'wifi'">
|
||||||
|
<div class="set-card"><div class="set-row"><div class="set-label"><span>Wi-Fi</span></div><div class="set-ctl"><div class="switch" :class="{ on: S.wifi }" role="switch" tabindex="0" @click="S.wifi = !S.wifi; apply()"></div></div></div></div>
|
||||||
|
<div class="set-section">已知网络</div>
|
||||||
|
<div class="set-card"><div v-for="(n,i) in networks" :key="n" class="set-row"><div class="set-label"><span>{{ n }}</span><small>{{ i===0?'WPA2 个人级':'开放' }}</small></div><div class="set-ctl"><button class="btn" :disabled="!S.wifi||S.wifiNetwork===n" @click="S.wifiNetwork=n;save()">{{ S.wifi&&S.wifiNetwork===n?'已连接':'连接' }}</button></div></div></div>
|
||||||
|
<div class="set-note">{{ S.wifi?'网络连接为模拟数据。':'Wi-Fi 已关闭。' }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 蓝牙 -->
|
||||||
|
<template v-if="pane === 'bluetooth'">
|
||||||
|
<div class="set-card"><div class="set-row"><div class="set-label"><span>蓝牙</span></div><div class="set-ctl"><div class="switch" :class="{ on: S.bluetooth }" role="switch" tabindex="0" @click="S.bluetooth=!S.bluetooth;apply()"></div></div></div></div>
|
||||||
|
<div class="set-section">附近的设备</div>
|
||||||
|
<div class="set-card"><div v-for="[n,t] in btDevices" :key="n" class="set-row"><div class="set-label"><span>{{ n }}</span><small>{{ t }}</small></div><div class="set-ctl"><button class="btn" :disabled="!S.bluetooth||S.btDevices[n]" @click="S.btDevices[n]=!S.btDevices[n];save()">{{ S.bluetooth&&S.btDevices[n]?'已连接':'连接' }}</button></div></div></div>
|
||||||
|
<div class="set-note">蓝牙设备为模拟数据。</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 外观 -->
|
||||||
|
<template v-if="pane === 'appearance'">
|
||||||
|
<div class="appearance-cards"><div v-for="[v,n,i] in appearanceOptions" :key="v" class="ap-card" :class="{sel:S.appearance===v}" @click="S.appearance=v;save();Sys.applyAppearance()"><div class="ap-preview" :class="'ap-'+v">{{ i }}</div><div class="ap-name">{{ n }}</div></div></div>
|
||||||
|
<div class="set-section">强调色</div>
|
||||||
|
<div class="accent-row"><button v-for="col in accentColors" :key="col" class="accent-dot" :class="{sel:S.accent===col}" :style="{background:col}" @click="S.accent=col;save();Sys.applyAll()"></button></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 桌面与程序坞 -->
|
||||||
|
<template v-if="pane === 'dock'">
|
||||||
|
<div class="set-card">
|
||||||
|
<div class="set-row"><div class="set-label"><span>大小</span></div><div class="set-ctl"><input type="range" class="slider" min="36" max="72" :value="S.dockSize" @input="onDockSize"></div></div>
|
||||||
|
<div class="set-row"><div class="set-label"><span>自动隐藏</span></div><div class="set-ctl"><div class="switch" :class="{on:S.dockAutohide}" role="switch" tabindex="0" @click="S.dockAutohide=!S.dockAutohide;save();Sys.layoutDock()"></div></div></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 通知 -->
|
||||||
|
<template v-if="pane === 'notifications'">
|
||||||
|
<div class="set-card"><div class="set-row"><div class="set-label"><span>允许通知</span><small>关闭后不产生横幅、通知记录或徽标</small></div><div class="set-ctl"><div class="switch" :class="{on:S.notificationsEnabled}" role="switch" tabindex="0" @click="S.notificationsEnabled=!S.notificationsEnabled;apply()"></div></div></div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 关于本机 -->
|
||||||
|
<template v-if="pane === 'about'">
|
||||||
|
<div class="set-card">
|
||||||
|
<div class="set-row"><div class="set-label"><span>电脑名称</span></div><div class="set-ctl"><span>{{ S.computerName }}</span></div></div>
|
||||||
|
<div class="set-row"><div class="set-label"><span>系统版本</span></div><div class="set-ctl"><span>macOS 网页版 1.0 (Sonoma 风格)</span></div></div>
|
||||||
|
<div class="set-row"><div class="set-label"><span>内存</span></div><div class="set-ctl"><span>16 GB</span></div></div>
|
||||||
|
<div class="set-row"><div class="set-label"><span>启动磁盘</span></div><div class="set-ctl"><span>Macintosh HD(虚拟)</span></div></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 锁定屏幕 -->
|
||||||
|
<template v-if="pane === 'lockscreen'">
|
||||||
|
<div class="set-card"><div class="set-row"><div class="set-label"><span>锁屏密码</span></div><div class="set-ctl"><div class="switch" :class="{on:S.passwordEnabled}" role="switch" tabindex="0" @click="S.passwordEnabled=!S.passwordEnabled;apply()"></div></div></div></div>
|
||||||
|
<div class="set-note">密码仅用于演示,保存在本地浏览器。</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 墙纸 -->
|
||||||
|
<template v-if="pane === 'wallpaper'">
|
||||||
|
<div class="wall-grid"><div v-for="w in wallpapers" :key="w.id" class="wall-cell" :class="{sel:S.wallpaper===w.id}" @click="S.wallpaper=w.id;save();Sys.applyWallpaper()"><img :src="w.src" alt="" :title="w.name"><div class="wall-name">{{ w.name }}</div></div></div>
|
||||||
|
<div class="set-note">锁屏与桌面共享同一墙纸。</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 用户与群组 -->
|
||||||
|
<template v-if="pane === 'users'">
|
||||||
|
<div class="set-card"><div class="set-row"><div class="set-label"><span>用户名</span></div><div class="set-ctl"><input class="text-input" :value="S.userName" @change="onUserNameChange"></div></div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 日期与时间 -->
|
||||||
|
<template v-if="pane === 'datetime'">
|
||||||
|
<div class="set-card">
|
||||||
|
<div class="set-row"><div class="set-label"><span>24 小时制</span></div><div class="set-ctl"><div class="switch" :class="{on:S.h24}" role="switch" tabindex="0" @click="S.h24=!S.h24;save();Sys.tickClock()"></div></div></div>
|
||||||
|
<div class="set-row"><div class="set-label"><span>时区</span></div><div class="set-ctl"><select class="text-input" :value="S.timezone||'local'" @change="onTimezoneChange"><option v-for="[v,l] in timeZones" :key="v" :value="v">{{ l }}</option></select></div></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 键盘 -->
|
||||||
|
<template v-if="pane === 'keyboard'">
|
||||||
|
<div class="set-card">
|
||||||
|
<div class="set-row"><div class="set-label"><span>重复速度</span></div><div class="set-ctl"><input type="range" class="slider" min="1" max="10" :value="S.kbRepeat" @input="S.kbRepeat=+($event.target as HTMLInputElement).value"></div></div>
|
||||||
|
<div class="set-row"><div class="set-label"><span>重复前延迟</span></div><div class="set-ctl"><input type="range" class="slider" min="1" max="10" :value="S.kbDelay" @input="S.kbDelay=+($event.target as HTMLInputElement).value"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="set-note">键盘参数为模拟项。</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 传输或还原 -->
|
||||||
|
<template v-if="pane === 'transfer'">
|
||||||
|
<div class="set-card"><div class="set-row"><div class="set-label"><span>还原</span><small>清除所有设置与数据</small></div><div class="set-ctl"><button class="btn" @click="Sys.resetAll()">还原所有设置…</button></div></div></div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { WALLPAPERS } from '../../composables/useSettings'
|
||||||
|
|
||||||
|
const props = defineProps<{ win: any }>()
|
||||||
|
const Sys = () => (window as any).Sys
|
||||||
|
const wm = (window as any).WM
|
||||||
|
const contentEl = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const S = Sys().settings
|
||||||
|
function save() { Sys().save() }
|
||||||
|
function apply() { save(); Sys().applyAll() }
|
||||||
|
|
||||||
|
const pane = ref('appearance')
|
||||||
|
const query = ref('')
|
||||||
|
const queryDebounced = ref('')
|
||||||
|
let debounceTimer: any = null
|
||||||
|
|
||||||
|
const panes = [
|
||||||
|
{ id: 'wifi', name: 'Wi-Fi', ico: '📶', group: '网络' },
|
||||||
|
{ id: 'bluetooth', name: '蓝牙', ico: '🔵', group: '网络' },
|
||||||
|
{ id: 'notifications', name: '通知', ico: '🔔', group: '系统' },
|
||||||
|
{ id: 'about', name: '关于本机', ico: '💻', group: '通用' },
|
||||||
|
{ id: 'datetime', name: '日期与时间', ico: '🕐', group: '通用' },
|
||||||
|
{ id: 'transfer', name: '传输或还原', ico: '♻️', group: '通用' },
|
||||||
|
{ id: 'appearance', name: '外观', ico: '🎨', group: '系统外观' },
|
||||||
|
{ id: 'dock', name: '桌面与程序坞', ico: '🖥', group: '系统外观' },
|
||||||
|
{ id: 'wallpaper', name: '墙纸', ico: '🏞', group: '系统外观' },
|
||||||
|
{ id: 'lockscreen', name: '锁定屏幕', ico: '🔐', group: '安全' },
|
||||||
|
{ id: 'users', name: '用户与群组', ico: '👤', group: '安全' },
|
||||||
|
{ id: 'keyboard', name: '键盘', ico: '⌨️', group: '输入' },
|
||||||
|
]
|
||||||
|
const groups = ['网络', '系统', '通用', '系统外观', '安全', '输入']
|
||||||
|
const networks = ['家庭网络 5G', 'CoffeeShop_Guest', 'Office-2.4G']
|
||||||
|
const btDevices: [string, string][] = [['AirPods Pro', '耳机'], ['Magic Keyboard', '键盘'], ['MX Master 3S', '鼠标']]
|
||||||
|
const appearanceOptions: [string, string, string][] = [['light', '浅色', '☀️'], ['dark', '深色', '🌙'], ['auto', '自动', '🌓']]
|
||||||
|
const accentColors = ['#0a84ff', '#bf5af2', '#ff453a', '#ff9f0a', '#32d74b', '#64d2ff']
|
||||||
|
const timeZones = [['local', '跟随本机'], ['Asia/Shanghai', '上海'], ['Asia/Tokyo', '东京'], ['Europe/London', '伦敦'], ['America/New_York', '纽约']]
|
||||||
|
const wallpapers = WALLPAPERS
|
||||||
|
|
||||||
|
const currentPane = computed(() => panes.find(p => p.id === pane.value))
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
const q = queryDebounced.value.toLowerCase()
|
||||||
|
return groups.map(g => ({ name: g, panes: panes.filter(p => p.group === g && (!q || p.name.toLowerCase().includes(q))) })).filter(g => g.panes.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSearch() { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { queryDebounced.value = query.value.trim() }, 150) }
|
||||||
|
function onDockSize(e: Event) { const v = +(e.target as HTMLInputElement).value; S.dockSize = v; save(); Sys().renderDock(); (e.target as HTMLElement).style.setProperty('--fill', ((v - 36) / 36 * 100) + '%') }
|
||||||
|
function onUserNameChange(e: Event) { S.userName = (e.target as HTMLInputElement).value.trim() || '客人用户'; save() }
|
||||||
|
function onTimezoneChange(e: Event) { S.timezone = (e.target as HTMLSelectElement).value; save(); Sys().tickClock() }
|
||||||
|
|
||||||
|
function go(id: string) {
|
||||||
|
pane.value = id
|
||||||
|
if (contentEl.value) contentEl.value.scrollTop = 0
|
||||||
|
const p = panes.find(p => p.id === id)
|
||||||
|
if (p) wm?.setTitle(props.win, p.name + ' — 系统设置')
|
||||||
|
}
|
||||||
|
|
||||||
|
props.win.appState = { go }
|
||||||
|
defineExpose({ go })
|
||||||
|
</script>
|
||||||
|
|||||||
@ -1,214 +1,17 @@
|
|||||||
|
// 系统设置应用 — Vue 组件化
|
||||||
import { h, render } from 'vue'
|
import { h, render } from 'vue'
|
||||||
// 系统设置应用 — 从 js/settings.js 手动转写
|
|
||||||
// 注意:这是一个 444 行的大文件,包含 30+ 个设置面板
|
|
||||||
import { el, iconImg, fmtBytes, debounce } from '../../utils'
|
|
||||||
import { store as Store } from '../../composables/useStore'
|
|
||||||
import { fs as FS } from '../../composables/useFS'
|
|
||||||
import { wm as WM } from '../../composables/useWM'
|
|
||||||
import { ui as UI } from '../../composables/useUI'
|
|
||||||
import { Apps, stdMenus } from '../../composables/useApps'
|
import { Apps, stdMenus } from '../../composables/useApps'
|
||||||
import SettingsComponent from './Settings.vue'
|
import SettingsComponent from './Settings.vue'
|
||||||
import { WALLPAPERS } from '../../composables/useSettings'
|
|
||||||
const Sys = () => (window as any).Sys
|
|
||||||
|
|
||||||
export const SettingsApp = {
|
export const SettingsApp = {
|
||||||
id: 'settings', name: '系统设置', icon: '/assets/icons/settings.png',
|
id: 'settings', name: '系统设置', icon: '/assets/icons/settings.png',
|
||||||
w: 780, h: 560, minW: 620, minH: 420,
|
w: 780, h: 560, minW: 620, minH: 420,
|
||||||
panes: [] as any[],
|
menus(win: any) { return stdMenus(this) },
|
||||||
menus(win: any) {
|
|
||||||
return stdMenus(this, {
|
|
||||||
view: this.panes.slice(0, 6).map((p: any) => ({ label: p.name, action: () => win?.appState?.go(p.id) })),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onArgs(args: any, win: any) { if (args.pane) win.appState?.go(args.pane) },
|
onArgs(args: any, win: any) { if (args.pane) win.appState?.go(args.pane) },
|
||||||
definePanes() {
|
render(win: any) {
|
||||||
const S = Sys().settings
|
const vnode = h(SettingsComponent, { win })
|
||||||
const row = (label: string, control: any, sub?: string) => {
|
render(vnode, win.body)
|
||||||
return el('div', { class: 'set-row' },
|
|
||||||
el('div', { class: 'set-label' }, el('span', { text: label }), sub ? el('small', { text: sub }) : null),
|
|
||||||
el('div', { class: 'set-ctl' }, control))
|
|
||||||
}
|
|
||||||
const toggle = (key: string, onChange?: (v: boolean) => void, io?: { get(): boolean; set(v: boolean): void }) => {
|
|
||||||
const get = io ? io.get : () => S[key]
|
|
||||||
const set = io ? io.set : (v: boolean) => { S[key] = v }
|
|
||||||
const sw = el('div', { class: 'switch' + (get() ? ' on' : ''), role: 'switch', 'aria-checked': String(!!get()), tabindex: '0' })
|
|
||||||
const flip = () => {
|
|
||||||
const v = !get(); set(v)
|
|
||||||
sw.classList.toggle('on', v); sw.setAttribute('aria-checked', String(v))
|
|
||||||
Sys().save(); Sys().applyAll(); onChange?.(v)
|
|
||||||
}
|
|
||||||
sw.addEventListener('click', flip)
|
|
||||||
sw.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); flip() } })
|
|
||||||
return sw
|
|
||||||
}
|
|
||||||
const slider = (key: string, min: number, max: number, pct: boolean, onInput?: (v: number) => void) => {
|
|
||||||
const r = el('input', { type: 'range', class: 'slider', min: String(min), max: String(max), value: String(S[key] * (pct ? 100 : 1)) }) as HTMLInputElement
|
|
||||||
const sync = () => r.style.setProperty('--fill', ((+r.value - min) / (max - min)) * 100 + '%')
|
|
||||||
sync()
|
|
||||||
r.addEventListener('input', () => { S[key] = pct ? +r.value / 100 : +r.value; sync(); onInput?.(+r.value); Sys().applyAll() })
|
|
||||||
r.addEventListener('change', () => Sys().save())
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
const card = (...rows: any[]) => el('div', { class: 'set-card' }, ...rows.filter(Boolean))
|
|
||||||
const title = (t: string) => el('div', { class: 'set-section', text: t })
|
|
||||||
const note = (t: string) => el('div', { class: 'set-note', text: t })
|
|
||||||
|
|
||||||
const P = (id: string, name: string, ico: string, group: string, render: (box: HTMLElement) => void) => {
|
|
||||||
this.panes.push({ id, name, ico, group, render })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wi-Fi pane
|
|
||||||
P('wifi', 'Wi-Fi', '📶', '网络', box => {
|
|
||||||
box.append(card(row('Wi-Fi', toggle('wifi'))))
|
|
||||||
box.append(title('已知网络'))
|
|
||||||
const list = card()
|
|
||||||
const networks = ['家庭网络 5G', 'CoffeeShop_Guest', 'Office-2.4G']
|
|
||||||
networks.forEach((n, i) => {
|
|
||||||
const connected = () => S.wifi && S.wifiNetwork === n
|
|
||||||
const btn = el('button', { class: 'btn', text: connected() ? '已连接' : '连接', disabled: !S.wifi || connected() }) as HTMLButtonElement
|
|
||||||
btn.addEventListener('click', () => { S.wifiNetwork = n; Sys().save(); list.querySelectorAll('.btn').forEach(b => { (b as HTMLButtonElement).textContent = '连接'; b.removeAttribute('disabled') }); btn.textContent = '已连接'; btn.disabled = true })
|
|
||||||
list.append(row(n, btn, i === 0 ? 'WPA2 个人级' : '开放'))
|
|
||||||
})
|
|
||||||
box.append(list, note(S.wifi ? '网络连接为模拟数据。' : 'Wi-Fi 已关闭。'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Bluetooth pane
|
|
||||||
P('bluetooth', '蓝牙', '🔵', '网络', box => {
|
|
||||||
box.append(card(row('蓝牙', toggle('bluetooth'))))
|
|
||||||
box.append(title('附近的设备'))
|
|
||||||
const c = card();
|
|
||||||
[['AirPods Pro', '耳机'], ['Magic Keyboard', '键盘'], ['MX Master 3S', '鼠标']].forEach(([n, t]) => {
|
|
||||||
const connected = () => S.bluetooth && S.btDevices[n] === true
|
|
||||||
const b = el('button', { class: 'btn', text: connected() ? '已连接' : '连接', disabled: !S.bluetooth || connected() })
|
|
||||||
b.addEventListener('click', () => { if (!S.bluetooth) return; const now = !(S.btDevices[n] === true); S.btDevices[n] = now; Sys().save(); (b as HTMLButtonElement).textContent = now ? '已连接' : '连接' })
|
|
||||||
c.append(row(n, b, t))
|
|
||||||
})
|
|
||||||
box.append(c, note('蓝牙设备为模拟数据。'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 外观 pane
|
|
||||||
P('appearance', '外观', '🎨', '系统外观', box => {
|
|
||||||
const seg = el('div', { class: 'appearance-cards' });
|
|
||||||
[['light', '浅色', '☀️'], ['dark', '深色', '🌙'], ['auto', '自动', '🌓']].forEach(([v, n, i]) => {
|
|
||||||
const c = el('div', { class: 'ap-card' + (S.appearance === v ? ' sel' : '') }, el('div', { class: 'ap-preview ap-' + v, text: i }), el('div', { class: 'ap-name', text: n }))
|
|
||||||
c.addEventListener('click', () => { S.appearance = v; Sys().save(); Sys().applyAppearance(); seg.querySelectorAll('.ap-card').forEach((x: any) => x.classList.remove('sel')); c.classList.add('sel') })
|
|
||||||
seg.append(c)
|
|
||||||
})
|
|
||||||
box.append(seg)
|
|
||||||
box.append(title('强调色'))
|
|
||||||
const colors = el('div', { class: 'accent-row' });
|
|
||||||
['#0a84ff', '#bf5af2', '#ff453a', '#ff9f0a', '#32d74b', '#64d2ff'].forEach(col => {
|
|
||||||
const dot = el('button', { class: 'accent-dot' + (S.accent === col ? ' sel' : ''), style: { background: col }, 'aria-label': '强调色 ' + col })
|
|
||||||
dot.addEventListener('click', () => { S.accent = col; Sys().save(); Sys().applyAll(); colors.querySelectorAll('.accent-dot').forEach((x: any) => x.classList.remove('sel')); dot.classList.add('sel') })
|
|
||||||
colors.append(dot)
|
|
||||||
})
|
|
||||||
box.append(colors)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 桌面与程序坞 pane
|
|
||||||
P('dock', '桌面与程序坞', '🖥', '系统外观', box => {
|
|
||||||
box.append(card(
|
|
||||||
row('大小', (() => { const r = el('input', { type: 'range', class: 'slider', min: '36', max: '72', value: String(S.dockSize) }) as HTMLInputElement; const sync = () => r.style.setProperty('--fill', ((+r.value - 36) / 36 * 100) + '%'); sync(); r.addEventListener('input', () => { S.dockSize = +r.value; sync(); Sys().save(); Sys().renderDock() }); return r })(),
|
|
||||||
row('自动隐藏', toggle('dockAutohide', () => Sys().layoutDock())),
|
|
||||||
)))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 通知 pane
|
|
||||||
P('notifications', '通知', '🔔', '系统', box => {
|
|
||||||
box.append(card(row('允许通知', toggle('notificationsEnabled'), '关闭后不产生横幅、通知记录或徽标')))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 关于本机 pane
|
|
||||||
P('about', '关于本机', '💻', '通用', box => {
|
|
||||||
box.append(card(
|
|
||||||
row('电脑名称', el('span', { text: S.computerName })),
|
|
||||||
row('系统版本', el('span', { text: 'macOS 网页版 1.0 (Sonoma 风格)' })),
|
|
||||||
row('内存', el('span', { text: '16 GB' })),
|
|
||||||
row('启动磁盘', el('span', { text: 'Macintosh HD(虚拟)' })),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 锁定屏幕 pane
|
|
||||||
P('lockscreen', '锁定屏幕', '🔐', '安全', box => {
|
|
||||||
box.append(card(row('锁屏密码', toggle('passwordEnabled'))))
|
|
||||||
box.append(note('密码仅用于演示,保存在本地浏览器。'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 墙纸 pane
|
|
||||||
P('wallpaper', '墙纸', '🏞', '系统外观', box => {
|
|
||||||
const grid = el('div', { class: 'wall-grid' })
|
|
||||||
WALLPAPERS.forEach((w: any) => {
|
|
||||||
const cell = el('div', { class: 'wall-cell' + (S.wallpaper === w.id ? ' sel' : '') }, iconImg(w.src, '', w.name), el('div', { class: 'wall-name', text: w.name }))
|
|
||||||
cell.addEventListener('click', () => { S.wallpaper = w.id; Sys().save(); Sys().applyWallpaper(); grid.querySelectorAll('.wall-cell').forEach((x: any) => x.classList.remove('sel')); cell.classList.add('sel') })
|
|
||||||
grid.append(cell)
|
|
||||||
})
|
|
||||||
box.append(grid, note('锁屏与桌面共享同一墙纸。'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 用户与群组
|
|
||||||
P('users', '用户与群组', '👤', '安全', box => {
|
|
||||||
const nameIn = el('input', { class: 'text-input', value: S.userName }) as HTMLInputElement
|
|
||||||
nameIn.addEventListener('change', () => { S.userName = nameIn.value.trim() || '客人用户'; Sys().save() })
|
|
||||||
box.append(card(row('用户名', nameIn)))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 日期与时间
|
|
||||||
P('datetime', '日期与时间', '🕐', '通用', box => {
|
|
||||||
const ZONES = [['local', '跟随本机'], ['Asia/Shanghai', '上海'], ['Asia/Tokyo', '东京'], ['Europe/London', '伦敦'], ['America/New_York', '纽约']]
|
|
||||||
const s = el('select', { class: 'text-input' }, ...ZONES.map(([v, l]) => el('option', { text: l, value: v }))) as HTMLSelectElement
|
|
||||||
s.value = S.timezone || 'local'
|
|
||||||
s.addEventListener('change', () => { S.timezone = s.value; Sys().save(); Sys().tickClock() })
|
|
||||||
box.append(card(row('24 小时制', toggle('h24', () => Sys().tickClock())), row('时区', s)))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 键盘
|
|
||||||
P('keyboard', '键盘', '⌨️', '输入', box => {
|
|
||||||
box.append(card(row('重复速度', slider('kbRepeat', 1, 10, false)), row('重复前延迟', slider('kbDelay', 1, 10, false))))
|
|
||||||
box.append(note('键盘参数为模拟项。'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 传输或还原
|
|
||||||
P('transfer', '传输或还原', '♻️', '通用', box => {
|
|
||||||
const b = el('button', { class: 'btn', text: '还原所有设置…' })
|
|
||||||
b.addEventListener('click', () => Sys().resetAll())
|
|
||||||
box.append(card(row('还原', b, '清除所有设置与数据')))
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
render(win: any, args: any) {
|
|
||||||
if (!this.panes.length) this.definePanes()
|
|
||||||
const st = win.appState = { pane: null as string | null, q: '' }
|
|
||||||
win.body.classList.add('settings-body')
|
|
||||||
const search = el('input', { class: 'text-input set-search', type: 'search', placeholder: '搜索' }) as HTMLInputElement
|
|
||||||
const nav = el('div', { class: 'set-nav' })
|
|
||||||
const side = el('div', { class: 'set-sidebar' }, el('div', { style: { padding: '8px 8px 4px' } }, search), nav)
|
|
||||||
const content = el('div', { class: 'set-content' })
|
|
||||||
win.body.append(side, content)
|
|
||||||
|
|
||||||
const groups = ['网络', '系统', '通用', '系统外观', '安全', '输入']
|
|
||||||
const renderNav = () => {
|
|
||||||
nav.innerHTML = ''
|
|
||||||
for (const g of groups) {
|
|
||||||
const items = this.panes.filter((p: any) => p.group === g && (!st.q || p.name.toLowerCase().includes(st.q.toLowerCase())))
|
|
||||||
if (!items.length) continue
|
|
||||||
nav.append(el('div', { class: 'fb-side-title', text: g }))
|
|
||||||
for (const p of items) {
|
|
||||||
const rowEl = el('div', { class: 'fb-side-item set-nav-item' + (st.pane === p.id ? ' sel' : '') }, el('span', { class: 'fb-side-ico', text: p.ico }), el('span', { text: p.name }))
|
|
||||||
rowEl.addEventListener('click', () => st.go(p.id)); nav.append(rowEl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.go = (id: string) => {
|
|
||||||
const p = this.panes.find((p: any) => p.id === id) || this.panes[0]
|
|
||||||
st.pane = p.id; renderNav(); content.innerHTML = ''; content.scrollTop = 0
|
|
||||||
WM.setTitle(win, p.name + ' — 系统设置')
|
|
||||||
content.append(el('div', { class: 'set-pane-title' }, el('span', { text: p.ico, style: { fontSize: '20px' } }), el('span', { text: p.name })))
|
|
||||||
try { p.render(content) } catch (e) { console.error('[settings]', e); content.append(el('div', { class: 'empty-state', text: '此设置页加载失败' })) }
|
|
||||||
}
|
|
||||||
search.addEventListener('input', debounce(() => { st.q = search.value.trim(); renderNav() }, 150))
|
|
||||||
renderNav()
|
|
||||||
st.go(args.pane || 'appearance')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Apps.register(SettingsApp)
|
Apps.register(SettingsApp)
|
||||||
;(window as any).SettingsApp = SettingsApp
|
;(window as any).SettingsApp = SettingsApp
|
||||||
|
|||||||
40
tests/cases/39-settings.test.ts
Normal file
40
tests/cases/39-settings.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 39-settings: Settings Vue 组件化测试
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { h, render, nextTick } from 'vue'
|
||||||
|
import SettingsComponent from '../../src/apps/settings/Settings.vue'
|
||||||
|
import { setupDOM } from '../helpers'
|
||||||
|
|
||||||
|
describe('39-settings — Settings Vue 组件化', () => {
|
||||||
|
let el: HTMLElement, body: HTMLElement, win: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
setupDOM()
|
||||||
|
el = document.createElement('div'); el.className = 'window'
|
||||||
|
document.getElementById('window-layer')?.appendChild(el)
|
||||||
|
body = document.createElement('div'); body.className = 'win-body'
|
||||||
|
el.appendChild(body)
|
||||||
|
win = { el, body, timers: [], data: {} };
|
||||||
|
(window as any).Sys = {
|
||||||
|
settings: { wifi: true, wifiNetwork: '', bluetooth: false, btDevices: {} as any, appearance: 'auto', accent: '#0a84ff', dockSize: 48, dockAutohide: false, notificationsEnabled: true, computerName: 'Mac', passwordEnabled: false, wallpaper: 'sonoma', h24: true, timezone: 'local', kbRepeat: 5, kbDelay: 3, userName: '客人用户' },
|
||||||
|
save() {}, applyAll() {}, applyAppearance() {}, applyWallpaper() {}, renderDock() {}, layoutDock() {}, tickClock() {}, resetAll() {},
|
||||||
|
};
|
||||||
|
(window as any).WM = { setTitle() {} }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => { render(null, body); el.remove() })
|
||||||
|
|
||||||
|
function mount() { const v = h(SettingsComponent, { win }); render(v, body) }
|
||||||
|
|
||||||
|
it('显示导航栏', () => { mount(); expect(body.querySelector('.set-sidebar')).toBeTruthy() })
|
||||||
|
it('显示内容区', () => { mount(); expect(body.querySelector('.set-content')).toBeTruthy() })
|
||||||
|
it('搜索框存在', () => { mount(); expect(body.querySelector('.set-search')).toBeTruthy() })
|
||||||
|
it('外观 pane 显示浅色/深色/自动', () => { mount(); expect(body.querySelectorAll('.ap-card').length).toBe(3) })
|
||||||
|
it('默认选中外观', () => { mount(); expect(body.querySelector('.set-nav-item.sel')?.textContent).toContain('外观') })
|
||||||
|
it('切换 pane', async () => { mount(); const items = body.querySelectorAll('.set-nav-item'); (items[1] as HTMLElement)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(items[1].classList.contains('sel')).toBe(true) })
|
||||||
|
it('Wi-Fi toggle 存在', async () => { mount(); const items = body.querySelectorAll('.set-nav-item'); const wifiItem = [...items].find(i => i.textContent?.includes('Wi-Fi')) as HTMLElement; wifiItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(body.querySelector('.switch')).toBeTruthy() })
|
||||||
|
it('强调色按钮存在', () => { mount(); expect(body.querySelectorAll('.accent-dot').length).toBe(6) })
|
||||||
|
it('卸载', () => { mount(); render(null, body); expect(body.children.length).toBe(0) })
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user