diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index fb391f7..847c57d 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -46,6 +46,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Test metadata validation + run: node --test scripts/meta-tags.test.mjs + - name: Check static site run: node scripts/check-site.mjs dist diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 89ddfa1..54e91d8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -307,12 +307,15 @@ export async function buildServer( cacheControl: false, setHeaders(response, filePath) { const hashedAsset = - /[/\\]assets[/\\][^/\\]+-[A-Za-z0-9_-]{8,}\.(?:js|css)(?:\.(?:br|gz))?$/.test(filePath); + /[/\\]assets[/\\][^/\\]+-[A-Za-z0-9_-]{8,}\.(?:js|css)(?:\.(?:br|gz))?$/.test(filePath) || + /[/\\]icons[/\\][a-f0-9]{16}[/\\][^/\\]+\.png$/.test(filePath); response.setHeader( 'Cache-Control', hashedAsset ? 'public, max-age=31536000, immutable' - : 'public, max-age=0, must-revalidate', + : /(?:index\.html|manifest\.webmanifest|sw\.js)(?:\.(?:br|gz))?$/.test(filePath) + ? 'no-store, no-cache, max-age=0, must-revalidate' + : 'public, max-age=0, must-revalidate', ); }, }); diff --git a/apps/api/test/static-delivery.test.ts b/apps/api/test/static-delivery.test.ts index eaf8bd6..574cc56 100644 --- a/apps/api/test/static-delivery.test.ts +++ b/apps/api/test/static-delivery.test.ts @@ -11,11 +11,20 @@ describe('static delivery cache contract', () => { const root = await mkdtemp(join(tmpdir(), 'taskdock-static-fixture-')); const { mkdir } = await import('node:fs/promises'); await mkdir(join(root, 'assets')); + await mkdir(join(root, 'icons/0123456789abcdef'), { recursive: true }); + await writeFile( + join(root, 'icons/0123456789abcdef/pwa-192.png'), + Buffer.from('89504e470d0a1a0a', 'hex'), + ); const source = 'console.log("taskdock");'.repeat(100); await writeFile(join(root, 'index.html'), '
TaskDock
'); await writeFile(join(root, 'assets/app-AbCd1234.js'), source); await writeFile(join(root, 'assets/app-AbCd1234.js.gz'), gzipSync(source)); await writeFile(join(root, 'sw.js'), '// service worker'); + await writeFile( + join(root, 'manifest.webmanifest'), + JSON.stringify({ id: '/', start_url: '/today', display: 'standalone' }), + ); const { app } = await buildServer({ store: new MemoryStore(), config: { webRoot: root, logLevel: 'silent' }, @@ -29,9 +38,17 @@ describe('static delivery cache contract', () => { expect(asset.headers['content-encoding']).toBe('gzip'); expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable'); expect(asset.headers['vary']).toContain('Accept-Encoding'); + expect( + (await app.inject('/icons/0123456789abcdef/pwa-192.png')).headers['cache-control'], + ).toBe('public, max-age=31536000, immutable'); expect((await app.inject('/')).headers['cache-control']).toContain('must-revalidate'); expect((await app.inject('/sw.js')).headers['cache-control']).toContain('must-revalidate'); expect((await app.inject('/today')).headers['cache-control']).toContain('must-revalidate'); + for (const endpoint of ['/', '/today', '/sw.js', '/manifest.webmanifest']) { + const response = await app.inject(endpoint); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toContain('no-store'); + } // A missing hashed chunk must not silently become the SPA shell: Safari // refuses to execute HTML as a module and the task detail chunk dies. const missingAsset = await app.inject('/assets/missing-DEADBEEF.js'); diff --git a/apps/web/e2e/mobile-pwa.spec.ts b/apps/web/e2e/mobile-pwa.spec.ts index a866b4c..9e67db4 100644 --- a/apps/web/e2e/mobile-pwa.spec.ts +++ b/apps/web/e2e/mobile-pwa.spec.ts @@ -285,10 +285,24 @@ test('landscape and enlarged text keep navigation and forms within reach', async await expect(dialog).toBeVisible(); await assertFits(page); await dialog.getByRole('button', { name: '关闭', exact: true }).click(); - await page - .getByRole('navigation', { name: '主导航' }) - .getByRole('link', { name: '日历', exact: true }) - .click(); + if (test.info().project.name === 'mobile') { + // Rotation keeps a real phone in its phone shell. Reach the calendar + // through the same Plan entry a person uses, rather than waiting for a + // tablet rail that intentionally does not exist on this device. + const navigation = page.getByRole('navigation', { name: '移动导航' }); + await expect(navigation).toBeVisible(); + await expect(page.getByRole('navigation', { name: '主导航' })).toHaveCount(0); + await navigation.getByRole('link', { name: '计划', exact: true }).click(); + await expect(page).toHaveURL(/\/time$/); + await assertFits(page); + await page.locator('.time-hub-grid').getByRole('link', { name: /^日历/ }).click(); + } else { + // A resized desktop browser still uses the medium-width navigation rail. + const navigation = page.getByRole('navigation', { name: '主导航' }); + await expect(navigation).toBeVisible(); + await navigation.getByRole('link', { name: '日历', exact: true }).click(); + } + await expect(page).toHaveURL(/\/time\/calendar(?:\/\d{4}-\d{2}-\d{2})?$/); await assertFits(page); await page.setViewportSize({ width: 390, height: 844 }); await expect(page.getByRole('navigation', { name: '移动导航' })).toBeVisible(); diff --git a/apps/web/e2e/pwa-adaptive.spec.ts b/apps/web/e2e/pwa-adaptive.spec.ts new file mode 100644 index 0000000..125b145 --- /dev/null +++ b/apps/web/e2e/pwa-adaptive.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Page } from '@playwright/test'; + +test.beforeEach(() => { + test.skip( + !['chromium', 'webkit'].includes(test.info().project.name), + 'adaptive matrix covers Chromium and WebKit', + ); +}); + +async function loginFixture(page: Page) { + // Keep mocked HTTP sessions independent from any live fixture on port 3000. + await page.routeWebSocket('**/api/v2/ws**', () => undefined); + const user = { + id: '00000000-0000-7000-8000-000000000901', + username: 'pwa-layout-owner', + createdAt: '2026-10-25T10:00:00.000Z', + }; + const settings = { + ownerId: user.id, + timezone: 'Asia/Shanghai', + weekStartsOn: 1, + defaultCaptureTarget: 'ROOT', + version: 1, + updatedAt: user.createdAt, + }; + const capabilities = { syncProtocolVersion: 2, websocket: false, offline: true }; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + let body: unknown = { items: [] }; + if (path.endsWith('/bootstrap/status')) body = { initialized: true }; + else if (path.endsWith('/auth/refresh')) body = { accessToken: 'pwa-test-token' }; + else if (path.endsWith('/me')) body = { user, settings, capabilities }; + else if (/\/sync\/(preview|bootstrap)/.test(path) || path.endsWith('/today')) + return route.fulfill({ + status: 404, + contentType: 'application/json', + body: '{"code":"NOT_FOUND"}', + }); + else if (path.endsWith('/sync/snapshot')) + body = { + folders: [], + tasks: [], + notes: [], + taskSteps: [], + timePoints: [], + placements: [], + workflows: [], + workflowStages: [], + workflowTaskMemberships: [], + settings, + cursor: '0', + }; + else if (path.endsWith('/sync/pull')) body = { changes: [], nextCursor: '0', hasMore: false }; + else if (path.endsWith('/sync/push')) body = { protocolVersion: 2, results: [] }; + else if (path.endsWith('/settings')) body = settings; + else if (path.endsWith('/devices')) body = []; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + }); +} + +async function assertPhoneTouchTargets(page: Page) { + const geometry = await page + .getByRole('navigation', { name: '移动导航', exact: true }) + .evaluate((navigation) => ({ + width: navigation.getBoundingClientRect().width, + items: [...navigation.querySelectorAll('a')].map((item) => ({ + width: item.getBoundingClientRect().width, + height: item.getBoundingClientRect().height, + })), + })); + expect(geometry.items).toHaveLength(5); + for (const item of geometry.items) { + expect(item.width).toBeGreaterThanOrEqual(44); + expect(item.height).toBeGreaterThanOrEqual(44); + expect(Math.abs(item.width - geometry.width / geometry.items.length)).toBeLessThanOrEqual(1); + } +} +for (const viewport of [ + { width: 390, height: 844, phone: true }, + { width: 844, height: 390, phone: true }, + { width: 600, height: 900, phone: false }, + { width: 834, height: 1194, phone: false }, + { width: 1366, height: 1024, phone: false }, +]) { + test.describe(`adaptive PWA ${viewport.width}x${viewport.height}`, () => { + test.use({ + viewport, + hasTouch: true, + reducedMotion: 'reduce', + userAgent: viewport.phone + ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 27_0 like Mac OS X) AppleWebKit/605.1.15 Version/27.0 Mobile/15E148 Safari/604.1' + : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/27.0 Safari/605.1.15', + }); + test('keeps navigation and all main routes usable with installation help', async ({ + page, + }, info) => { + test.skip(process.env['E2E_REAL'] === '1', 'uses a public API fixture'); + await loginFixture(page); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + for (const route of [ + '/today', + '/tree', + '/workflows', + '/time/calendar', + '/time/events', + '/settings', + '/more', + ]) { + await page.goto(route); + await expect(page.locator('.app-frame')).toBeVisible(); + await expect( + page.locator(viewport.phone ? '.is-phone-frame' : '.is-tablet-frame'), + ).toBeVisible(); + await expect( + page.locator(viewport.phone ? '.m3e-navigation-bar' : '.m3e-navigation-rail'), + ).toBeVisible(); + await expect(page.locator('.page-header')).toBeVisible(); + if (viewport.phone) await assertPhoneTouchTargets(page); + const overflow = await page.evaluate(() => { + const root = document.documentElement; + const page = document.querySelector('.page-wrap'); + const bar = document.querySelector('.m3e-app-bar'); + return { + appBar: !!bar && bar.scrollWidth > bar.clientWidth + 1, + document: root.scrollWidth > root.clientWidth + 1, + page: !!page && page.scrollWidth > page.clientWidth + 1, + }; + }); + expect(overflow).toEqual({ appBar: false, document: false, page: false }); + if (route === '/today' || route === '/time/calendar') + await page.screenshot({ path: info.outputPath(route.replaceAll('/', '-') + '.png') }); + } + if (!viewport.phone) { + await page.getByRole('button', { name: '退出登录', exact: true }).click(); + const logout = page.getByRole('dialog', { name: '退出登录', exact: true }); + await expect(logout).toBeVisible(); + await logout.getByRole('button', { name: '取消', exact: true }).click(); + await expect(logout).toBeHidden(); + } + await page.getByRole('button', { name: '安装 TaskDock', exact: true }).click(); + const help = page.getByRole('dialog', { name: '安装与离线使用' }); + await expect(help).toBeVisible(); + await expect(help.getByText(/Safari 分享菜单/)).toBeVisible(); + await help.getByRole('button', { name: '完成' }).click(); + await expect(help).toBeHidden(); + expect(errors).toEqual([]); + }); + }); +} + +test('installation help remains available before login without an install event', async ({ + page, +}) => { + await page.route('**/api/v1/auth/refresh', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: '{"code":"AUTH_SESSION_REVOKED"}', + }), + ); + await page.route('**/api/v1/bootstrap/status', (route) => + route.fulfill({ contentType: 'application/json', body: '{"initialized":true}' }), + ); + await page.goto('/login'); + await page.getByRole('button', { name: '安装 TaskDock', exact: true }).click(); + await expect(page.getByRole('dialog', { name: '安装与离线使用' })).toBeVisible(); +}); +test('uses a captured Chromium installation event only after user action', async ({ page }) => { + await page.route('**/api/v1/auth/refresh', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: '{"code":"AUTH_SESSION_REVOKED"}', + }), + ); + await page.route('**/api/v1/bootstrap/status', (route) => + route.fulfill({ contentType: 'application/json', body: '{"initialized":true}' }), + ); + await page.goto('/login'); + await page.getByRole('button', { name: '安装 TaskDock', exact: true }).waitFor(); + await page.evaluate(() => { + const event = new Event('beforeinstallprompt', { cancelable: true }); + Object.assign(event, { + prompt: () => { + document.documentElement.dataset.installPrompted = 'yes'; + return Promise.resolve(); + }, + userChoice: Promise.resolve({ outcome: 'dismissed' }), + }); + window.dispatchEvent(event); + }); + await expect(page.locator('html')).not.toHaveAttribute('data-install-prompted'); + await page.getByRole('button', { name: '安装 TaskDock', exact: true }).click(); + const help = page.getByRole('dialog', { name: '安装与离线使用' }); + await help.getByRole('button', { name: '安装 TaskDock', exact: true }).click(); + await expect(page.locator('html')).toHaveAttribute('data-install-prompted', 'yes'); + await expect(help.getByText('可稍后从浏览器菜单再次安装。')).toBeVisible(); +}); + +test.describe('tablet sign-out', () => { + test.use({ + viewport: { width: 600, height: 900 }, + hasTouch: true, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 27_0 like Mac OS X) AppleWebKit/605.1.15', + reducedMotion: 'reduce', + }); + test('keeps sign-out reachable on the rail without losing the session on cancel', async ({ + page, + }) => { + test.skip(process.env['E2E_REAL'] === '1', 'uses a public API fixture'); + await loginFixture(page); + await page.goto('/settings'); + await page + .getByRole('navigation', { name: '主导航' }) + .getByRole('button', { name: '退出登录', exact: true }) + .click(); + const dialog = page.getByRole('dialog', { name: '退出登录', exact: true }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: '取消', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(page.locator('.is-tablet-frame')).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/pwa-multitab-update.spec.ts b/apps/web/e2e/pwa-multitab-update.spec.ts new file mode 100644 index 0000000..fd43880 --- /dev/null +++ b/apps/web/e2e/pwa-multitab-update.spec.ts @@ -0,0 +1,223 @@ +import { createServer, type Server } from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import { extname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test as base, type Page } from '@playwright/test'; + +type PwaFixture = { origin: string; deployUpdate: () => void }; +const test = base.extend<{ pwaFixture: PwaFixture }>({ + pwaFixture: async ({ browserName }, provideFixture) => { + const dist = resolve( + process.env['PWA_MULTITAB_DIST_DIR'] ?? fileURLToPath(new URL('../dist/', import.meta.url)), + ); + let version = 1; + const user = { + id: '00000000-0000-7000-8000-000000000994', + username: `pwa-update-fixture-${browserName}`, + createdAt: '2026-10-26T00:00:00.000Z', + }; + const settings = { + ownerId: user.id, + timezone: 'Asia/Shanghai', + weekStartsOn: 1, + defaultCaptureTarget: 'ROOT', + version: 1, + updatedAt: user.createdAt, + }; + const types: Record = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.webmanifest': 'application/manifest+json', + }; + const server: Server = createServer(async (request, response) => { + try { + const path = new URL(request.url ?? '/', 'http://localhost').pathname; + if (path.startsWith('/api/')) { + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('Content-Type', 'application/json'); + let body: unknown = { items: [] }; + if (path.endsWith('/bootstrap/status')) body = { initialized: true }; + else if (path.endsWith('/auth/refresh')) + body = { accessToken: 'pwa-update-synthetic-token' }; + else if (path.endsWith('/me')) + body = { + user, + settings, + capabilities: { syncProtocolVersion: 2, websocket: false, offline: true }, + }; + else if (/\/sync\/(preview|bootstrap)/.test(path) || path.endsWith('/today')) { + response.writeHead(404); + response.end('{"code":"NOT_FOUND"}'); + return; + } else if (path.endsWith('/sync/snapshot')) + body = { + folders: [], + tasks: [], + notes: [], + taskSteps: [], + timePoints: [], + placements: [], + workflows: [], + workflowStages: [], + workflowTaskMemberships: [], + settings, + cursor: '0', + }; + else if (path.endsWith('/sync/pull')) + body = { changes: [], nextCursor: '0', hasMore: false }; + else if (path.endsWith('/sync/push')) body = { protocolVersion: 2, results: [] }; + else if (path.endsWith('/settings')) body = settings; + else if (path.endsWith('/devices')) body = []; + response.end(JSON.stringify(body)); + return; + } + const navigation = !extname(path); + const file = resolve(dist, navigation ? 'index.html' : '.' + path); + if (!file.startsWith(dist + sep) || !(await stat(file)).isFile()) { + response.writeHead(404); + response.end(); + return; + } + let bytes = await readFile(file); + if (path === '/sw.js') { + bytes = Buffer.from( + bytes.toString() + + `\nself.addEventListener('message',event=>{if(event.data?.type==='PWA_FIXTURE_VERSION')event.ports[0]?.postMessage(${version})});\n`, + ); + response.setHeader('Service-Worker-Allowed', '/'); + } + response.setHeader('Content-Type', types[extname(file)] ?? 'application/octet-stream'); + response.setHeader('Cache-Control', 'no-store, no-cache, max-age=0, must-revalidate'); + response.end(bytes); + } catch { + response.writeHead(404); + response.end(); + } + }); + server.on('upgrade', (_request, socket) => socket.destroy()); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('PWA fixture did not bind'); + try { + await provideFixture({ + origin: `http://127.0.0.1:${address.port}`, + deployUpdate: () => { + version += 1; + }, + }); + } finally { + server.closeAllConnections(); + await new Promise((done, reject) => + server.close((error) => (error ? reject(error) : done())), + ); + } + }, +}); + +test.use({ + serviceWorkers: 'allow', + reducedMotion: 'reduce', + viewport: { width: 1280, height: 900 }, +}); +test.beforeEach(() => { + test.skip( + !['chromium', 'webkit'].includes(test.info().project.name), + 'real multi-tab Service Worker lifecycle in Chromium and WebKit', + ); +}); +const documentToken = async (page: Page) => { + try { + return await page.evaluate( + () => (window as Window & { pwaDocumentToken: string }).pwaDocumentToken, + ); + } catch (error) { + if ( + error instanceof Error && + /Execution context was destroyed|Cannot find context/.test(error.message) + ) + return undefined; + throw error; + } +}; +const controllerVersion = (page: Page) => + page.evaluate( + () => + new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = window.setTimeout( + () => reject(new Error('worker version reply timed out')), + 5000, + ); + channel.port1.onmessage = (event) => { + window.clearTimeout(timeout); + resolve(event.data as number); + }; + navigator.serviceWorker.controller?.postMessage({ type: 'PWA_FIXTURE_VERSION' }, [ + channel.port2, + ]); + }), + ); + +test('a consented update reloads only its tab and another tab can finish its draft before reopening', async ({ + context, + pwaFixture, +}) => { + test.setTimeout(60_000); + await context.addInitScript(() => { + (window as Window & { pwaDocumentToken: string }).pwaDocumentToken = crypto.randomUUID(); + }); + const first = await context.newPage(); + const second = await context.newPage(); + await first.goto(pwaFixture.origin + '/tree'); + await expect(first.locator('.app-frame')).toBeVisible(); + await first.waitForFunction(() => Boolean(navigator.serviceWorker.controller)); + // Both pages now start under an existing controller, as installed PWA windows do. + await first.reload(); + await expect(first.getByRole('button', { name: '新建文件夹', exact: true })).toBeVisible(); + await second.goto(pwaFixture.origin + '/tree'); + await expect(second.getByRole('button', { name: '新建文件夹', exact: true })).toBeVisible(); + await expect.poll(() => controllerVersion(first)).toBe(1); + await expect.poll(() => controllerVersion(second)).toBe(1); + const firstToken = await documentToken(first); + const secondToken = await documentToken(second); + await second.getByRole('button', { name: '新建文件夹', exact: true }).click(); + const draft = second.getByRole('textbox', { name: '文件夹名称', exact: true }); + await draft.fill('Keep this unsaved folder draft'); + pwaFixture.deployUpdate(); + await first.evaluate(async () => { + await (await navigator.serviceWorker.getRegistration())?.update(); + }); + await first.getByRole('button', { name: '查看更新', exact: true }).click(); + first.once('dialog', (dialog) => void dialog.accept()); + await first.getByRole('button', { name: '更新并重新打开', exact: true }).click(); + await expect + .poll(async () => { + const token = await documentToken(first); + return Boolean(token && token !== firstToken); + }) + .toBe(true); + await expect(first.locator('.app-frame')).toBeVisible(); + await expect.poll(() => controllerVersion(second)).toBe(2); + expect(await documentToken(second)).toBe(secondToken); + await expect(draft).toHaveValue('Keep this unsaved folder draft'); + // The other window has no waiting worker now. Its explicit confirmation must + // still reopen it after the local draft is deliberately closed by the user. + await second + .getByRole('dialog', { name: '新建文件夹', exact: true }) + .getByRole('button', { name: '取消', exact: true }) + .click(); + await second.getByRole('button', { name: '查看更新', exact: true }).click(); + second.once('dialog', (dialog) => void dialog.accept()); + await second.getByRole('button', { name: '更新并重新打开', exact: true }).click(); + await expect + .poll(async () => { + const token = await documentToken(second); + return Boolean(token && token !== secondToken); + }) + .toBe(true); + await expect(second.locator('.app-frame')).toBeVisible(); + await expect.poll(() => controllerVersion(second)).toBe(2); +}); diff --git a/apps/web/index.html b/apps/web/index.html index b85c615..b6243f9 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -14,16 +14,51 @@ Keep a high-resolution source in the rel=icon set so it never has to upscale the 64px favicon. --> - - + + - - - - - + + + + + + TaskDock · 开发任务工作台 diff --git a/apps/web/package.json b/apps/web/package.json index d71c4df..c7b24f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,7 +28,9 @@ "react-router-dom": "^7.8.2", "vite": "^7.1.4", "vite-plugin-pwa": "^1.0.2", - "workbox-window": "^7.4.1" + "workbox-window": "^7.4.1", + "workbox-precaching": "^7.4.1", + "workbox-routing": "^7.4.1" }, "devDependencies": { "@types/dompurify": "^3.2.0", diff --git a/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-120x120.png b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-120x120.png new file mode 100644 index 0000000..77f023b Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-120x120.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-152x152.png b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-152x152.png new file mode 100644 index 0000000..8765fe9 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-152x152.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-167x167.png b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-167x167.png new file mode 100644 index 0000000..724cb9e Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-167x167.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-180x180.png b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-180x180.png new file mode 100644 index 0000000..b5fd815 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/apple-touch-icon-180x180.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-1024.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-1024.png new file mode 100644 index 0000000..fdf9776 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-1024.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-180.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-180.png new file mode 100644 index 0000000..b5fd815 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-180.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-192.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-192.png new file mode 100644 index 0000000..0065bcd Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-192.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-512.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-512.png new file mode 100644 index 0000000..98c5469 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-512.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-64.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-64.png new file mode 100644 index 0000000..6d9b469 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-64.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-1024.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-1024.png new file mode 100644 index 0000000..aa15014 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-1024.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-192.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-192.png new file mode 100644 index 0000000..af8d4e7 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-192.png differ diff --git a/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-512.png b/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-512.png new file mode 100644 index 0000000..198b4f1 Binary files /dev/null and b/apps/web/public/icons/5afd1dbb11145bdc/pwa-maskable-512.png differ diff --git a/apps/web/public/pwa-1024.png b/apps/web/public/pwa-1024.png new file mode 100644 index 0000000..fdf9776 Binary files /dev/null and b/apps/web/public/pwa-1024.png differ diff --git a/apps/web/public/pwa-maskable-1024.png b/apps/web/public/pwa-maskable-1024.png new file mode 100644 index 0000000..aa15014 Binary files /dev/null and b/apps/web/public/pwa-maskable-1024.png differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a91de86..663b4a3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -131,7 +131,7 @@ import { } from './components/m3e/index.js'; import { useDevice } from './device.js'; import { useDismissibleMenu } from './components/m3e/behavior.js'; -import { PwaLifecycleNotice } from './pwa.js'; +import { PwaInstallButton, PwaLifecycleNotice } from './pwa.js'; import { ErrorBoundary, TaskDetailErrorFallback } from './components/error-boundary.js'; import { LazyPageBoundary } from './components/lazy-page-boundary.js'; import { TaskDetailV2Overlay } from './components/task-detail-overlay.js'; @@ -280,7 +280,8 @@ function AuthenticatedApp() { const location = useLocation(); const navigate = useNavigate(); const { isPhone, isTablet } = useDevice(); - const sizeClass = useWindowSizeClass(); + const windowSizeClass = useWindowSizeClass(); + const sizeClass = isPhone ? 'compact' : windowSizeClass; const pageWrapRef = useRef(null); const [commandOpen, setCommandOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -500,7 +501,7 @@ function AuthenticatedApp() {
- {(sizeClass === 'medium' || (isTablet && sizeClass === 'expanded')) && ( + {(sizeClass === 'medium' || (isTablet && sizeClass !== 'compact')) && ( } + footer={ + setLogoutConfirmOpen(true)} + > + + + } /> )} - {!isCompactShell(sizeClass) && - (!isTablet || sizeClass !== 'expanded') && - sizeClass !== 'medium' && ( - /* Expanded and up on desktop: the drawer is persistent, so it is never modal. */ - undefined} - modal={false} - side="start" - label="主导航" - destinations={navItems} - activeTo={activeForExpanded} - onNavigate={navigate} - linkAs={RouterLinkAdapter} - header={drawerHeader} - footer={drawerFooter} - className="m3e-drawer-layer--persistent" - > - - - )} + {!isCompactShell(sizeClass) && !isTablet && sizeClass !== 'medium' && ( + /* Expanded and up on desktop: the drawer is persistent, so it is never modal. */ + undefined} + modal={false} + side="start" + label="主导航" + destinations={navItems} + activeTo={activeForExpanded} + onNavigate={navigate} + linkAs={RouterLinkAdapter} + header={drawerHeader} + footer={drawerFooter} + className="m3e-drawer-layer--persistent" + > + + + )}
+
TaskDock @@ -994,6 +1003,7 @@ function MorePage({ onOpenSearch }: { onOpenSearch: () => void }) { return (
+ + ); +} -/** - * Service-worker feedback is deliberately a Snackbar: it is transient global - * state, not page content. This keeps refresh/offline affordances consistent - * with the rest of the M3 Expressive feedback model. - */ export function PwaLifecycleNotice() { + const { db, connection } = useAuth(); + const [open, setOpen] = useState(false); + const [prompt, setPrompt] = useState(null); + const [installed, setInstalled] = useState(detectStandalone); + const [feedback, setFeedback] = useState(''); + const [busy, setBusy] = useState(false); + const [updateDismissed, setUpdateDismissed] = useState(false); + const registration = useRef(undefined); + const pendingConsent = useRef<{ worker: ServiceWorker; dispose: () => void } | null>(null); + const activatedWorker = useRef(null); + const reloadIssued = useRef(false); + const [activatedUpdate, setActivatedUpdate] = useState(false); + const clearConsent = useCallback(() => { + const pending = pendingConsent.current; + pendingConsent.current = null; + pending?.dispose(); + }, []); + const handleActivatedUpdate = useCallback(() => { + if (reloadIssued.current) return; + const controller = navigator.serviceWorker.controller; + if (!controller) return; + const consented = pendingConsent.current?.worker === controller; + clearConsent(); + if (consented) { + reloadIssued.current = true; + window.location.reload(); + return; + } + // An update accepted in another tab must not discard this tab's draft. + activatedWorker.current = controller; + setActivatedUpdate(true); + setUpdateDismissed(false); + setBusy(false); + setFeedback('其他窗口已完成更新。当前窗口会保留原有内容,请保存输入后再重新打开。'); + }, [clearConsent]); const { - needRefresh: [needRefresh, setNeedRefresh], + needRefresh: [needRefresh], offlineReady: [offlineReady, setOfflineReady], updateServiceWorker, - } = useRegisterSW(); + } = useRegisterSW({ + onNeedReload: handleActivatedUpdate, + onRegisteredSW(_url, value) { + registration.current = value; + }, + onRegisterError() { + setFeedback('离线组件暂未就绪,联网后重新打开可重试。'); + }, + }); + const updateAvailable = needRefresh || activatedUpdate; + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + const workers = navigator.serviceWorker; + let previous = workers.controller; + const changed = () => { + const controller = workers.controller; + const previousController = previous; + previous = controller; + // Also cover a page whose first registration predated the update. The + // plugin's isUpdate flag is fixed when that registration was created. + if ( + controller && + controller !== previousController && + (previousController || pendingConsent.current?.worker === controller) + ) + handleActivatedUpdate(); + }; + workers.addEventListener('controllerchange', changed); + return () => { + workers.removeEventListener('controllerchange', changed); + clearConsent(); + }; + }, [clearConsent, handleActivatedUpdate]); + useEffect(() => { + const show = () => { + setOpen(true); + setFeedback(''); + }; + const capture = (event: Event) => { + event.preventDefault(); + setPrompt(event as InstallPrompt); + }; + const done = () => { + setPrompt(null); + setInstalled(true); + }; + const foreground = () => { + if (!document.hidden && navigator.onLine) + void registration.current?.update().catch(() => undefined); + }; + window.addEventListener('taskdock:pwa-help', show); + window.addEventListener('beforeinstallprompt', capture); + window.addEventListener('appinstalled', done); + document.addEventListener('visibilitychange', foreground); + return () => { + window.removeEventListener('taskdock:pwa-help', show); + window.removeEventListener('beforeinstallprompt', capture); + window.removeEventListener('appinstalled', done); + document.removeEventListener('visibilitychange', foreground); + }; + }, []); useEffect(() => { if (!offlineReady) return; const timer = window.setTimeout(() => setOfflineReady(false), 5000); return () => window.clearTimeout(timer); }, [offlineReady, setOfflineReady]); - if (needRefresh) - return ( - void updateServiceWorker(true) }} - onDismiss={() => setNeedRefresh(false)} - duration={0} - /> - ); - - if (offlineReady) - return ( - setOfflineReady(false) }} - onDismiss={() => setOfflineReady(false)} - duration={5000} - /> - ); - - return null; + const install = async () => { + if (!prompt) return; + setBusy(true); + try { + await prompt.prompt(); + const choice = await prompt.userChoice; + setFeedback( + choice.outcome === 'accepted' + ? '已请求安装,可从主屏幕打开。' + : '可稍后从浏览器菜单再次安装。', + ); + setPrompt(null); + } catch { + setFeedback('请从浏览器菜单选择安装应用或添加到主屏幕。'); + } finally { + setBusy(false); + } + }; + const update = async () => { + setBusy(true); + try { + // Leave the user's durable offline mutations and upgrade queue in place; + // wait for synchronization before changing the code that consumes them. + if ( + db && + (connection === 'syncing' || + (await db.outbox.count()) > 0 || + (await readUpgradePending(db)).length > 0) + ) { + setFeedback('还有正在同步或待同步的改动。请联网完成同步后再更新,本地任务会继续保留。'); + return; + } + if (!window.confirm('更新会重新打开页面。请确认当前输入和设置已经保存,再继续更新。')) return; + const waiting = registration.current?.waiting; + if ( + !waiting && + activatedWorker.current && + activatedWorker.current === navigator.serviceWorker.controller + ) { + // Another tab already activated the worker; there is no SKIP_WAITING + // message to send, but this tab has now passed its own safety checks. + reloadIssued.current = true; + window.location.reload(); + return; + } + if (!waiting || waiting.state !== 'installed') { + setFeedback('更新尚未就绪,请稍后重试。'); + return; + } + clearConsent(); + const revoked = () => { + if (waiting.state !== 'redundant' || pendingConsent.current?.worker !== waiting) return; + clearConsent(); + setBusy(false); + setFeedback('候选更新未能启用,请稍后重新确认。当前内容仍会保留。'); + }; + const timeout = window.setTimeout(() => { + if (pendingConsent.current?.worker !== waiting) return; + clearConsent(); + setBusy(false); + setFeedback('更新尚未完成。当前窗口会保留,请稍后重新确认。'); + }, 10_000); + pendingConsent.current = { + worker: waiting, + dispose: () => { + window.clearTimeout(timeout); + waiting.removeEventListener('statechange', revoked); + }, + }; + waiting.addEventListener('statechange', revoked); + await updateServiceWorker(true); + } catch { + clearConsent(); + setFeedback('暂时无法更新,请稍后重试。当前版本仍可继续使用。'); + } finally { + if (!pendingConsent.current) setBusy(false); + } + }; + return ( + <> + {updateAvailable && !updateDismissed ? ( + setOpen(true) }} + onDismiss={() => setUpdateDismissed(true)} + duration={0} + /> + ) : ( + offlineReady && ( + setOfflineReady(false) }} + onDismiss={() => setOfflineReady(false)} + duration={5000} + /> + ) + )} + { + if (!busy) setOpen(false); + }} + title="安装与离线使用" + footer={ + + } + > +
+ {installed ? ( +

当前已在独立应用窗口中打开 TaskDock。

+ ) : ( + <> +

安装后可从主屏幕打开同一个任务工作区。

+ {!window.isSecureContext &&

请使用 HTTPS 地址打开,才能启用安装和离线启动。

} + {prompt && ( + + )} +

+ iPhone / iPad:在 Safari 分享菜单选择“添加到主屏幕”,并启用“作为网页 App 打开”。 +

+

+ 安卓:在浏览器菜单查找“安装应用”或“添加到主屏幕”。Edge + 的菜单和支持程度取决于版本;没有弹出安装提示时仍可查看菜单,或用 Chrome + 打开同一地址安装。 +

+ + )} +

已保存的任务保留在本机并在联网后同步。尚未访问的页面或服务器功能可能需要联网。

+ {updateAvailable && ( + <> +

新版本已下载。请先保存当前输入;完成待同步改动后可更新并重新打开。

+ + + )} + {feedback &&

{feedback}

} +
+
+ + ); } diff --git a/apps/web/src/styles/tablet.css b/apps/web/src/styles/tablet.css index e847344..3f13710 100644 --- a/apps/web/src/styles/tablet.css +++ b/apps/web/src/styles/tablet.css @@ -75,3 +75,72 @@ display: none; } } + +/* Pane count follows available window space, including tablet split view. */ +@media (max-width: 839.98px) { + .is-tablet-frame .today-task-workspace, + .is-tablet-frame .calendar-layout { + grid-template-columns: minmax(0, 1fr); + } + .is-tablet-frame .page { + inline-size: calc(100% - 32px); + max-inline-size: calc(100% - 32px); + padding-inline: 0; + } + .is-tablet-frame .m3e-app-bar__row { + padding-inline: 16px; + } +} + +.app-frame.is-phone-frame { + flex-direction: column; +} +.is-phone-frame .m3e-navigation-bar { + display: grid; + flex: none; +} +.is-phone-frame .m3e-app-bar__row { + min-inline-size: 0; +} +.app-frame.is-tablet-frame, +.app-frame.is-phone-frame { + block-size: var(--dt-viewport-height, 100dvh); +} + +/* Portals are outside the app frame; keyboard bounds must also cover large iPads. */ +@media (any-pointer: coarse) { + .m3e-dialog-layer, + .m3e-sheet-layer, + .m3e-side-sheet-layer, + .m3e-drawer-layer:not(.m3e-drawer-layer--persistent) { + inset-block-start: var(--dt-viewport-top, 0px); + inset-block-end: auto; + block-size: var(--dt-viewport-height, 100dvh); + } + .auth-page { + block-size: var(--dt-viewport-height, 100dvh); + min-block-size: 0; + overflow-y: auto; + padding-block: max(24px, var(--dt-safe-top)) max(24px, var(--dt-safe-bottom)); + } + .keyboard-visible .is-phone-frame .m3e-navigation-bar, + .keyboard-visible .app-float-layer { + display: none; + } +} +.pwa-help { + display: grid; + gap: 16px; + overflow-wrap: anywhere; +} +.pwa-help p { + margin: 0; + line-height: 1.65; +} + +.is-tablet-frame .m3e-navigation-rail { + overflow-y: auto; +} +.is-tablet-frame .m3e-navigation-rail__list { + flex-shrink: 0; +} diff --git a/apps/web/src/sw.ts b/apps/web/src/sw.ts new file mode 100644 index 0000000..4644dab --- /dev/null +++ b/apps/web/src/sw.ts @@ -0,0 +1,91 @@ +/// +import { PrecacheController } from 'workbox-precaching'; +import { NavigationRoute, registerRoute } from 'workbox-routing'; + +declare const self: ServiceWorkerGlobalScope; +const precache = new PrecacheController({ cacheName: 'taskdock-precache-v2' }); +precache.addToCacheList(self.__WB_MANIFEST); +const retainedAssets = 'taskdock-hashed-assets-v2'; +const immutableAsset = (url: URL) => + url.origin === self.location.origin && + /^\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.(?:js|css)$/.test(url.pathname) && + !url.search; + +self.addEventListener('install', (event) => { + void precache.install(event); +}); +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting(); +}); +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const retained = await caches.open(retainedAssets); + // Save immutable chunks before Workbox removes previous revision entries. + // Other open tabs can finish loading their visited graph after this update. + const oldCaches = (await caches.keys()).filter( + (name) => + name === 'taskdock-precache-v2' || + name.startsWith('workbox-precache') || + name === 'taskdock-hashed-assets-v1', + ); + for (const name of oldCaches) { + const old = await caches.open(name); + for (const request of await old.keys()) { + const url = new URL(request.url); + if (!immutableAsset(url)) continue; + const response = await old.match(request); + if (response?.ok) await retained.put(url.href, response); + } + } + const keys = await retained.keys(); + for (const key of keys.slice(0, Math.max(0, keys.length - 96))) await retained.delete(key); + await precache.activate(event); + // Only obsolete TaskDock shell caches are removed. IndexedDB and its + // offline queue are independent and must never be cleared by an update. + for (const name of oldCaches) if (name !== 'taskdock-precache-v2') await caches.delete(name); + await self.clients.claim(); + })(), + ); +}); + +// Restrict precaching to build-declared public files. API/auth requests and +// uploaded content never enter Cache Storage. +registerRoute( + ({ url, request, sameOrigin }) => + sameOrigin && + request.method === 'GET' && + !request.headers.has('Authorization') && + !url.pathname.startsWith('/api/') && + precache.getURLsToCacheKeys().has(url.href), + ({ request }) => precache.matchPrecache(request).then((response) => response ?? fetch(request)), +); +registerRoute( + new NavigationRoute(precache.createHandlerBoundToURL('/index.html'), { + denylist: [ + /^\/api(?:\/|$)/, + /^\/health(?:\/|$)/, + /^\/version$/, + /^\/assets(?:\/|$)/, + /^\/sw\.js$/, + /^\/manifest\.webmanifest$/, + /\.[a-z0-9]+(?:\?|$)/i, + ], + }), +); +registerRoute( + ({ url, request }) => immutableAsset(url) && !request.headers.has('Authorization'), + async ({ request }) => { + const cache = await caches.open(retainedAssets); + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + const type = response.headers.get('Content-Type') ?? ''; + if (response.ok && !response.redirected && /javascript|text\/css/.test(type)) { + await cache.put(request, response.clone()); + const keys = await cache.keys(); + for (const key of keys.slice(0, Math.max(0, keys.length - 96))) await cache.delete(key); + } + return response; + }, +); diff --git a/apps/web/test/device.test.ts b/apps/web/test/device.test.ts index d20dcd4..c0fc834 100644 --- a/apps/web/test/device.test.ts +++ b/apps/web/test/device.test.ts @@ -1,36 +1,26 @@ import { describe, expect, it } from 'vitest'; import { determineFormFactor, detectIPad } from '../src/device.js'; -describe('determineFormFactor', () => { - it('identifies compact size as phone regardless of touch', () => { - expect(determineFormFactor('compact', false)).toBe('phone'); - expect(determineFormFactor('compact', true)).toBe('phone'); +describe('device identity and adaptive window sizes', () => { + it('keeps wide iPads and Android tablets as tablets', () => { + expect(determineFormFactor('large', true, true, 'Macintosh', 1024, false)).toBe('tablet'); + expect(determineFormFactor('xlarge', true, false, 'Android Tablet', 1200, true)).toBe('tablet'); + expect(determineFormFactor('compact', true, true, 'Macintosh', 1024, false)).toBe('tablet'); }); - - it('identifies medium and expanded touch devices as tablet', () => { - expect(determineFormFactor('medium', true)).toBe('tablet'); - expect(determineFormFactor('expanded', true)).toBe('tablet'); + it('keeps a phone navigation model after rotation', () => { + expect(determineFormFactor('expanded', true, false, 'iPhone', 390, true)).toBe('phone'); + expect(determineFormFactor('medium', true, false, 'Android Mobile', 430, true)).toBe('phone'); }); - - it('identifies medium and expanded non-touch devices as desktop by default', () => { - expect(determineFormFactor('medium', false)).toBe('desktop'); - expect(determineFormFactor('expanded', false)).toBe('desktop'); + it('uses compact fallback on an unknown device', () => { + expect(determineFormFactor('compact', false, false, '', 0, false)).toBe('phone'); }); - - it('identifies medium and expanded as tablet when isIPadDevice is true', () => { - expect(determineFormFactor('medium', false, true)).toBe('tablet'); - expect(determineFormFactor('expanded', false, true)).toBe('tablet'); + it('does not treat a large touch-enabled laptop as a tablet', () => { + expect(determineFormFactor('large', true, false, 'Windows NT', 1080, false)).toBe('desktop'); + expect(determineFormFactor('large', false, false, 'Windows NT', 1080, false)).toBe('desktop'); }); - - it('identifies large and xlarge screens as desktop even with touch', () => { - expect(determineFormFactor('large', true)).toBe('desktop'); - expect(determineFormFactor('xlarge', true)).toBe('desktop'); - expect(determineFormFactor('large', false)).toBe('desktop'); - }); -}); - -describe('detectIPad', () => { - it('handles environment without navigator', () => { - expect(typeof detectIPad()).toBe('boolean'); + it('uses touch and available space as the fallback for smaller windows', () => { + expect(determineFormFactor('medium', true, false, '', 800, true)).toBe('tablet'); + expect(determineFormFactor('expanded', false, false, '', 800, false)).toBe('desktop'); }); + it('can inspect a non-browser runtime', () => expect(typeof detectIPad()).toBe('boolean')); }); diff --git a/apps/web/test/pwa-icons.test.ts b/apps/web/test/pwa-icons.test.ts index 6a744c7..4c79cf5 100644 --- a/apps/web/test/pwa-icons.test.ts +++ b/apps/web/test/pwa-icons.test.ts @@ -1,10 +1,20 @@ +import { createHash } from 'node:crypto'; +import { inflateSync } from 'node:zlib'; import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; const webRoot = new URL('../', import.meta.url); const publicRoot = new URL('public/', webRoot); -const html = readFileSync(new URL('index.html', webRoot), 'utf8'); +const revision = createHash('sha256') + .update('taskdock-pwa-v2') + .update(readFileSync(new URL('../../assets/branding/taskdock-icon.png', webRoot))) + .digest('hex') + .slice(0, 16); +const html = readFileSync(new URL('index.html', webRoot), 'utf8').replaceAll( + '%TASKDOCK_ICON_REVISION%', + revision, +); interface IconLink { href: string; @@ -59,8 +69,43 @@ describe('PWA icon links', () => { it('keep a manifest with usable any-purpose icons', () => { const config = readFileSync(new URL('vite.config.ts', webRoot), 'utf8'); - expect(config).toContain("src: '/pwa-192.png'"); - expect(config).toContain("src: '/pwa-512.png'"); - expect(config).not.toContain("src: '/pwa-64.png'"); + expect(config).toContain("src: iconUrl('pwa-192.png')"); + expect(config).toContain("src: iconUrl('pwa-512.png')"); + expect(config).not.toContain("src: iconUrl('pwa-64.png')"); + }); + it('ships distinct high-resolution maskable files and opaque launcher PNGs', () => { + for (const name of [ + 'pwa-192.png', + 'pwa-512.png', + 'pwa-1024.png', + 'pwa-maskable-1024.png', + 'apple-touch-icon-180x180.png', + 'apple-touch-icon-167x167.png', + 'apple-touch-icon-152x152.png', + ]) { + const bytes = readFileSync(new URL(`public/icons/${revision}/${name}`, webRoot)); + const { width, height } = pngDimensions(bytes); + expect(bytes[25]).toBe(6); + const chunks: Buffer[] = []; + for (let offset = 8; offset < bytes.length;) { + const length = bytes.readUInt32BE(offset); + if (bytes.toString('ascii', offset + 4, offset + 8) === 'IDAT') + chunks.push(bytes.subarray(offset + 8, offset + 8 + length)); + offset += length + 12; + } + const pixels = inflateSync(Buffer.concat(chunks)); + for (let y = 0; y < height; y++) { + const row = y * (width * 4 + 1); + expect(pixels[row]).toBe(0); + for (let x = 0; x < width; x++) + if (pixels[row + 1 + x * 4 + 3] !== 255) + throw new Error(`${name} has transparency at ${x},${y}`); + } + } + expect( + readFileSync(new URL(`public/icons/${revision}/pwa-512.png`, webRoot)).equals( + readFileSync(new URL(`public/icons/${revision}/pwa-maskable-512.png`, webRoot)), + ), + ).toBe(false); }); }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6dd7d62..7165269 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,4 +1,13 @@ import react from '@vitejs/plugin-react'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +const iconRevision = createHash('sha256') + .update('taskdock-pwa-v2') + .update(readFileSync(new URL('../../assets/branding/taskdock-icon.png', import.meta.url))) + .digest('hex') + .slice(0, 16); +const iconUrl = (name: string) => '/icons/' + iconRevision + '/' + name; import { brotliCompressSync, gzipSync, constants } from 'node:zlib'; import { readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -11,6 +20,13 @@ export default defineConfig({ }, plugins: [ react(), + { + name: 'taskdock-versioned-icons', + transformIndexHtml: { + order: 'pre', + handler: (html) => html.replaceAll('%TASKDOCK_ICON_REVISION%', iconRevision), + }, + }, { name: 'taskdock-precompressed-assets', apply: 'build', @@ -33,6 +49,9 @@ export default defineConfig({ }, }, VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', registerType: 'prompt', devOptions: { enabled: true, @@ -66,16 +85,23 @@ export default defineConfig({ theme_color: '#f5f2f9', background_color: '#f5f2f9', icons: [ - { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, - { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: iconUrl('pwa-1024.png'), sizes: '1024x1024', type: 'image/png', purpose: 'any' }, + { + src: iconUrl('pwa-maskable-1024.png'), + sizes: '1024x1024', + type: 'image/png', + purpose: 'maskable', + }, + { src: iconUrl('pwa-192.png'), sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: iconUrl('pwa-512.png'), sizes: '512x512', type: 'image/png', purpose: 'any' }, { - src: '/pwa-maskable-192.png', + src: iconUrl('pwa-maskable-192.png'), sizes: '192x192', type: 'image/png', purpose: 'maskable', }, { - src: '/pwa-maskable-512.png', + src: iconUrl('pwa-maskable-512.png'), sizes: '512x512', type: 'image/png', purpose: 'maskable', @@ -87,55 +113,34 @@ export default defineConfig({ short_name: '今日', description: '查看今天的安排与待办任务', url: '/today', - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + icons: [{ src: iconUrl('pwa-192.png'), sizes: '192x192', type: 'image/png' }], }, { name: '新建任务', short_name: '新建', description: '快速创建并捕获一条新任务', url: '/today?action=new-task', - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + icons: [{ src: iconUrl('pwa-192.png'), sizes: '192x192', type: 'image/png' }], }, { name: '任务目录树', short_name: '目录', description: '浏览完整文件夹结构与任务清单', url: '/tree', - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + icons: [{ src: iconUrl('pwa-192.png'), sizes: '192x192', type: 'image/png' }], }, { name: '工作流看板', short_name: '流程', description: '按阶段跟踪任务流动与交付', url: '/workflows', - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + icons: [{ src: iconUrl('pwa-192.png'), sizes: '192x192', type: 'image/png' }], }, ], }, - workbox: { + injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png}'], - cleanupOutdatedCaches: true, - // Core routes stay available offline; large optional surfaces are cached - // when visited rather than competing with the first useful screen. globIgnores: ['**/WorkflowsPage-*.js'], - runtimeCaching: [ - { - urlPattern: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/assets/'), - handler: 'CacheFirst', - options: { - cacheName: 'taskdock-hashed-assets-v1', - cacheableResponse: { statuses: [200] }, - expiration: { maxEntries: 64, maxAgeSeconds: 31_536_000, purgeOnQuotaError: true }, - }, - }, - ], - navigateFallback: '/index.html', - navigateFallbackDenylist: [ - /^\/api(?:\/|$)/, - /^\/health(?:\/|$)/, - /^\/version$/, - /^\/assets(?:\/|$)/, - ], }, }), ], diff --git a/docs/PWA_MOBILE.md b/docs/PWA_MOBILE.md new file mode 100644 index 0000000..9f085bb --- /dev/null +++ b/docs/PWA_MOBILE.md @@ -0,0 +1,55 @@ +# PWA and adaptive web client + +The normal business URL is the installable application. `/manifest.webmanifest` and `/sw.js` +are stable public endpoints; the application id and scope remain `/`, and its launch route +remains `/today`. No separate PWA or mobile site is required. The marketing website is a +separate product surface and does not install a second copy of the task workbench. + +## Installation and icons + +Installation help is reachable before login, from Settings, and from More. A supported +`beforeinstallprompt` event enables the native prompt; absence of the event does not mean +that the website is unusable or that manual installation is unavailable. iOS/iPadOS users +use Safari's Share menu and Add to Home Screen. Android browser versions differ, including +Edge; Chrome and Edge must be validated separately on physical devices. + +`scripts/brand-assets.ts` generates the PNG set from the existing TaskDock master image. +PWA assets are composited onto an opaque brand canvas. Normal and maskable images have +separate padding and include 192, 512 and 1024 px versions; Apple images include 180, 167 +and 152 px versions. The HTML and manifest refer to `/icons//` while +stable root aliases remain for automatic Apple probes and older installations. Run +`pnpm exec tsx scripts/brand-assets.ts --web-only` for a web-only regeneration. + +## Version updates and offline state + +The Vite PWA adapter uses Workbox injectManifest. The worker caches only the build's public +shell graph and immutable JS/CSS assets; API, authentication and user uploads are excluded. +The new worker waits until the user chooses to update. The UI checks the durable outbox and +protocol upgrade queue, waits for active synchronization to finish, and asks the user to +save inputs before reopening. It never deletes IndexedDB or queued task mutations. + +Before old precache entries are removed, their immutable JS/CSS assets are copied to a +bounded TaskDock cache (96 entries). This allows other already-open tabs to finish loading +their cached release graph. An optional old chunk that was never cached still requires the +server to retain that immutable file; deployment must not assume all clients close at once. + +HTML, the canonical manifest and the worker use `no-store, no-cache, max-age=0, +must-revalidate`. Content-addressed assets remain immutable. Cloudflare/CDN rules must +respect origin cache directives or bypass caching for these exact PWA control endpoints +and navigational HTML on the business host. Do not lower cache TTL across an entire zone. +A source change alone does not prove that the running deployment or CDN was updated. + +## Phone and tablet layouts + +Device identity and available window space are separate. Rotating a phone keeps phone +navigation; a large iPad or Android tablet keeps tablet navigation. A tablet's compact +split-screen window uses a single pane and mobile navigation. Supporting panes start at +840 px, and keyboard overlays use VisualViewport bounds on large tablets too. Preserve +safe-area insets, text scaling and pinch zoom. + +Acceptance includes all main routes, login, task capture, directory, workflow, calendar, +events, settings, overlays, error and offline states at phone widths 320/390/430, tablet +widths 600/768/834/1024/1366, both orientations and tablet split view. Physical iOS 27 and +Android Chrome/Edge installation, home-screen artwork, standalone launch and update checks +are required in addition to automated browser tests. Desktop WebKit emulation cannot prove +iOS launcher icon selection or an Android Edge installation path. diff --git a/eslint.config.mjs b/eslint.config.mjs index ca410d6..3a58331 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,4 +41,40 @@ export default tseslint.config( }, }, }, + { + files: ['website/scripts/*.mjs'], + languageOptions: { + globals: { + process: 'readonly', + console: 'readonly', + URL: 'readonly', + fetch: 'readonly', + AbortSignal: 'readonly', + }, + }, + }, + { + files: ['website/site/assets/js/*.js'], + languageOptions: { + globals: { + document: 'readonly', + window: 'readonly', + navigator: 'readonly', + console: 'readonly', + URL: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + localStorage: 'readonly', + performance: 'readonly', + getComputedStyle: 'readonly', + IntersectionObserver: 'readonly', + ResizeObserver: 'readonly', + requestAnimationFrame: 'readonly', + cancelAnimationFrame: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + }, + }, + }, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc151bc..453e611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,12 @@ importers: vite-plugin-pwa: specifier: ^1.0.2 version: 1.3.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13))(workbox-build@7.4.1(@types/babel__core@7.20.5)(supports-color@7.2.0))(workbox-window@7.4.1) + workbox-precaching: + specifier: ^7.4.1 + version: 7.4.1 + workbox-routing: + specifier: ^7.4.1 + version: 7.4.1 workbox-window: specifier: ^7.4.1 version: 7.4.1 diff --git a/scripts/brand-assets.ts b/scripts/brand-assets.ts index 44cddad..ab2fedb 100644 --- a/scripts/brand-assets.ts +++ b/scripts/brand-assets.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -240,36 +241,57 @@ async function main(): Promise { encodePng(width, height, resizeCover(source, width, height)); const resizedIcon = (width: number, height: number, contentScale = 0.72) => encodePng(width, height, resizeContain(source, width, height, contentScale)); + const webIcon = (size: number, contentScale = 0.72) => { + const pixels = resizeContain(source, size, size, contentScale); + // Always composite onto the brand canvas: RGBA files may still be opaque, + // but no launcher should have to invent a backdrop for transparent pixels. + const background = source.rgba.subarray(0, 3); + for (let offset = 0; offset < pixels.length; offset += 4) { + const alpha = pixels[offset + 3]! / 255; + for (let channel = 0; channel < 3; channel++) + pixels[offset + channel] = Math.round( + pixels[offset + channel]! * alpha + background[channel]! * (1 - alpha), + ); + pixels[offset + 3] = 255; + } + return encodePng(size, size, pixels); + }; + const iconRevision = createHash('sha256') + .update('taskdock-pwa-v2') + .update(sourceBytes) + .digest('hex') + .slice(0, 16); const webPublic = join(rootDir, 'apps', 'web', 'public'); - await writeBinary(join(webPublic, 'favicon.png'), resizedIcon(64, 64)); - await writeBinary(join(webPublic, 'pwa-64.png'), resizedIcon(64, 64)); - await writeBinary(join(webPublic, 'pwa-180.png'), resizedIcon(180, 180)); - await writeBinary(join(webPublic, 'pwa-192.png'), resizedIcon(192, 192)); - await writeBinary(join(webPublic, 'pwa-512.png'), resizedIcon(512, 512)); - await writeBinary(join(webPublic, 'pwa-maskable-192.png'), resizedIcon(192, 192, 0.65)); - await writeBinary(join(webPublic, 'pwa-maskable-512.png'), resizedIcon(512, 512, 0.65)); - - // Apple touch icons with solid background. Safari/iOS cache a web clip icon - // against its URL for the lifetime of the installed app, so the icons - // referenced by index.html live under /icons/ (a new URL space since the - // original installs). The root-level copies stay in place for Safari's - // automatic /apple-touch-icon.png probe and older service workers. - await writeBinary(join(webPublic, 'apple-touch-icon.png'), resizedIcon(180, 180)); - await writeBinary(join(webPublic, 'apple-touch-icon-180x180.png'), resizedIcon(180, 180)); - await writeBinary(join(webPublic, 'apple-touch-icon-167x167.png'), resizedIcon(167, 167)); - await writeBinary(join(webPublic, 'apple-touch-icon-152x152.png'), resizedIcon(152, 152)); - await writeBinary(join(webPublic, 'apple-touch-icon-120x120.png'), resizedIcon(120, 120)); - - const appleTouchIcons = join(webPublic, 'icons'); - await writeBinary(join(appleTouchIcons, 'apple-touch-icon.png'), resizedIcon(180, 180)); - await writeBinary(join(appleTouchIcons, 'apple-touch-icon-180x180.png'), resizedIcon(180, 180)); - await writeBinary(join(appleTouchIcons, 'apple-touch-icon-167x167.png'), resizedIcon(167, 167)); - await writeBinary(join(appleTouchIcons, 'apple-touch-icon-152x152.png'), resizedIcon(152, 152)); - await writeBinary(join(appleTouchIcons, 'apple-touch-icon-120x120.png'), resizedIcon(120, 120)); - + const versioned = join(webPublic, 'icons', iconRevision); + for (const size of [64, 180, 192, 512, 1024]) { + const bytes = webIcon(size); + await writeBinary(join(webPublic, `pwa-${size}.png`), bytes); + await writeBinary(join(versioned, `pwa-${size}.png`), bytes); + } + await writeBinary(join(webPublic, 'favicon.png'), webIcon(64)); + for (const size of [192, 512, 1024]) { + const bytes = webIcon(size, 0.65); + await writeBinary(join(webPublic, `pwa-maskable-${size}.png`), bytes); + await writeBinary(join(versioned, `pwa-maskable-${size}.png`), bytes); + } + // Stable aliases support automatic Apple probes and previously installed apps. + for (const size of [120, 152, 167, 180]) { + const name = `apple-touch-icon-${size}x${size}.png`; + const bytes = webIcon(size); + await writeBinary(join(webPublic, name), bytes); + await writeBinary(join(webPublic, 'icons', name), bytes); + await writeBinary(join(versioned, name), bytes); + } + for (const path of [ + join(webPublic, 'apple-touch-icon.png'), + join(webPublic, 'icons', 'apple-touch-icon.png'), + ]) + await writeBinary(path, webIcon(180)); await writeBinary(join(webPublic, 'brand', 'taskdock-icon.png'), resized(512, 512)); + if (process.argv.includes('--web-only')) return; + const androidRes = join(rootDir, 'apps', 'mobile', 'android', 'app', 'src', 'main', 'res'); const backgroundColor = `#${source.rgba.subarray(0, 3).toString('hex').toUpperCase()}`; await writeBinary( diff --git a/website/README.md b/website/README.md index 598aaab..9992527 100644 --- a/website/README.md +++ b/website/README.md @@ -12,14 +12,14 @@ TaskDock 的产品官网与下载入口,源码位于产品仓库的 `website/` ## 目录结构 -| 路径 | 内容 | -| --- | --- | -| `site/` | 站点源文件:首页、下载/部署页、404、`assets/`、`_headers`、`sitemap.xml`、`robots.txt` | -| `scripts/build-site.mjs` | 生成 `dist/`,注入最新发布版本/日期/渠道/APK 名称 | -| `scripts/check-site.mjs` | 静态检查:元信息、站内链接与锚点、外部资源、脚本语法、体积预算 | -| `docs/SITE.md` | 官网定位、设计系统、内容事实来源与维护 | -| `docs/HOSTING.md` | Cloudflare 托管、自动部署与回滚 | -| `docs/WORKSPACE.md` | 本机 worktree 与验证约定 | +| 路径 | 内容 | +| ------------------------ | -------------------------------------------------------------------------------------- | +| `site/` | 站点源文件:首页、下载/部署页、404、`assets/`、`_headers`、`sitemap.xml`、`robots.txt` | +| `scripts/build-site.mjs` | 生成 `dist/`,注入最新发布版本/日期/渠道/APK 名称 | +| `scripts/check-site.mjs` | 静态检查:元信息、站内链接与锚点、外部资源、脚本语法、体积预算 | +| `docs/SITE.md` | 官网定位、设计系统、内容事实来源与维护 | +| `docs/HOSTING.md` | Cloudflare 托管、自动部署与回滚 | +| `docs/WORKSPACE.md` | 本机 worktree 与验证约定 | ## 开发 diff --git a/website/docs/HOSTING.md b/website/docs/HOSTING.md index fc263d6..22f5384 100644 --- a/website/docs/HOSTING.md +++ b/website/docs/HOSTING.md @@ -5,17 +5,17 @@ ## 当前配置 -| 项 | 值 | -| --- | --- | -| Pages 项目 | `taskdock-website`(Direct Upload) | -| 部署来源 | `IGNGserver/taskdock` 的 `website/` | -| 部署工作流 | `.github/workflows/website.yml` | -| 生产分支 | `master`(产品仓库默认分支) | -| 构建命令 | `node scripts/build-site.mjs`(在 `website/` 下,无 npm 依赖) | -| 发布目录 | `website/dist/` | -| 自定义域名 | `https://taskdock.igng.net/`(zone `igng.net`) | -| 默认别名 | `https://taskdock-website.pages.dev/`(保留) | -| DNS | proxied CNAME `taskdock.igng.net` → `taskdock-website.pages.dev` | +| 项 | 值 | +| ---------- | ---------------------------------------------------------------- | +| Pages 项目 | `taskdock-website`(Direct Upload) | +| 部署来源 | `IGNGserver/taskdock` 的 `website/` | +| 部署工作流 | `.github/workflows/website.yml` | +| 生产分支 | `master`(产品仓库默认分支) | +| 构建命令 | `node scripts/build-site.mjs`(在 `website/` 下,无 npm 依赖) | +| 发布目录 | `website/dist/` | +| 自定义域名 | `https://taskdock.igng.net/`(zone `igng.net`) | +| 默认别名 | `https://taskdock-website.pages.dev/`(保留) | +| DNS | proxied CNAME `taskdock.igng.net` → `taskdock-website.pages.dev` | `build-site.mjs` 从 GitHub Releases 取最新发布,注入版本、发布日期、渠道(预览版/正式版)和 APK 名称; `check-site.mjs` 检查元信息、站内链接与锚点、外部资源、脚本语法与体积预算。两者都在部署前运行。 @@ -36,24 +36,24 @@ 需要两个 repository secrets: -| Secret | 用途 | -| --- | --- | -| `CLOUDFLARE_API_TOKEN` | 最小权限 token,仅需 Cloudflare Pages:Edit | -| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账号 ID | +| Secret | 用途 | +| ----------------------- | ------------------------------------------ | +| `CLOUDFLARE_API_TOKEN` | 最小权限 token,仅需 Cloudflare Pages:Edit | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账号 ID | 凭据只放 GitHub Actions Secrets,不写进仓库、文档、日志或前端产物。继续复用现有 Direct Upload 项目, 不要新建项目或改成 Git 集成。 ## 构建参数 -| 项 | 值 | -| --- | --- | -| 运行时 | Node.js ≥ 22(内置 `fetch` 与 `fs.cp`) | -| 站点源目录 | `website/site/` | -| 生成目录 | `website/dist/`(不入库) | -| 响应头 | `website/site/_headers`:CSP、`nosniff`、`frame-ancestors 'none'`、静态资源缓存 | -| 404 | `website/site/404.html`,资源使用根路径 | -| 外部请求 | 浏览器读取 `https://api.github.com/repos/IGNGserver/taskdock/releases`(CSP `connect-src` 已放行) | +| 项 | 值 | +| ---------- | -------------------------------------------------------------------------------------------------- | +| 运行时 | Node.js ≥ 22(内置 `fetch` 与 `fs.cp`) | +| 站点源目录 | `website/site/` | +| 生成目录 | `website/dist/`(不入库) | +| 响应头 | `website/site/_headers`:CSP、`nosniff`、`frame-ancestors 'none'`、静态资源缓存 | +| 404 | `website/site/404.html`,资源使用根路径 | +| 外部请求 | 浏览器读取 `https://api.github.com/repos/IGNGserver/taskdock/releases`(CSP `connect-src` 已放行) | ## 发布与回滚 diff --git a/website/docs/SITE.md b/website/docs/SITE.md index 6e9913a..e533613 100644 --- a/website/docs/SITE.md +++ b/website/docs/SITE.md @@ -7,10 +7,10 @@ TaskDock 的产品官网:介绍产品、提供下载与部署入口和文档链接,并作为搜索引擎与 AI 工具的可引用入口。 站点源码位于产品仓库的 `website/`: -| 事项 | 归属 | -| --- | --- | -| 产品功能、版本、发布资产、产品文档 | 产品仓库 `IGNGserver/taskdock`(默认分支 `master`) | -| 页面、样式、静态资源、SEO、站点文案 | `IGNGserver/taskdock/website/` | +| 事项 | 归属 | +| ----------------------------------- | --------------------------------------------------- | +| 产品功能、版本、发布资产、产品文档 | 产品仓库 `IGNGserver/taskdock`(默认分支 `master`) | +| 页面、样式、静态资源、SEO、站点文案 | `IGNGserver/taskdock/website/` | 产品定位(以产品仓库 `README.md` 为准):面向个人开发者的离线优先、自托管任务工作台,单用户使用; 数据存放在用户自己的服务器与 PostgreSQL 数据库。当前活动客户端是响应式 Web/PWA 与原生 Android; @@ -28,13 +28,13 @@ TaskDock 的产品官网:介绍产品、提供下载与部署入口和文档 ## 已定案的决策 -| 事项 | 决定 | -| --- | --- | -| 技术栈 | 零依赖静态站点:`website/site/` 下手写 HTML + CSS + 原生 ES 模块 JS;无框架、无 `package.json`;部署前由 Node 标准库脚本 `website/scripts/build-site.mjs` 注入最新发布并生成 `dist/` | -| 页面 | `index.html`(首页)、`download.html`(下载与部署)、`404.html`;文档入口链接到产品仓库 `docs/`,不在本站复制文档 | -| 语言 | 简体中文单语(与产品文档一致);`` | -| 视觉方向 | 见下文“设计系统” | -| Cloudflare Pages 与地址 | 项目 `taskdock-website`;正式地址 `https://taskdock.igng.net/`,默认别名 `https://taskdock-website.pages.dev/`,见 `docs/HOSTING.md` | +| 事项 | 决定 | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 技术栈 | 零依赖静态站点:`website/site/` 下手写 HTML + CSS + 原生 ES 模块 JS;无框架、无 `package.json`;部署前由 Node 标准库脚本 `website/scripts/build-site.mjs` 注入最新发布并生成 `dist/` | +| 页面 | `index.html`(首页)、`download.html`(下载与部署)、`404.html`;文档入口链接到产品仓库 `docs/`,不在本站复制文档 | +| 语言 | 简体中文单语(与产品文档一致);`` | +| 视觉方向 | 见下文“设计系统” | +| Cloudflare Pages 与地址 | 项目 `taskdock-website`;正式地址 `https://taskdock.igng.net/`,默认别名 `https://taskdock-website.pages.dev/`,见 `docs/HOSTING.md` | ## 目录结构 @@ -86,15 +86,15 @@ website/ 站点上的产品事实与出处(改文案前先核对产品仓库当前版本): -| 站点内容 | 产品仓库出处 | -| --- | --- | +| 站点内容 | 产品仓库出处 | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | | 目录树、日期与时间点、流程、执行步骤四个维度;文件夹状态推导;一个任务多处安排 | `docs/V2_TREE_WORKFLOW_DEVELOPMENT.md`、`docs/ARCHITECTURE.md`(不变量) | -| 顺延只复制未完成的安排、可撤销、离线拒绝;事件到达不改任务 | `docs/API.md`、`docs/ARCHITECTURE.md` | -| 先读本地、待同步队列、按顺序推送、冲突保留双方、WebSocket 只发信号 | `docs/SYNC_PROTOCOL.md` | -| Docker 部署步骤、`.env` 变量、端口 48731、Owner 初始化、升级与回滚 | `README.md`、`docs/DEPLOYMENT.md`、`compose.yaml` | -| PWA 安装与离线打开需要 HTTPS | `docs/DEPLOYMENT.md` | -| 命令面板(⌘K / Ctrl K)、移动端右滑完成与左滑菜单、三态状态圆圈 | `apps/web/src/App.tsx`、`CHANGELOG.md`(rc.24、rc.28) | -| 版本规则(预发布默认、`latest` 只指向正式版) | `README.md`、`docs/RELEASING.md` | +| 顺延只复制未完成的安排、可撤销、离线拒绝;事件到达不改任务 | `docs/API.md`、`docs/ARCHITECTURE.md` | +| 先读本地、待同步队列、按顺序推送、冲突保留双方、WebSocket 只发信号 | `docs/SYNC_PROTOCOL.md` | +| Docker 部署步骤、`.env` 变量、端口 48731、Owner 初始化、升级与回滚 | `README.md`、`docs/DEPLOYMENT.md`、`compose.yaml` | +| PWA 安装与离线打开需要 HTTPS | `docs/DEPLOYMENT.md` | +| 命令面板(⌘K / Ctrl K)、移动端右滑完成与左滑菜单、三态状态圆圈 | `apps/web/src/App.tsx`、`CHANGELOG.md`(rc.24、rc.28) | +| 版本规则(预发布默认、`latest` 只指向正式版) | `README.md`、`docs/RELEASING.md` | ## 维护 diff --git a/website/scripts/build-site.mjs b/website/scripts/build-site.mjs index d9a138d..d6ed21c 100644 --- a/website/scripts/build-site.mjs +++ b/website/scripts/build-site.mjs @@ -31,7 +31,8 @@ const zhDate = `${year}年${Number(month)}月${Number(day)}日`; const channel = latest.prerelease ? '预览版' : '正式版'; const apk = (latest.assets || []).find((asset) => /\.apk$/i.test(asset.name)); const expectedApk = `TaskDock-${version}-android.apk`; -if (!apk || apk.name !== expectedApk) throw new Error(`Expected APK ${expectedApk}, got ${apk?.name || 'none'}`); +if (!apk || apk.name !== expectedApk) + throw new Error(`Expected APK ${expectedApk}, got ${apk?.name || 'none'}`); await rm(outRoot, { recursive: true, force: true }); await cp(siteRoot, outRoot, { recursive: true }); @@ -60,7 +61,12 @@ for (const file of ['index.html', 'download.html', 'assets/js/release.js']) { } if (file === 'assets/js/release.js') { text = replaceAll(text, OLD_DATE_ISO, publishedAt, 'fallback ISO date'); - text = replaceAll(text, 'prerelease: true,', `prerelease: ${latest.prerelease},`, 'fallback prerelease flag'); + text = replaceAll( + text, + 'prerelease: true,', + `prerelease: ${latest.prerelease},`, + 'fallback prerelease flag', + ); } await writeFile(new URL(file, outRoot), text); } diff --git a/website/scripts/check-site.mjs b/website/scripts/check-site.mjs index cc7c742..7551344 100644 --- a/website/scripts/check-site.mjs +++ b/website/scripts/check-site.mjs @@ -6,8 +6,11 @@ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join, dirname, relative, extname, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { readMetaTags } from './meta-tags.mjs'; -const root = resolve(process.argv[2] || resolve(dirname(fileURLToPath(import.meta.url)), '..', 'site')); +const root = resolve( + process.argv[2] || resolve(dirname(fileURLToPath(import.meta.url)), '..', 'site'), +); const errors = []; const fail = (file, msg) => errors.push(`${relative(root, file) || file}: ${msg}`); @@ -36,17 +39,32 @@ for (const page of pages) { const html = readFileSync(page, 'utf8'); if (!/^/i.test(html)) fail(page, '缺少 '); if (!//.test(html)) fail(page, '缺少 '); - if (!//.test(html)) fail(page, '缺少 charset'); - if (!/]*width=device-width/.test(html)) fail(page, '缺少 viewport'); + const metadata = readMetaTags(html); + if (!metadata.some((tag) => tag.get('charset')?.trim().toLowerCase() === 'utf-8')) + fail(page, '缺少 charset'); + if ( + !metadata.some( + (tag) => + tag.get('name')?.toLowerCase() === 'viewport' && + /(?:^|,)\s*width\s*=\s*device-width\s*(?:,|$)/i.test(tag.get('content') ?? ''), + ) + ) + fail(page, '缺少 viewport'); if (!/[^<]+<\/title>/.test(html)) fail(page, '缺少 <title>'); - if (!/<meta name="description" content="[^"]+">/.test(html)) fail(page, '缺少 description'); + if ( + !metadata.some( + (tag) => tag.get('name')?.toLowerCase() === 'description' && tag.get('content')?.trim(), + ) + ) + fail(page, '缺少 description'); if ((html.match(/<h1[\s>]/g) || []).length !== 1) fail(page, '每页应当恰好有一个 <h1>'); // 外部脚本与样式:站点只加载自身资源 for (const m of html.matchAll(/<(script|link)\b[^>]*\s(?:src|href)="(https?:)?\/\/[^"]*"/g)) { if (m[1] === 'script' || /rel="stylesheet"/.test(m[0])) fail(page, `引用了外部资源:${m[0]}`); } - if (/<script(?![^>]*\bsrc=)[^>]*>\s*\S/.test(html)) fail(page, '存在内联脚本(CSP 只允许站内脚本文件)'); + if (/<script(?![^>]*\bsrc=)[^>]*>\s*\S/.test(html)) + fail(page, '存在内联脚本(CSP 只允许站内脚本文件)'); // 站内链接、资源与锚点 for (const m of html.matchAll(/\s(?:href|src)="([^"]+)"/g)) { @@ -59,7 +77,8 @@ for (const page of pages) { let target = page; if (path) { const base = path.startsWith('/') ? join(root, path) : join(dirname(page), path); - target = path.endsWith('/') || path === '.' || path === './' ? join(base, 'index.html') : base; + target = + path.endsWith('/') || path === '.' || path === './' ? join(base, 'index.html') : base; if (!existsSync(target)) { fail(page, `链接目标不存在:${ref}`); continue; @@ -73,7 +92,9 @@ for (const page of pages) { // 脚本语法(ES module) for (const js of files.filter((f) => extname(f) === '.js')) { - const r = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: readFileSync(js) }); + const r = spawnSync(process.execPath, ['--input-type=module', '--check'], { + input: readFileSync(js), + }); if (r.status !== 0) fail(js, `语法错误\n${r.stderr.toString()}`); } @@ -98,4 +119,6 @@ if (errors.length) { console.error(`\n站点检查失败:${errors.length} 项`); process.exit(1); } -console.log(`site check passed: ${pages.length} pages, ${files.length} files, ${(total / 1024).toFixed(0)} KB`); +console.log( + `site check passed: ${pages.length} pages, ${files.length} files, ${(total / 1024).toFixed(0)} KB`, +); diff --git a/website/scripts/meta-tags.mjs b/website/scripts/meta-tags.mjs new file mode 100644 index 0000000..3ba361b --- /dev/null +++ b/website/scripts/meta-tags.mjs @@ -0,0 +1,16 @@ +// Read only meta-tag attributes; this is not a general-purpose HTML parser. +// Quoted values can contain whitespace and >, and formatting may use />. +export function readMetaTags(html) { + const source = html.replace(/<!--[\s\S]*?-->/g, ''); + return [...source.matchAll(/<meta\b(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi)].map(([tag]) => { + const attributes = new Map(); + for (const match of tag.matchAll( + /\s+([a-z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/gi, + )) { + const name = match[1].toLowerCase(); + // HTML keeps the first occurrence of a duplicate attribute. + if (!attributes.has(name)) attributes.set(name, match[2] ?? match[3] ?? match[4] ?? ''); + } + return attributes; + }); +} diff --git a/website/scripts/meta-tags.test.mjs b/website/scripts/meta-tags.test.mjs new file mode 100644 index 0000000..a3b18a5 --- /dev/null +++ b/website/scripts/meta-tags.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { readMetaTags } from './meta-tags.mjs'; + +const checker = fileURLToPath(new URL('./check-site.mjs', import.meta.url)); +const plain = + '<meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="description" content="TaskDock task workspace">'; +const formatted = `<meta charset="utf-8" /> +<meta content="width=device-width, initial-scale=1" name="viewport" /> +<meta + content="TaskDock task workspace" + name="description" +/>`; + +function check(metadata) { + const root = mkdtempSync(join(tmpdir(), 'taskdock-meta-check-')); + try { + writeFileSync( + join(root, 'index.html'), + `<!doctype html><html lang="zh-CN"><head><title>TaskDock${metadata}

