diff --git a/src/apps/settings/Settings.vue b/src/apps/settings/Settings.vue index 0ada1d8..ea3af13 100644 --- a/src/apps/settings/Settings.vue +++ b/src/apps/settings/Settings.vue @@ -1 +1,176 @@ - + + + diff --git a/src/apps/settings/index.ts b/src/apps/settings/index.ts index f8db5c5..0ba65d5 100644 --- a/src/apps/settings/index.ts +++ b/src/apps/settings/index.ts @@ -1,214 +1,17 @@ +// 系统设置应用 — 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 SettingsComponent from './Settings.vue' -import { WALLPAPERS } from '../../composables/useSettings' -const Sys = () => (window as any).Sys export const SettingsApp = { id: 'settings', name: '系统设置', icon: '/assets/icons/settings.png', w: 780, h: 560, minW: 620, minH: 420, - panes: [] as any[], - menus(win: any) { - return stdMenus(this, { - view: this.panes.slice(0, 6).map((p: any) => ({ label: p.name, action: () => win?.appState?.go(p.id) })), - }) - }, + menus(win: any) { return stdMenus(this) }, onArgs(args: any, win: any) { if (args.pane) win.appState?.go(args.pane) }, - definePanes() { - const S = Sys().settings - const row = (label: string, control: any, sub?: string) => { - 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) { + const vnode = h(SettingsComponent, { win }) + render(vnode, win.body) }, - - 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) ;(window as any).SettingsApp = SettingsApp diff --git a/tests/cases/39-settings.test.ts b/tests/cases/39-settings.test.ts new file mode 100644 index 0000000..a680453 --- /dev/null +++ b/tests/cases/39-settings.test.ts @@ -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) }) +})