TaskDock

`, + ); + return spawnSync(process.execPath, [checker, root], { encoding: 'utf8' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('metadata checks accept both compact and Prettier-formatted HTML', () => { + for (const metadata of [plain, formatted]) { + const result = check(metadata); + assert.equal(result.status, 0, result.stderr); + } +}); + +test('meta attributes support legal quote styles, case, order and quoted >', () => { + const tags = readMetaTags( + ``, + ); + assert.equal(tags[0].get('charset'), 'UTF-8'); + assert.equal(tags[1].get('name'), 'description'); + assert.equal(tags[1].get('content'), "Task's > list"); + assert.equal(readMetaTags('')[0].get('charset'), 'utf-8'); +}); + +test('commented metadata is not accepted as an actual page declaration', () => { + const result = check(``); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /缺少 charset/); + assert.match(result.stderr, /缺少 description/); +}); + +for (const [label, metadata] of [ + ['missing charset', plain.replace('', '')], + ['empty charset', plain.replace('charset="utf-8"', 'charset=""')], + ['wrong charset', plain.replace('charset="utf-8"', 'charset="iso-8859-1"')], + [ + 'missing description', + plain.replace('', ''), + ], + ['empty description', plain.replace('content="TaskDock task workspace"', 'content=""')], + [ + 'whitespace-only description', + plain.replace('content="TaskDock task workspace"', 'content=" \n "'), + ], +]) { + test(`metadata checker still rejects ${label}`, () => { + const result = check(metadata); + assert.notEqual(result.status, 0); + assert.match(result.stderr, label.includes('charset') ? /缺少 charset/ : /缺少 description/); + }); +} diff --git a/website/site/404.html b/website/site/404.html index 56e51b5..f102b78 100644 --- a/website/site/404.html +++ b/website/site/404.html @@ -1,40 +1,43 @@ - - - - 页面未找到 — TaskDock - - - - - - - - - - - -
-
-
- +
+ diff --git a/website/site/assets/css/site.css b/website/site/assets/css/site.css index 47a51e1..0988825 100644 --- a/website/site/assets/css/site.css +++ b/website/site/assets/css/site.css @@ -26,26 +26,121 @@ --slate: #93a6cc; --rose: #ef9a8f; - --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable Text", "Segoe UI", - "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans SC", - "Noto Sans CJK SC", "Source Han Sans SC", system-ui, sans-serif; - --font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI Variable Display", - "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Noto Sans SC", - "Noto Sans CJK SC", system-ui, sans-serif; - --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, - "Liberation Mono", monospace; + --font-sans: + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI Variable Text', 'Segoe UI', + 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Noto Sans SC', + 'Noto Sans CJK SC', 'Source Han Sans SC', system-ui, sans-serif; + --font-display: + -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI Variable Display', 'Segoe UI', + 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Noto Sans SC', 'Noto Sans CJK SC', + system-ui, sans-serif; + --font-mono: + ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, 'Liberation Mono', + monospace; /* 由阻尼弹簧采样得到的 linear() 曲线(k/c:420/30、170/17、260/15) */ - --spring-snappy: linear(0, 0.0294, 0.0861, 0.1615, 0.2479, 0.3393, 0.4308, 0.5192, 0.6019, 0.6892, - 0.7552, 0.8128, 0.8621, 0.9034, 0.9373, 0.9646, 0.9859, 1.0021, 1.014, 1.0222, 1.0274, 1.0302, - 1.0311, 1.0307, 1.0292, 1.0266, 1.024, 1.0212, 1.0183, 1.0155, 1.0129, 1.0105, 1.0084, 1.0065, - 1.0048, 1.0035, 1.0023, 1.0014, 1.0007, 1.0001, 0.9997, 0.9994, 0.9992, 0.9991, 0.999, 0.999, - 0.9991, 0.9991, 1); - --spring-soft: linear(0, 0.0331, 0.0969, 0.1916, 0.2904, 0.4043, 0.5168, 0.6135, 0.7104, 0.7881, - 0.8613, 0.9216, 0.9655, 1.0028, 1.0278, 1.0468, 1.0584, 1.0636, 1.0648, 1.0629, 1.0583, 1.052, - 1.0456, 1.0382, 1.0309, 1.0247, 1.0184, 1.0134, 1.0087, 1.0049, 1.0021, 0.9997, 0.9981, 0.9969, - 0.9962, 0.9959, 0.9958, 0.9959, 0.9962, 0.9967, 0.9971, 0.9976, 0.998, 0.9984, 0.9988, 0.9991, - 0.9995, 0.9997, 1); + --spring-snappy: linear( + 0, + 0.0294, + 0.0861, + 0.1615, + 0.2479, + 0.3393, + 0.4308, + 0.5192, + 0.6019, + 0.6892, + 0.7552, + 0.8128, + 0.8621, + 0.9034, + 0.9373, + 0.9646, + 0.9859, + 1.0021, + 1.014, + 1.0222, + 1.0274, + 1.0302, + 1.0311, + 1.0307, + 1.0292, + 1.0266, + 1.024, + 1.0212, + 1.0183, + 1.0155, + 1.0129, + 1.0105, + 1.0084, + 1.0065, + 1.0048, + 1.0035, + 1.0023, + 1.0014, + 1.0007, + 1.0001, + 0.9997, + 0.9994, + 0.9992, + 0.9991, + 0.999, + 0.999, + 0.9991, + 0.9991, + 1 + ); + --spring-soft: linear( + 0, + 0.0331, + 0.0969, + 0.1916, + 0.2904, + 0.4043, + 0.5168, + 0.6135, + 0.7104, + 0.7881, + 0.8613, + 0.9216, + 0.9655, + 1.0028, + 1.0278, + 1.0468, + 1.0584, + 1.0636, + 1.0648, + 1.0629, + 1.0583, + 1.052, + 1.0456, + 1.0382, + 1.0309, + 1.0247, + 1.0184, + 1.0134, + 1.0087, + 1.0049, + 1.0021, + 0.9997, + 0.9981, + 0.9969, + 0.9962, + 0.9959, + 0.9958, + 0.9959, + 0.9962, + 0.9967, + 0.9971, + 0.9976, + 0.998, + 0.9984, + 0.9988, + 0.9991, + 0.9995, + 0.9997, + 1 + ); --spring-snappy-ms: 485ms; --spring-soft-ms: 850ms; --ease-out: cubic-bezier(0.22, 1, 0.36, 1); @@ -93,7 +188,7 @@ body { /* 极淡的蓝图网格:秩序的底纹 */ body::before { - content: ""; + content: ''; position: fixed; inset: 0; z-index: -1; @@ -188,7 +283,9 @@ pre { z-index: 50; height: var(--header-h); view-transition-name: site-header; - transition: background-color 0.4s var(--ease-out), border-color 0.4s var(--ease-out), + transition: + background-color 0.4s var(--ease-out), + border-color 0.4s var(--ease-out), backdrop-filter 0.4s var(--ease-out); border-bottom: 1px solid transparent; } @@ -226,7 +323,9 @@ pre { width: 30px; height: 30px; border-radius: 8px; - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 18px rgba(0, 0, 0, 0.35); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.06), + 0 6px 18px rgba(0, 0, 0, 0.35); } .site-nav { @@ -242,7 +341,9 @@ pre { border-radius: 999px; font-size: 14px; color: var(--text-2); - transition: color 0.2s, background-color 0.2s; + transition: + color 0.2s, + background-color 0.2s; } .site-nav a:hover { @@ -250,12 +351,12 @@ pre { background: rgba(255, 255, 255, 0.05); } -.site-nav a[aria-current="page"] { +.site-nav a[aria-current='page'] { color: var(--text); } -.site-nav a[aria-current="page"]::after { - content: ""; +.site-nav a[aria-current='page']::after { + content: ''; position: absolute; left: 50%; bottom: 2px; @@ -279,7 +380,9 @@ pre { height: 38px; border-radius: 999px; color: var(--text-2); - transition: color 0.2s, background-color 0.2s; + transition: + color 0.2s, + background-color 0.2s; } .icon-link:hover { @@ -309,7 +412,9 @@ pre { height: 1.5px; background: var(--text); border-radius: 2px; - transition: transform var(--spring-snappy-ms) var(--spring-snappy), opacity 0.2s; + transition: + transform var(--spring-snappy-ms) var(--spring-snappy), + opacity 0.2s; } .menu-toggle span:nth-child(1) { @@ -355,8 +460,11 @@ pre { font-weight: 600; font-size: 15px; white-space: nowrap; - transition: transform var(--spring-snappy-ms) var(--spring-snappy), background-color 0.25s, - border-color 0.25s, box-shadow 0.3s; + transition: + transform var(--spring-snappy-ms) var(--spring-snappy), + background-color 0.25s, + border-color 0.25s, + box-shadow 0.3s; } .btn:hover { @@ -389,12 +497,16 @@ pre { --btn-bg: var(--sage); --btn-fg: var(--sage-ink); border-color: transparent; - box-shadow: 0 10px 30px -10px rgba(143, 205, 170, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.35); + box-shadow: + 0 10px 30px -10px rgba(143, 205, 170, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.35); } .btn-primary:hover { border-color: transparent; - box-shadow: 0 16px 40px -12px rgba(143, 205, 170, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.4); + box-shadow: + 0 16px 40px -12px rgba(143, 205, 170, 0.7), + inset 0 1px 0 rgba(255, 255, 255, 0.4); } .btn-small { @@ -535,11 +647,13 @@ p { height: 18px; border-radius: 50%; border: 1.6px solid var(--c); - transition: border-color 0.3s, background-color 0.3s; + transition: + border-color 0.3s, + background-color 0.3s; } .status::before { - content: ""; + content: ''; position: absolute; inset: 2px; border-radius: 50%; @@ -549,7 +663,7 @@ p { } .status::after { - content: ""; + content: ''; position: absolute; left: 4.6px; top: 2.2px; @@ -561,20 +675,20 @@ p { transition: transform var(--spring-snappy-ms) var(--spring-snappy); } -.status[data-s="doing"] { +.status[data-s='doing'] { --c: var(--amber); } -.status[data-s="doing"]::before { +.status[data-s='doing']::before { transform: scale(1) rotate(0deg); } -.status[data-s="done"] { +.status[data-s='done'] { --c: var(--sage); background: var(--sage); } -.status[data-s="done"]::after { +.status[data-s='done']::after { transform: rotate(45deg) scale(1); } @@ -583,7 +697,7 @@ button.status { background-color: transparent; } -button.status[data-s="done"] { +button.status[data-s='done'] { background-color: var(--sage); } @@ -598,7 +712,9 @@ button.status[data-s="done"] { border-radius: 14px; background: linear-gradient(180deg, rgba(36, 52, 92, 0.96), rgba(24, 36, 66, 0.96)); border: 1px solid rgba(168, 186, 228, 0.14); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 1px 0 rgba(0, 0, 0, 0.3), + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 1px 0 rgba(0, 0, 0, 0.3), 0 14px 30px -14px rgba(0, 0, 0, 0.7); color: var(--text); } @@ -626,7 +742,7 @@ button.status[data-s="done"] { margin-top: 3px; } -.tcard[data-s="done"] .tcard-title { +.tcard[data-s='done'] .tcard-title { color: var(--text-3); text-decoration: line-through; text-decoration-color: rgba(143, 205, 170, 0.5); @@ -719,7 +835,7 @@ button.status[data-s="done"] { } .release-badge::before { - content: ""; + content: ''; width: 6px; height: 6px; border-radius: 50%; @@ -749,7 +865,9 @@ button.status[data-s="done"] { .hero-field .tcard.is-dragging { cursor: grabbing; z-index: 20; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.09), 0 30px 60px -18px rgba(0, 0, 0, 0.85), + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.09), + 0 30px 60px -18px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(143, 205, 170, 0.35); } @@ -787,11 +905,15 @@ button.status[data-s="done"] { @keyframes dock-pulse { 0% { - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 0 0 0 rgba(143, 205, 170, 0.5), + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 0 0 0 rgba(143, 205, 170, 0.5), 0 14px 30px -14px rgba(0, 0, 0, 0.7); } 100% { - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 0 0 12px rgba(143, 205, 170, 0), + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 0 0 12px rgba(143, 205, 170, 0), 0 14px 30px -14px rgba(0, 0, 0, 0.7); } } @@ -802,7 +924,9 @@ button.status[data-s="done"] { border-radius: 22px; background: linear-gradient(180deg, rgba(17, 26, 50, 0.9), rgba(11, 18, 37, 0.9)); border: 1px solid var(--line); - box-shadow: 0 40px 100px -40px rgba(0, 0, 0, 0.9), inset 0 1px 0 rgba(255, 255, 255, 0.04); + box-shadow: + 0 40px 100px -40px rgba(0, 0, 0, 0.9), + inset 0 1px 0 rgba(255, 255, 255, 0.04); opacity: 0; pointer-events: none; } @@ -950,14 +1074,16 @@ button.status[data-s="done"] { border-radius: 16px; background: transparent; text-align: left; - transition: background-color 0.3s, border-color 0.3s; + transition: + background-color 0.3s, + border-color 0.3s; } .model-tab:hover { background: rgba(255, 255, 255, 0.03); } -.model-tab[aria-selected="true"] { +.model-tab[aria-selected='true'] { background: rgba(255, 255, 255, 0.045); border-color: var(--line); } @@ -971,7 +1097,7 @@ button.status[data-s="done"] { transition: color 0.3s; } -.model-tab[aria-selected="true"] .k { +.model-tab[aria-selected='true'] .k { color: var(--sage); } @@ -988,14 +1114,16 @@ button.status[data-s="done"] { color: var(--text-3); display: grid; grid-template-rows: 0fr; - transition: grid-template-rows var(--spring-soft-ms) var(--spring-soft), color 0.3s; + transition: + grid-template-rows var(--spring-soft-ms) var(--spring-soft), + color 0.3s; } .model-tab .d > span { overflow: hidden; } -.model-tab[aria-selected="true"] .d { +.model-tab[aria-selected='true'] .d { grid-template-rows: 1fr; color: var(--text-2); } @@ -1052,7 +1180,7 @@ button.status[data-s="done"] { } .lens-bar .live::before { - content: ""; + content: ''; width: 6px; height: 6px; border-radius: 50%; @@ -1463,7 +1591,9 @@ button.status[data-s="done"] { user-select: none; -webkit-user-select: none; cursor: grab; - transition: transform var(--spring-snappy-ms) var(--spring-snappy), border-color 0.25s, + transition: + transform var(--spring-snappy-ms) var(--spring-snappy), + border-color 0.25s, box-shadow 0.25s; } @@ -1471,7 +1601,7 @@ button.status[data-s="done"] { transform: translateY(-2px); } -.pill-task[aria-pressed="true"] { +.pill-task[aria-pressed='true'] { border-color: var(--sage); box-shadow: 0 0 0 4px rgba(143, 205, 170, 0.14); } @@ -1496,7 +1626,9 @@ button.status[data-s="done"] { z-index: 80; pointer-events: none; cursor: grabbing; - box-shadow: 0 24px 50px -16px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(143, 205, 170, 0.4); + box-shadow: + 0 24px 50px -16px rgba(0, 0, 0, 0.9), + 0 0 0 1px rgba(143, 205, 170, 0.4); } .timeline { @@ -1577,7 +1709,7 @@ button.status[data-s="done"] { } .tl-col.is-event.has-arrived .tl-head b::after { - content: " · 已到达"; + content: ' · 已到达'; font-size: 12px; font-weight: 500; color: var(--text-3); @@ -1613,7 +1745,9 @@ button.status[data-s="done"] { border-radius: 9px; font-size: 11.5px; color: var(--text-3); - transition: border-color 0.25s, color 0.25s; + transition: + border-color 0.25s, + color 0.25s; } .tl-actions { @@ -1740,7 +1874,9 @@ button.status[data-s="done"] { stroke: rgba(143, 205, 170, 0.35); stroke-width: 1.5; stroke-dasharray: 3 5; - transition: stroke 0.4s, opacity 0.4s; + transition: + stroke 0.4s, + opacity 0.4s; } .sync-wires path.is-down { @@ -1755,7 +1891,9 @@ button.status[data-s="done"] { border: 1px solid var(--line); background: var(--ink-2); padding: 12px; - transition: border-color 0.4s, box-shadow 0.4s; + transition: + border-color 0.4s, + box-shadow 0.4s; } .client.is-offline { @@ -1788,7 +1926,7 @@ button.status[data-s="done"] { } .net::before { - content: ""; + content: ''; width: 6px; height: 6px; border-radius: 50%; @@ -1808,7 +1946,9 @@ button.status[data-s="done"] { margin: 0; height: 36px; font-size: 12.5px; - transition: border-color 0.4s, background-color 0.4s; + transition: + border-color 0.4s, + background-color 0.4s; } .client-list .mini.is-pending { @@ -1913,7 +2053,8 @@ button.status[data-s="done"] { padding: 16px 8px; border-radius: 18px; border: 1px solid rgba(143, 205, 170, 0.28); - background: radial-gradient(circle at 50% 30%, rgba(143, 205, 170, 0.12), transparent 70%), var(--ink-2); + background: + radial-gradient(circle at 50% 30%, rgba(143, 205, 170, 0.12), transparent 70%), var(--ink-2); text-align: center; } @@ -1983,7 +2124,7 @@ button.status[data-s="done"] { } .switch .track::after { - content: ""; + content: ''; position: absolute; top: 3px; left: 3px; @@ -1995,11 +2136,11 @@ button.status[data-s="done"] { transition: transform var(--spring-snappy-ms) var(--spring-snappy); } -.switch[aria-checked="false"] .track { +.switch[aria-checked='false'] .track { background: rgba(239, 154, 143, 0.55); } -.switch[aria-checked="false"] .track::after { +.switch[aria-checked='false'] .track::after { transform: translateX(0); } @@ -2086,10 +2227,12 @@ button.status[data-s="done"] { background: transparent; font-size: 12.5px; color: var(--text-3); - transition: background-color 0.2s, color 0.2s; + transition: + background-color 0.2s, + color 0.2s; } -.term-tab[aria-selected="true"], +.term-tab[aria-selected='true'], .term-tab.is-label { background: rgba(255, 255, 255, 0.07); color: var(--text); @@ -2107,7 +2250,9 @@ button.status[data-s="done"] { background: transparent; font-size: 12px; color: var(--text-2); - transition: color 0.2s, border-color 0.2s; + transition: + color 0.2s, + border-color 0.2s; } .copy-btn:hover { @@ -2171,7 +2316,7 @@ button.status[data-s="done"] { } .stack-layer + .stack-layer::before { - content: ""; + content: ''; position: absolute; left: 38px; top: -11px; @@ -2248,7 +2393,7 @@ button.status[data-s="done"] { } .host-notes li::before { - content: "—"; + content: '—'; color: var(--text-3); } @@ -2408,7 +2553,7 @@ button.status[data-s="done"] { } .demo-swipe::before { - content: "完成"; + content: '完成'; position: absolute; left: 14px; top: 50%; @@ -2579,7 +2724,7 @@ button.status[data-s="done"] { } .platforms::before { - content: ""; + content: ''; position: absolute; left: 49px; right: 49px; @@ -2594,10 +2739,14 @@ button.status[data-s="done"] { @keyframes sync-flow { from { - background-position: -30% 0, 0 0; + background-position: + -30% 0, + 0 0; } to { - background-position: 130% 0, 0 0; + background-position: + 130% 0, + 0 0; } } @@ -2617,7 +2766,9 @@ button.status[data-s="done"] { border-radius: 22px; border: 1px solid var(--line); background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)); - transition: transform var(--spring-soft-ms) var(--spring-soft), border-color 0.3s; + transition: + transform var(--spring-soft-ms) var(--spring-soft), + border-color 0.3s; } .platform:hover { @@ -2701,7 +2852,9 @@ button.status[data-s="done"] { left: calc(var(--x) * 1%); transform: translateX(-50%) translateY(var(--y, -60px)) rotate(var(--r, 30deg)); opacity: var(--o, 0); - transition: transform 1.1s var(--spring-soft), opacity 0.6s; + transition: + transform 1.1s var(--spring-soft), + opacity 0.6s; transition-delay: calc(var(--i) * 70ms); } @@ -2767,7 +2920,9 @@ button.status[data-s="done"] { .reveal { opacity: 0; transform: translateY(26px); - transition: opacity 0.7s var(--ease-out), transform var(--spring-soft-ms) var(--spring-soft); + transition: + opacity 0.7s var(--ease-out), + transform var(--spring-soft-ms) var(--spring-soft); transition-delay: calc(var(--d, 0) * 80ms); } @@ -2946,7 +3101,10 @@ button.status[data-s="done"] { font-family: var(--font-mono); font-size: 13px; color: var(--text-3); - transition: border-color 0.4s, color 0.4s, background-color 0.4s; + transition: + border-color 0.4s, + color 0.4s, + background-color 0.4s; } .rail-step.is-reached .num { @@ -3082,7 +3240,9 @@ button.status[data-s="done"] { border-radius: 16px; border: 1px solid var(--line); background: rgba(255, 255, 255, 0.02); - transition: border-color 0.25s, transform var(--spring-snappy-ms) var(--spring-snappy); + transition: + border-color 0.25s, + transform var(--spring-snappy-ms) var(--spring-snappy); } .doc-link:hover { @@ -3136,7 +3296,6 @@ button.status[data-s="done"] { color: var(--text-2); } - /* ========== 响应式 ========== */ @media (max-width: 1080px) { @@ -3179,14 +3338,19 @@ button.status[data-s="done"] { transform: translateY(-8px); opacity: 0; visibility: hidden; - transition: transform var(--spring-snappy-ms) var(--spring-snappy), opacity 0.2s, visibility 0s 0.2s; + transition: + transform var(--spring-snappy-ms) var(--spring-snappy), + opacity 0.2s, + visibility 0s 0.2s; } .site-header.is-open .site-nav { transform: none; opacity: 1; visibility: visible; - transition: transform var(--spring-snappy-ms) var(--spring-snappy), opacity 0.2s; + transition: + transform var(--spring-snappy-ms) var(--spring-snappy), + opacity 0.2s; } .site-nav a { @@ -3200,7 +3364,7 @@ button.status[data-s="done"] { background: transparent; } - .site-nav a[aria-current="page"]::after { + .site-nav a[aria-current='page']::after { left: auto; right: 6px; top: 50%; @@ -3444,7 +3608,7 @@ button.status[data-s="done"] { grid-row: 1 / span 2; } - [data-client="android"] .outbox { + [data-client='android'] .outbox { display: none; } diff --git a/website/site/assets/js/dimensions.js b/website/site/assets/js/dimensions.js index d78567d..70c2931 100644 --- a/website/site/assets/js/dimensions.js +++ b/website/site/assets/js/dimensions.js @@ -8,9 +8,15 @@ const DESC = { tree: '文件夹可以无限嵌套,同一级里文件夹和任务并存。文件夹没有手动状态,由全部后代任务实时推导。', time: '同一个任务可以同时出现在多个日期,也可以挂在「额度重置后」「上线前」这样的自定义事件上。', flow: '把任务放进分阶段的流程看板。一个任务可以加入多个流程,在同一个流程里只出现一次。', - steps: '在任务内部列出线性步骤,并附上 Markdown 笔记。步骤有自己的三态状态,不会自动改动任务状态。', + steps: + '在任务内部列出线性步骤,并附上 Markdown 笔记。步骤有自己的三态状态,不会自动改动任务状态。', +}; +const WHERE = { + tree: '/tree/发布-2.0', + time: '/time/calendar', + flow: '/workflows/发布-2.0', + steps: 'TASK-214 · 详情', }; -const WHERE = { tree: '/tree/发布-2.0', time: '/time/calendar', flow: '/workflows/发布-2.0', steps: 'TASK-214 · 详情' }; const focus = { title: '迁移脚本加回滚演练', @@ -110,7 +116,8 @@ function views() {
    ${focus.steps .map( - ([label, s], i) => `
  1. ${label}${LABEL[s]}
  2. `, + ([label, s], i) => + `
  3. ${label}${LABEL[s]}
  4. `, ) .join('')}
@@ -129,7 +136,8 @@ export function initDimensions(lens, tabsEl) { const tabs = [...tabsEl.querySelectorAll('[role="tab"]')]; let current = 'tree'; - const caption = () => `
点任务前的圆圈:待开始 → 进行中 → 已完成${focus.ref} · ${LABEL[focus.s]}
`; + const caption = () => + `
点任务前的圆圈:待开始 → 进行中 → 已完成${focus.ref} · ${LABEL[focus.s]}
`; function sync() { stage.querySelectorAll('[data-focus-status]').forEach((el) => { @@ -159,14 +167,26 @@ export function initDimensions(lens, tabsEl) { const andTasks = ['doing', 'todo']; const tdTasks = [...relTasks, ...andTasks]; const devTasks = [...tdTasks, 'todo']; - const aggs = { rel: derive(relTasks), and: derive(andTasks), td: derive(tdTasks), dev: derive(devTasks) }; + const aggs = { + rel: derive(relTasks), + and: derive(andTasks), + td: derive(tdTasks), + dev: derive(devTasks), + }; stage.querySelectorAll('[data-agg]').forEach((el) => { const s = aggs[el.dataset.agg]; const changed = el.dataset.s && el.dataset.s !== s; el.dataset.s = s; el.innerHTML = `${LABEL[s]}`; el.style.color = s === 'done' ? 'var(--sage)' : s === 'doing' ? 'var(--amber)' : ''; - if (changed) el.animate([{ opacity: 0.2, transform: 'translateY(-4px)' }, { opacity: 1, transform: 'none' }], { duration: 420, easing: 'cubic-bezier(.22,1,.36,1)' }); + if (changed) + el.animate( + [ + { opacity: 0.2, transform: 'translateY(-4px)' }, + { opacity: 1, transform: 'none' }, + ], + { duration: 420, easing: 'cubic-bezier(.22,1,.36,1)' }, + ); }); } diff --git a/website/site/assets/js/hero-field.js b/website/site/assets/js/hero-field.js index 1eededc..ee8e1fa 100644 --- a/website/site/assets/js/hero-field.js +++ b/website/site/assets/js/hero-field.js @@ -2,18 +2,65 @@ // 卡片可以拖动:散落时可以随手甩开,归位后可以在组内重新排序; // 点状态圆圈按「待开始 → 进行中 → 已完成」切换,卡片会移动到对应分组。 -import { Spring2D, clamp, createLoop, easeInOut, easeOut, integrate, lerp, prefersReducedMotion } from './spring.js'; +import { + Spring2D, + clamp, + createLoop, + easeInOut, + easeOut, + integrate, + lerp, + prefersReducedMotion, +} from './spring.js'; const TASKS = [ { id: 'a', title: '迁移脚本加回滚演练', ref: 'TASK-214', s: 'doing', chip: ['flow', '验证'] }, - { id: 'b', title: '修复 Android 冷启动读取凭据', ref: 'TASK-208', s: 'doing', chip: ['date', '明天'] }, - { id: 'c', title: '整理同步冲突的复现步骤', ref: 'TASK-219', s: 'doing', chip: ['steps', '步骤 2/4'], mobile: false }, - { id: 'd', title: '额度重置后重跑 e2e 门禁', ref: 'TASK-221', s: 'todo', chip: ['event', '额度重置后'] }, - { id: 'e', title: '上线前检查 HTTPS 与 CORS', ref: 'TASK-223', s: 'todo', chip: ['event', '上线前'] }, - { id: 'f', title: '给备份脚本补一次恢复演练', ref: 'TASK-197', s: 'todo', chip: ['date', '周六'] }, + { + id: 'b', + title: '修复 Android 冷启动读取凭据', + ref: 'TASK-208', + s: 'doing', + chip: ['date', '明天'], + }, + { + id: 'c', + title: '整理同步冲突的复现步骤', + ref: 'TASK-219', + s: 'doing', + chip: ['steps', '步骤 2/4'], + mobile: false, + }, + { + id: 'd', + title: '额度重置后重跑 e2e 门禁', + ref: 'TASK-221', + s: 'todo', + chip: ['event', '额度重置后'], + }, + { + id: 'e', + title: '上线前检查 HTTPS 与 CORS', + ref: 'TASK-223', + s: 'todo', + chip: ['event', '上线前'], + }, + { + id: 'f', + title: '给备份脚本补一次恢复演练', + ref: 'TASK-197', + s: 'todo', + chip: ['date', '周六'], + }, { id: 'g', title: '升级到 PostgreSQL 18', ref: 'TASK-188', s: 'done', chip: ['note', '笔记'] }, { id: 'h', title: '统一移动端底部导航', ref: 'TASK-176', s: 'done', chip: ['flow', '发布'] }, - { id: 'i', title: '清理过期的同步游标', ref: 'TASK-181', s: 'done', chip: ['date', '周一'], mobile: false }, + { + id: 'i', + title: '清理过期的同步游标', + ref: 'TASK-181', + s: 'done', + chip: ['date', '周一'], + mobile: false, + }, ]; const GROUPS = ['doing', 'todo', 'done']; @@ -21,13 +68,24 @@ const NEXT = { todo: 'doing', doing: 'done', done: 'todo' }; // 散落构图(中心点,按舞台归一化):桌面避开左侧文案,移动端集中在下方与边缘 const SCATTER_DESKTOP = [ - [0.67, 0.2, -11, 0.86], [0.9, 0.12, 8, 0.8], [0.79, 0.37, -4, 0.9], [0.99, 0.5, 13, 0.78], - [0.69, 0.6, 7, 0.85], [0.87, 0.73, -9, 0.9], [0.63, 0.9, 4, 0.82], [0.96, 0.92, -6, 0.8], + [0.67, 0.2, -11, 0.86], + [0.9, 0.12, 8, 0.8], + [0.79, 0.37, -4, 0.9], + [0.99, 0.5, 13, 0.78], + [0.69, 0.6, 7, 0.85], + [0.87, 0.73, -9, 0.9], + [0.63, 0.9, 4, 0.82], + [0.96, 0.92, -6, 0.8], [0.24, 0.95, -7, 0.78], ]; const SCATTER_MOBILE = [ - [0.22, 0.7, -9, 0.9], [0.8, 0.66, 8, 0.86], [0.5, 0.79, 3, 0.95], [0.12, 0.9, 10, 0.86], - [0.86, 0.86, -7, 0.9], [0.45, 0.97, -4, 0.92], [1.0, 0.98, 12, 0.82], + [0.22, 0.7, -9, 0.9], + [0.8, 0.66, 8, 0.86], + [0.5, 0.79, 3, 0.95], + [0.12, 0.9, 10, 0.86], + [0.86, 0.86, -7, 0.9], + [0.45, 0.97, -4, 0.92], + [1.0, 0.98, 12, 0.82], ]; const ICONS = { @@ -37,7 +95,13 @@ const ICONS = { steps: '', note: '', }; -const CHIP_CLASS = { flow: 'chip-flow', date: 'chip-date', event: 'chip-event', steps: '', note: '' }; +const CHIP_CLASS = { + flow: 'chip-flow', + date: 'chip-date', + event: 'chip-event', + steps: '', + note: '', +}; function cardHTML(t) { const [kind, text] = t.chip; @@ -89,7 +153,12 @@ export function initHero(track) { scatterLine.setAttribute('aria-label', text); scatterLine.textContent = ''; const jitter = [ - [0, 0, 0], [-4, -10, -12], [6, 8, 10], [-3, -6, -8], [0, 0, 0], [0, 0, 0], + [0, 0, 0], + [-4, -10, -12], + [6, 8, 10], + [-3, -6, -8], + [0, 0, 0], + [0, 0, 0], ]; [...text].forEach((ch, i) => { const span = document.createElement('span'); @@ -105,8 +174,14 @@ export function initHero(track) { field.querySelectorAll('.tcard').forEach((el) => el.remove()); const pick = TASKS.filter((t) => !mobile || t.mobile !== false); const scatter = mobile ? SCATTER_MOBILE : SCATTER_DESKTOP; - tasks = pick.map((t, i) => ({ ...t, anchor: scatter[i % scatter.length].slice(), custom: null })); - order = Object.fromEntries(GROUPS.map((g) => [g, tasks.filter((t) => t.s === g).map((t) => t.id)])); + tasks = pick.map((t, i) => ({ + ...t, + anchor: scatter[i % scatter.length].slice(), + custom: null, + })); + order = Object.fromEntries( + GROUPS.map((g) => [g, tasks.filter((t) => t.s === g).map((t) => t.id)]), + ); // 归位顺序打散一点,看起来像被一张张吸过去,而不是排队 const settleOrder = [2, 0, 4, 1, 6, 3, 8, 5, 7]; cards = tasks.map((t, i) => { @@ -160,7 +235,16 @@ export function initHero(track) { const s = clamp(avail / tall, 0.62, 1); const ph = tall * s; const py = mobile ? top : Math.max(top, top + (avail - ph) / 2); - geo = { W, H, px, pw, py, s, m, fs: scatterLine ? parseFloat(getComputedStyle(scatterLine).fontSize) / 90 : 1 }; + geo = { + W, + H, + px, + pw, + py, + s, + m, + fs: scatterLine ? parseFloat(getComputedStyle(scatterLine).fontSize) / 90 : 1, + }; const cw = (pw - 28) / s; field.style.setProperty('--card-w', `${cw}px`); geo.cw = cw; @@ -264,7 +348,10 @@ export function initHero(track) { if (reduced) { cards.forEach((c) => c.sp.snap()); } else { - integrate(cards.map((c) => c.sp), dt); + integrate( + cards.map((c) => c.sp), + dt, + ); } const { cw, ch } = geo; @@ -314,7 +401,8 @@ export function initHero(track) { const fs = geo.fs; for (const gl of glyphs) { const [dx, dy, dr] = gl.j.map((v, i) => (i < 2 ? v * fs : v)); - gl.el.style.transform = ge > 0.001 ? `translate(${dx * ge}px, ${dy * ge}px) rotate(${dr * ge}deg)` : ''; + gl.el.style.transform = + ge > 0.001 ? `translate(${dx * ge}px, ${dy * ge}px) rotate(${dr * ge}deg)` : ''; } // 移动端:文案让出舞台给面板 @@ -451,7 +539,9 @@ export function initHero(track) { c.el.dataset.s = orig.s; c.el.querySelector('.status').dataset.s = orig.s; } - order = Object.fromEntries(GROUPS.map((g) => [g, fresh.filter((t) => t.s === g).map((t) => t.id)])); + order = Object.fromEntries( + GROUPS.map((g) => [g, fresh.filter((t) => t.s === g).map((t) => t.id)]), + ); interacted = false; loop.kick(); }); diff --git a/website/site/assets/js/release.js b/website/site/assets/js/release.js index 12ac109..250be07 100644 --- a/website/site/assets/js/release.js +++ b/website/site/assets/js/release.js @@ -56,7 +56,9 @@ async function fetchLatest() { const latest = Array.isArray(list) ? list.find((r) => !r.draft) : null; if (!latest || typeof latest.tag_name !== 'string') return null; if (!SAFE_SEGMENT.test(latest.tag_name)) return null; - const apk = (latest.assets || []).find((a) => /\.apk$/i.test(a.name) && SAFE_SEGMENT.test(a.name)); + const apk = (latest.assets || []).find( + (a) => /\.apk$/i.test(a.name) && SAFE_SEGMENT.test(a.name), + ); const apkName = apk ? apk.name : null; const release = { tag: latest.tag_name, @@ -82,7 +84,11 @@ const versionOf = (tag) => tag.replace(/^v/, ''); function formatDate(iso) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; - return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d); + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(d); } function apply(release) { diff --git a/website/site/assets/js/sync-demo.js b/website/site/assets/js/sync-demo.js index 19b8a84..a17b0bd 100644 --- a/website/site/assets/js/sync-demo.js +++ b/website/site/assets/js/sync-demo.js @@ -30,8 +30,16 @@ export function initSync(root) { { id: 'b', ref: 'TASK-219', title: '整理同步冲突的复现步骤', s: 'todo' }, { id: 'c', ref: 'TASK-188', title: '升级到 PostgreSQL 18', s: 'done' }, ]; - const hub = { cursor: 1042, tasks: Object.fromEntries(seed.map((t) => [t.id, { s: t.s, v: 3 }])) }; - const web = { online: true, tasks: Object.fromEntries(seed.map((t) => [t.id, { s: t.s, v: 3 }])), outbox: [], conflict: null }; + const hub = { + cursor: 1042, + tasks: Object.fromEntries(seed.map((t) => [t.id, { s: t.s, v: 3 }])), + }; + const web = { + online: true, + tasks: Object.fromEntries(seed.map((t) => [t.id, { s: t.s, v: 3 }])), + outbox: [], + conflict: null, + }; const android = { tasks: Object.fromEntries(seed.map((t) => [t.id, { s: t.s, v: 3 }])) }; const meta = Object.fromEntries(seed.map((t) => [t.id, t])); let busy = false; @@ -50,7 +58,11 @@ export function initSync(root) { const status = interactive ? `` : ``; - const tag = conflict ? '冲突' : pending ? '待同步' : ''; + const tag = conflict + ? '冲突' + : pending + ? '待同步' + : ''; return `
${status}${t.title}${tag}
`; }) .join(''); @@ -58,7 +70,9 @@ export function initSync(root) { function renderOutbox() { outboxEl.innerHTML = web.outbox - .map((op) => `${meta[op.task].ref} → ${LABEL[op.to]}`) + .map( + (op) => `${meta[op.task].ref} → ${LABEL[op.to]}`, + ) .join(''); outboxCount.textContent = String(web.outbox.length); } @@ -137,7 +151,12 @@ export function initSync(root) { const ch = chip.offsetHeight; const a = center(fromEl); const b = center(toEl); - await flyTo(chip, { x: a.x - cw / 2, y: a.y - ch / 2 }, { x: b.x - cw / 2, y: b.y - ch / 2 }, { duration: 640, arc: 26 }).catch(() => {}); + await flyTo( + chip, + { x: a.x - cw / 2, y: a.y - ch / 2 }, + { x: b.x - cw / 2, y: b.y - ch / 2 }, + { duration: 640, arc: 26 }, + ).catch(() => {}); chip.remove(); } @@ -176,15 +195,25 @@ export function initSync(root) { web.outbox.forEach((rest) => { if (rest.task === op.task) rest.base = server.v; }); - if (!web.outbox.some((rest) => rest.task === op.task)) web.tasks[op.task] = { s: op.to, v: server.v }; + if (!web.outbox.some((rest) => rest.task === op.task)) + web.tasks[op.task] = { s: op.to, v: server.v }; changed.add(op.task); pulse(hubEl, 'is-hit'); - say(`中枢已应用 ${meta[op.task].ref} → ${LABEL[op.to]},版本 ${server.v},变更游标前进到 ${hub.cursor}。`); + say( + `中枢已应用 ${meta[op.task].ref} → ${LABEL[op.to]},版本 ${server.v},变更游标前进到 ${hub.cursor}。`, + ); } else { // 版本冲突:保留本地意图与服务器快照,不静默覆盖 web.outbox = web.outbox.filter((rest) => rest.task !== op.task); - web.conflict = { task: op.task, local: web.tasks[op.task].s, server: server.s, serverV: server.v }; - say(`${meta[op.task].ref} 的基准版本是 ${op.base},中枢已经是 ${server.v}:返回冲突与服务器快照,等你选择。`); + web.conflict = { + task: op.task, + local: web.tasks[op.task].s, + server: server.s, + serverV: server.v, + }; + say( + `${meta[op.task].ref} 的基准版本是 ${op.base},中枢已经是 ${server.v}:返回冲突与服务器快照,等你选择。`, + ); } render(); await sleep(reduced ? 0 : 120); @@ -209,14 +238,21 @@ export function initSync(root) { const to = NEXT[t.s]; // 先写本地:实体与待同步记录在同一个事务里 const pendingBase = web.outbox.find((op) => op.task === id)?.base; - web.outbox.push({ id: `${Date.now()}-${Math.random()}`, task: id, to, base: pendingBase ?? t.v }); + web.outbox.push({ + id: `${Date.now()}-${Math.random()}`, + task: id, + to, + base: pendingBase ?? t.v, + }); t.s = to; render(); if (web.online) { say(`本地已写入 ${meta[id].ref} → ${LABEL[to]},正在推送……`); flush(); } else { - say(`离线:${meta[id].ref} → ${LABEL[to]} 已写入本地,排进待同步队列(${web.outbox.length})。`); + say( + `离线:${meta[id].ref} → ${LABEL[to]} 已写入本地,排进待同步队列(${web.outbox.length})。`, + ); } } @@ -236,7 +272,12 @@ export function initSync(root) { render(); } else { web.tasks[c.task] = { s: c.local, v: hub.tasks[c.task].v }; - web.outbox.push({ id: `r-${Date.now()}`, task: c.task, to: c.local, base: hub.tasks[c.task].v }); + web.outbox.push({ + id: `r-${Date.now()}`, + task: c.task, + to: c.local, + base: hub.tasks[c.task].v, + }); say(`已采用本地版本,基于最新版本重新推送 ${meta[c.task].ref}……`); render(); flush(); @@ -249,7 +290,11 @@ export function initSync(root) { web.online = !web.online; render(); if (web.online) { - say(web.outbox.length ? `重新连上中枢:按顺序推送 ${web.outbox.length} 条修改……` : '重新连上中枢,没有待同步的修改。'); + say( + web.outbox.length + ? `重新连上中枢:按顺序推送 ${web.outbox.length} 条修改……` + : '重新连上中枢,没有待同步的修改。', + ); flush(); } else { say('浏览器离线了。照常改任务:界面立刻更新,修改排进待同步队列。'); @@ -262,7 +307,9 @@ export function initSync(root) { const id = web.outbox[0]?.task || 'a'; const server = hub.tasks[id]; // 选一个与服务器、浏览器本地都不同的状态,冲突才看得出差别 - const to = ['todo', 'doing', 'done'].find((x) => x !== server.s && x !== web.tasks[id].s) || NEXT[server.s]; + const to = + ['todo', 'doing', 'done'].find((x) => x !== server.s && x !== web.tasks[id].s) || + NEXT[server.s]; busy = true; render(); await fly(`${meta[id].ref} → ${LABEL[to]}`, andEl, hubEl); @@ -278,7 +325,9 @@ export function initSync(root) { flush(); return; } - say(`Android 把 ${meta[id].ref} 改成了 ${LABEL[to]}。浏览器离线,收不到通知;重新连上时会发现版本对不上。`); + say( + `Android 把 ${meta[id].ref} 改成了 ${LABEL[to]}。浏览器离线,收不到通知;重新连上时会发现版本对不上。`, + ); }); render(); diff --git a/website/site/assets/js/timeline.js b/website/site/assets/js/timeline.js index 62565d8..9f62d0c 100644 --- a/website/site/assets/js/timeline.js +++ b/website/site/assets/js/timeline.js @@ -32,7 +32,12 @@ export function initTimeline(root) { for (let i = 0; i < 5; i++) { const d = new Date(base); d.setDate(base.getDate() + i); - cols.push({ id: `d${i}`, kind: 'date', title: i === 0 ? '今天' : i === 1 ? '明天' : wd.format(d), sub: md.format(d) }); + cols.push({ + id: `d${i}`, + kind: 'date', + title: i === 0 ? '今天' : i === 1 ? '明天' : wd.format(d), + sub: md.format(d), + }); } cols.push({ id: 'e1', kind: 'event', title: '额度重置后', sub: '自定义事件', arrived: false }); cols.push({ id: 'e2', kind: 'event', title: '上线前', sub: '自定义事件', arrived: false }); @@ -55,7 +60,9 @@ export function initTimeline(root) { function renderTray() { tray.innerHTML = ['t1', 't2', 't4'] .map( - (id) => ``, ) .join(''); @@ -148,7 +155,8 @@ export function initTimeline(root) { const pill = e.target.closest('[data-task]'); if (pill) { selected = selected === pill.dataset.task ? null : pill.dataset.task; - if (selected) say(`已选中「${tasks[selected].title}」,点一个日期或事件放进去。按 Esc 取消。`); + if (selected) + say(`已选中「${tasks[selected].title}」,点一个日期或事件放进去。按 Esc 取消。`); render(); return; } @@ -230,7 +238,9 @@ export function initTimeline(root) { drag = { src, pid: mini ? mini.dataset.placement : null, - task: pill ? pill.dataset.task : placements.find((p) => p.id === mini.dataset.placement)?.task, + task: pill + ? pill.dataset.task + : placements.find((p) => p.id === mini.dataset.placement)?.task, sx: e.clientX, sy: e.clientY, ox: e.clientX - rect.left, @@ -295,7 +305,10 @@ export function initTimeline(root) { .animate( [ { transform: d.ghost.style.transform, opacity: 1 }, - { transform: `translate(${r.left}px, ${toY}px) rotate(0deg) scale(0.92)`, opacity: 0.2 }, + { + transform: `translate(${r.left}px, ${toY}px) rotate(0deg) scale(0.92)`, + opacity: 0.2, + }, ], { duration: 260, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'forwards' }, ) @@ -310,7 +323,10 @@ export function initTimeline(root) { if (!reduced) { await d.ghost .animate( - [{ transform: d.ghost.style.transform }, { transform: `translate(${r.left}px, ${r.top}px) rotate(0deg) scale(1)` }], + [ + { transform: d.ghost.style.transform }, + { transform: `translate(${r.left}px, ${r.top}px) rotate(0deg) scale(1)` }, + ], { duration: 420, easing: 'cubic-bezier(.34,1.56,.64,1)', fill: 'forwards' }, ) .finished.catch(() => {}); diff --git a/website/site/download.html b/website/site/download.html index ddcf89f..076580c 100644 --- a/website/site/download.html +++ b/website/site/download.html @@ -1,275 +1,540 @@ - - - - 下载与部署 — TaskDock - - - - - - - - - - - - - - - - - + + + + 下载与部署 — TaskDock + + + + + + + + + + + + + + + + + - + - - -
-
-
-

下载与部署

-

先部署中枢,
再从任何设备打开。

-

TaskDock 由一个自托管中枢和若干客户端组成。中枢用 Docker 运行,网页端随中枢一起提供;Android 客户端从 GitHub Releases 下载。

- -
- v2.0.0-rc.37 - 预览版 · 发布于 2026年10月13日
当前发布均为预发布版本,正式使用前请先备份数据。
- 发布说明 +
+ -
-
-
- 01 · 中枢 -

Docker 镜像

-

运行在 NAS、家用服务器或云主机上。镜像发布在 GitHub Container Registry,服务器上不需要源码,也不需要 Node.js。

-
ghcr.io/igngserver/taskdock:2.0.0-rc.37
- -
-
- 02 · 网页 -

浏览器 / PWA

-

中枢启动后,用浏览器打开你配置的 APP_ORIGIN 地址就能使用,无需安装客户端。使用 HTTPS 时可以安装为 PWA 并离线打开。

-
默认 http://服务器地址:48731
-
-
- 03 · 手机 -

Android

-

原生客户端,登录时填写你的中枢地址。APK 附在每个 Release 中,由发布流程构建并签名。

- - TaskDock-2.0.0-rc.37-android.apk -
-
-
+
+
+
+

下载与部署

+

先部署中枢,
再从任何设备打开。

+

+ TaskDock 由一个自托管中枢和若干客户端组成。中枢用 Docker + 运行,网页端随中枢一起提供;Android 客户端从 GitHub Releases 下载。 +

-
-
-
-

部署五步完成

-

用 Docker Compose
部署中枢。

-

以下步骤与产品仓库的 README 和部署说明一致。服务器只需要 Docker 与 Docker Compose。

+
+ v2.0.0-rc.37 + 预览版 · 发布于 + 2026年10月13日
当前发布均为预发布版本,正式使用前请先备份数据。
+ 发布说明 +
+
-
- - -
- 1 -
-

准备文件

-

在服务器上建一个空目录,下载 compose.yaml 和 .env.example,然后复制一份环境变量文件。

-
cp .env.example .env
+
+
+
+ 01 · 中枢 +

Docker 镜像

+

+ 运行在 NAS、家用服务器或云主机上。镜像发布在 GitHub Container + Registry,服务器上不需要源码,也不需要 Node.js。 +

+
+ ghcr.io/igngserver/taskdock:2.0.0-rc.37
+ +
+
+ 02 · 网页 +

浏览器 / PWA

+

+ 中枢启动后,用浏览器打开你配置的 + APP_ORIGIN 地址就能使用,无需安装客户端。使用 HTTPS + 时可以安装为 PWA 并离线打开。 +

+
默认 http://服务器地址:48731
+
+
+ 03 · 手机 +

Android

+

+ 原生客户端,登录时填写你的中枢地址。APK 附在每个 Release 中,由发布流程构建并签名。 +

+ + TaskDock-2.0.0-rc.37-android.apk +
+
+
+ +
+
+
+

部署五步完成

+

用 Docker Compose
部署中枢。

+

+ 以下步骤与产品仓库的 README 和部署说明一致。服务器只需要 Docker 与 Docker Compose。 +

-
- 2 -
-

填写 .env

-

至少填写下面这些项。随机密钥可以用 openssl rand -base64 48 生成;真实密钥不要提交到 Git。

-
- - - - - - - - - - - - -
变量说明
TASKDOCK_PORT对外 HTTP 端口,默认 48731;被占用时可以换成其他五位端口。
APP_ORIGIN访问 TaskDock 的完整地址,例如 https://tasks.example.com 或 http://192.168.1.10:48731。
CORS_ALLOWED_ORIGINS允许访问的前端地址,通常与 APP_ORIGIN 相同。
POSTGRES_PASSWORD数据库密码。
BOOTSTRAP_TOKEN首次初始化 Owner 用的一次性令牌,只在部署端使用,客户端不会要求填写。
OWNER_USERNAME
OWNER_PASSWORD
首次初始化的 Owner 账号。密码至少 6 位。
ACCESS_TOKEN_SECRET访问令牌密钥。
REFRESH_TOKEN_PEPPER刷新令牌保护密钥。
+
+ + +
+ 1 +
+

准备文件

+

+ 在服务器上建一个空目录,下载 + compose.yaml + 和 + .env.example,然后复制一份环境变量文件。 +

+
+ cp .env.example .env +
-
-
- 3 -
-

选择版本并启动

-

使用明确的版本号,方便升级和回滚。下面的版本号是当前最新发布。

-
-
- - 启动 - +
+ 2 +
+

填写 .env

+

+ 至少填写下面这些项。随机密钥可以用 + openssl rand -base64 48 生成;真实密钥不要提交到 Git。 +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
变量说明
TASKDOCK_PORT + 对外 HTTP 端口,默认 + 48731;被占用时可以换成其他五位端口。 +
APP_ORIGIN + 访问 TaskDock 的完整地址,例如 + https://tasks.example.com 或 + http://192.168.1.10:48731。 +
CORS_ALLOWED_ORIGINS + 允许访问的前端地址,通常与 + APP_ORIGIN 相同。 +
POSTGRES_PASSWORD数据库密码。
BOOTSTRAP_TOKEN + 首次初始化 Owner 用的一次性令牌,只在部署端使用,客户端不会要求填写。 +
OWNER_USERNAME
OWNER_PASSWORD
首次初始化的 Owner 账号。密码至少 6 位。
ACCESS_TOKEN_SECRET访问令牌密钥。
REFRESH_TOKEN_PEPPER刷新令牌保护密钥。
-
$ export APP_VERSION=2.0.0-rc.37
+              
+
+ +
+ 3 +
+

选择版本并启动

+

使用明确的版本号,方便升级和回滚。下面的版本号是当前最新发布。

+
+
+ + 启动 + +
+
$ export APP_VERSION=2.0.0-rc.37
 $ docker compose pull
 $ docker compose --profile operations run --rm migrate
 $ docker compose up -d
 # 检查就绪状态(使用 HTTPS 或自定义端口时按 APP_ORIGIN 修改)
 $ curl -fsS http://服务器地址:48731/health/ready
+
+
+ latest 只指向正式稳定版本,不会指向预发布版本。要使用 2.0 + 预览版,请写明确的版本号;生产环境同样建议固定版本。 +
-
latest 只指向正式稳定版本,不会指向预发布版本。要使用 2.0 预览版,请写明确的版本号;生产环境同样建议固定版本。
-
-
- 4 -
-

初始化 Owner

-

TaskDock 是单用户设计。由部署端执行一次初始化,读取 .env 里的 Owner 账号与 BOOTSTRAP_TOKEN。

-
docker compose --profile operations run --rm bootstrap
-
初始化成功后,从 .env 中删除 OWNER_PASSWORD,并删除或轮换 BOOTSTRAP_TOKEN。
+
+ 4 +
+

初始化 Owner

+

+ TaskDock 是单用户设计。由部署端执行一次初始化,读取 .env 里的 Owner + 账号与 BOOTSTRAP_TOKEN。 +

+
+ docker compose --profile operations run --rm bootstrap +
+
+ 初始化成功后,从 .env 中删除 + OWNER_PASSWORD,并删除或轮换 BOOTSTRAP_TOKEN。 +
+
-
-
- 5 -
-

打开并登录

-

浏览器访问 APP_ORIGIN 并登录;Android 客户端安装后填写同一个中枢地址。HTTP 地址可以使用,但页面会显示安全警告,密码与会话信息会明文传输。公网环境请使用 HTTPS:需要反向代理时,可以启用 Compose 中的 gateway(Caddy)profile,注意不要让两个服务占用同一个宿主机端口。

+
+ 5 +
+

打开并登录

+

+ 浏览器访问 APP_ORIGIN 并登录;Android + 客户端安装后填写同一个中枢地址。HTTP + 地址可以使用,但页面会显示安全警告,密码与会话信息会明文传输。公网环境请使用 + HTTPS:需要反向代理时,可以启用 Compose 中的 + gateway(Caddy)profile,注意不要让两个服务占用同一个宿主机端口。 +

+
-
-
+
-
-
-
-

运维升级、回滚与版本

-

升级前,先备份。

-
-
-
-

升级

-

按备份与恢复文档生成并校验备份,再把 APP_VERSION 改成目标版本,依次执行 pull、migrate、up -d。

+
+
+
+

运维升级、回滚与版本

+

升级前,先备份。

-
-

回滚

-

把 APP_VERSION 改回上一个版本,并根据备份恢复文档处理数据库。不要直接删除旧的数据库卷。

+
+
+

升级

+

+ 按备份与恢复文档生成并校验备份,再把 APP_VERSION 改成目标版本,依次执行 + pull、migrate、up -d。 +

+
+
+

回滚

+

+ 把 + APP_VERSION + 改回上一个版本,并根据备份恢复文档处理数据库。不要直接删除旧的数据库卷。 +

+
+
+

预览版与正式版

+

+ 版本号遵循语义化版本。普通发布默认是预发布版本,例如 + 2.0.0-rc.37;只有明确选择正式发布时,才会生成正式 Release。 +

+
+
+

每个版本都有说明

+

+ 每次发布都会在 GitHub Release 中用中文写明更新内容、已知问题和安装包状态。Docker + 镜像单独发布在 GHCR,不是 Release 附件。 +

+
-
-

预览版与正式版

-

版本号遵循语义化版本。普通发布默认是预发布版本,例如 2.0.0-rc.37;只有明确选择正式发布时,才会生成正式 Release。

+
+
+ +
+
+
+

文档产品仓库

+

更多细节,
以产品仓库为准。

-
-

每个版本都有说明

-

每次发布都会在 GitHub Release 中用中文写明更新内容、已知问题和安装包状态。Docker 镜像单独发布在 GHCR,不是 Release 附件。

+
-
-
+
+
-
+
-
- - - + + diff --git a/website/site/index.html b/website/site/index.html index 841d5d0..0c3b56d 100644 --- a/website/site/index.html +++ b/website/site/index.html @@ -1,266 +1,584 @@ - - - - TaskDock — 让散落的任务各归其位 - - - - - - - - - - - - - - - - - - - + + + + TaskDock — 让散落的任务各归其位 + + + + + + + + + + + + + + + + + + + - + - + -
- -
-
-
-

自托管 · 离线优先 · 为个人开发者

-

- 让散落的任务 - 各归其位。 -

-

TaskDock 是一个自托管的任务工作台:先把想法记下来,再按目录、日期和流程安排,每个任务都能附上开发笔记。它部署在你自己的服务器上,断网时照样能看、能改。

- -
- 2.0 预览版 - 网页 / PWA · Android · Docker 中枢 +
+ +
+
+
+

自托管 · 离线优先 · 为个人开发者

+

+ 让散落的任务 + 各归其位。 +

+

+ TaskDock + 是一个自托管的任务工作台:先把想法记下来,再按目录、日期和流程安排,每个任务都能附上开发笔记。它部署在你自己的服务器上,断网时照样能看、能改。 +

+ +
+ 2.0 预览版 + 网页 / PWA · Android · Docker 中枢 +
-
-
- - -
-
-
-

01数据模型

-

一个任务,
只有一个事实。

-

目录决定任务放在哪里,日期与时间点决定什么时候关注它,流程记录它走到哪个阶段,步骤拆解它具体怎么做。四个维度彼此独立,引用的始终是同一个任务——在任何一处改变状态,所有视图同时更新。

-
- - - - + -

+
-
-
- - /tree - 同一个 taskId -
-
- - + +
+
+
+

01数据模型

+

一个任务,
只有一个事实。

+

+ 目录决定任务放在哪里,日期与时间点决定什么时候关注它,流程记录它走到哪个阶段,步骤拆解它具体怎么做。四个维度彼此独立,引用的始终是同一个任务——在任何一处改变状态,所有视图同时更新。 +

+
+ + + + +
+

-
-
-
- -
-
-
-

02日期与时间点

-

什么时候做,
交给时间轴。

-

把任务安排到某一天,或者挂在一个还没有确定日期的事件上。安排只是一条引用:同一个任务可以同时出现在好几处,完成一次,处处完成。

-
- -
-
- 拖到时间轴上,或点选后再点目标列: -
-
-
-
- 试试把任务拖到另一天:它不会被复制,只是多了一处安排。 - +
+
+ + /tree + 同一个 taskId +
+
+ + +
+
-
-
-

安排不是复制

-

加入日程只增加一条安排关系,标题、状态和笔记仍然只存在于任务本身。

+ +
+
+
+

02日期与时间点

+

什么时候做,
交给时间轴。

+

+ 把任务安排到某一天,或者挂在一个还没有确定日期的事件上。安排只是一条引用:同一个任务可以同时出现在好几处,完成一次,处处完成。 +

-
-

顺延可以撤销

-

把某一天仍未完成的安排批量顺延到次日,自动跳过已完成或已归档的任务,随时可以撤销。

+ +
+
+ 拖到时间轴上,或点选后再点目标列: +
+
+
+
+ 试试把任务拖到另一天:它不会被复制,只是多了一处安排。 + +
-
-

事件到达不改任务

-

「上线前」「额度重置后」这样的事件到达,只改变事件本身;任务是否完成,仍然由你决定。

+ +
+
+

安排不是复制

+

加入日程只增加一条安排关系,标题、状态和笔记仍然只存在于任务本身。

+
+
+

顺延可以撤销

+

+ 把某一天仍未完成的安排批量顺延到次日,自动跳过已完成或已归档的任务,随时可以撤销。 +

+
+
+

事件到达不改任务

+

+ 「上线前」「额度重置后」这样的事件到达,只改变事件本身;任务是否完成,仍然由你决定。 +

+
-
-
+ - -
-
-
-

03离线优先

-

断网的时候,
工作不会停。

-

界面先读本地数据,不等网络。每次修改和它的待同步记录在同一个本地事务里写入;恢复连接后按顺序推送,再拉取其他设备的变化。

-
    -
  • 先读本地。浏览器使用 IndexedDB,Android 使用 Room,打开就能用。
  • -
  • 按顺序重放。修改先进入待同步队列,联网后逐条推送;同一条修改重试不会重复写入。
  • -
  • 冲突不静默覆盖。版本对不上时,同时保留你的修改和服务器快照,由你决定。
  • -
  • 高风险操作不冒险。整棵目录删除、批量顺延这类操作,离线时直接拒绝。
  • -
  • 实时但克制。WebSocket 只推送“需要同步”的信号,不携带任务内容。
  • -
-
+ +
+
+
+

03离线优先

+

断网的时候,
工作不会停。

+

+ 界面先读本地数据,不等网络。每次修改和它的待同步记录在同一个本地事务里写入;恢复连接后按顺序推送,再拉取其他设备的变化。 +

+
    +
  • + 先读本地。浏览器使用 IndexedDB,Android 使用 Room,打开就能用。 +
  • +
  • + 按顺序重放。修改先进入待同步队列,联网后逐条推送;同一条修改重试不会重复写入。 +
  • +
  • + 冲突不静默覆盖。版本对不上时,同时保留你的修改和服务器快照,由你决定。 +
  • +
  • + 高风险操作不冒险。整棵目录删除、批量顺延这类操作,离线时直接拒绝。 +
  • +
  • + 实时但克制。WebSocket 只推送“需要同步”的信号,不携带任务内容。 +
  • +
+
-
-
- -
-
浏览器 · PWA在线
-
-
-
待同步队列0
-
+
+
+ +
+
+ 浏览器 · PWA在线 +
+
+
+
待同步队列0
+
+
-
-
- - TaskDock 中枢 - Fastify · PostgreSQL - cursor 1042 -
-
-
Android在线
-
-
-
待同步队列0
-
+
+ + TaskDock 中枢 + Fastify · PostgreSQL + cursor 1042 +
+
+
+ Android在线 +
+
+
+
待同步队列0
+
+
+
+ + +
+

+ 点浏览器里任务前的圆圈改变状态,看修改怎样到达中枢和 Android;再把浏览器切到离线试试。 +

-
- - -
-

点浏览器里任务前的圆圈改变状态,看修改怎样到达中枢和 Android;再把浏览器切到离线试试。

-
-
+
- -
-
-
-

04自托管

-

数据放在你自己的
服务器上。

-

TaskDock 以 Docker 镜像发布在 GitHub Container Registry。服务器上不需要源码,也不需要 Node.js:准备好 compose.yaml 和 .env,拉取镜像、执行迁移、启动,家里的 NAS 也能跑。

-
+ +
+
+
+

04自托管

+

数据放在你自己的
服务器上。

+

+ TaskDock 以 Docker 镜像发布在 GitHub Container Registry。服务器上不需要源码,也不需要 + Node.js:准备好 compose.yaml 和 + .env,拉取镜像、执行迁移、启动,家里的 NAS 也能跑。 +

+
-
-
-
- - - - - -
-
# 在空目录中准备 compose.yaml 与 .env.example
+          
+
+
+ + + + + +
+
# 在空目录中准备 compose.yaml 与 .env.example
 $ cp .env.example .env
 # 生成随机密钥,填写 .env 里的密码与令牌
 $ openssl rand -base64 48
@@ -269,194 +587,315 @@ 

数据放在你自己的
服务器上。$ docker compose --profile operations run --rm migrate $ docker compose up -d # 默认访问 http://服务器地址:48731

-
+
-
-
-
- - Caddy 网关(可选)
反向代理;HTTPS 地址自动签发证书
- :80 :443 -
-
- - TaskDock 中枢
REST · WebSocket · 网页端
- :48731 -
-
- - PostgreSQL 18
权威数据源,只在容器网络内可达
- 不映射端口 +
+
+
+ + Caddy 网关(可选)
反向代理;HTTPS 地址自动签发证书
+ :80 :443 +
+
+ + TaskDock 中枢
REST · WebSocket · 网页端
+ :48731 +
+
+ + PostgreSQL 18
权威数据源,只在容器网络内可达
+ 不映射端口 +
+
    +
  • + 推荐固定具体版本号。latest + 只跟随正式稳定版,不会指向预览版。 +
  • +
  • + 公网建议使用 HTTPS。HTTP 地址也能用,但界面会给出安全提示;网页端要安装为 PWA + 或离线打开,需要 HTTPS(或本机 localhost)。 +
  • +
  • + 用于重要数据前,先读 + 备份与恢复 + 和 + 安全说明。 +
  • +
-
    -
  • 推荐固定具体版本号。latest 只跟随正式稳定版,不会指向预览版。
  • -
  • 公网建议使用 HTTPS。HTTP 地址也能用,但界面会给出安全提示;网页端要安装为 PWA 或离线打开,需要 HTTPS(或本机 localhost)。
  • -
  • 用于重要数据前,先读 备份与恢复 和 安全说明。
  • -
-
-
+
- -
-
-
-

05细节

-

每天都会碰到的小事,
都有安放的位置。

-
-
-
- 05.1 -

Markdown 开发笔记

-

每个任务都能保存 Markdown 笔记,记下思路、命令、链接和处理过程,和任务放在一起。

-
- -
-
-
-

06多端

-

一套数据,
在你常用的地方打开。

-
-
-
- -

网页 · PWA

-

部署后直接用浏览器访问,无需另装客户端。界面按屏幕自适应:手机单手快速记录,平板双栏,桌面支持拖放与键盘。

- 随中枢一起提供 -
-
- -

Android

-

原生 Jetpack Compose 客户端,使用 Room 本地存储,和网页端共用同一套 API 与同步协议。

- APK · GitHub Releases -
-
- -

中枢 · Docker

-

Fastify 与 PostgreSQL 组成的单用户中枢。所有设备的数据最终汇集在这里,也只在这里。

- ghcr.io/igngserver/taskdock -
+ +
+
+
+

06多端

+

一套数据,
在你常用的地方打开。

+
+
+
+ +

网页 · PWA

+

+ 部署后直接用浏览器访问,无需另装客户端。界面按屏幕自适应:手机单手快速记录,平板双栏,桌面支持拖放与键盘。 +

+ 随中枢一起提供 +
+
+ +

Android

+

+ 原生 Jetpack Compose 客户端,使用 Room 本地存储,和网页端共用同一套 API 与同步协议。 +

+ APK · GitHub Releases +
+
+ +

中枢 · Docker

+

+ Fastify 与 PostgreSQL 组成的单用户中枢。所有设备的数据最终汇集在这里,也只在这里。 +

+ ghcr.io/igngserver/taskdock +
+
-
-
+ -
-
-

把今天的事,
放回它该在的位置。

-

TaskDock 目前处于 2.0 预览阶段,发布的都是预发布版本。用于重要数据前,请先阅读部署与备份文档。

- - -
-
+ + - +