Skip to content

src/app.ts

162 lines · 6.5 KB · TypeScript
Raw
import { packageContext } from 'kody:runtime'
import { getSnapshot } from './getSnapshot'
import { AQI_STANDARDS, type AqiStandard } from './getSnapshot'

function appUrl(path: string): URL {
	if (!packageContext?.hostedUrl) {
		throw new Error('This module must run as a package app.')
	}
	const relative = path.replace(/^\/+/, '')
	const mount = packageContext.appBasePath.endsWith('/')
		? packageContext.appBasePath
		: `${packageContext.appBasePath}/`
	return new URL(`${mount}${relative}`, packageContext.hostedUrl)
}

function renderPage(): Response {
	const apiPath = appUrl('api/getSnapshot').pathname
	const standards = Object.entries(AQI_STANDARDS)
		.map(([k, label]) => `<option value="${k}"${k === 'aqi_cn' ? ' selected' : ''}>${label}</option>`)
		.join('')
	const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>空气质量快照</title>
  <style>
    :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b1220; color: #e2e8f0; }
    main { width: min(720px, 94vw); background: #111827; border-radius: 16px; padding: 1.4rem; box-shadow: 0 20px 50px rgba(0,0,0,.4); }
    h1 { margin: 0 0 .35rem; font-size: 1.35rem; }
    p.lead { margin: 0 0 1rem; color: #94a3b8; font-size: .92rem; }
    label { display: block; margin: .55rem 0 .35rem; font-size: .88rem; color: #cbd5e1; }
    input, select { width: 100%; border-radius: 10px; border: 1px solid #334155; background: #0b1220; color: inherit; padding: .7rem; font-size: 1rem; box-sizing: border-box; }
    .row { display: flex; gap: .75rem; align-items: center; margin: .9rem 0 1rem; flex-wrap: wrap; }
    button { border-radius: 999px; border: 0; padding: .55rem 1.15rem; font-size: .95rem; background: #38bdf8; color: #0f172a; font-weight: 700; cursor: pointer; }
    button:disabled { opacity: .6; cursor: wait; }
    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .6rem; margin-top: .8rem; }
    .card { background: #0b1220; border: 1px solid #334155; border-radius: 12px; padding: .75rem; }
    .card .k { color: #94a3b8; font-size: .75rem; }
    .card .v { font-size: 1.15rem; font-weight: 700; margin-top: .2rem; }
    .out { margin-top: .8rem; padding: .9rem; border-radius: 10px; background: #0b1220; border: 1px solid #334155; white-space: pre-wrap; word-break: break-word; font-size: .9rem; }
    .err { color: #fca5a5; margin-top: .6rem; }
    .note { margin-top: 1rem; color: #64748b; font-size: .78rem; line-height: 1.4; }
  </style>
</head>
<body>
  <main>
    <h1>空气质量快照</h1>
    <p class="lead">Scrapes air-quality.com(同 HA air_quality_cn 字段):AQI / 污染物 / 花粉 / 天气。上游 HTML 可能变更。</p>
    <label for="city">地点</label>
    <input id="city" value="北京" placeholder="北京 / 上海 / Berlin / 海淀" />
    <label for="standard">AQI 标准</label>
    <select id="standard">${standards}</select>
    <div class="row"><button id="go" type="button">查询</button></div>
    <div class="grid" id="cards"></div>
    <div class="out" id="out">—</div>
    <div class="err" id="err"></div>
    <p class="note">Not medical advice. Primary: scraped air-quality.com. Fallback: Open-Meteo / 北京花粉监测平台.</p>
  </main>
  <script>
    const apiPath = ${JSON.stringify(apiPath)};
    const cityEl = document.getElementById('city');
    const standardEl = document.getElementById('standard');
    const outEl = document.getElementById('out');
    const errEl = document.getElementById('err');
    const goEl = document.getElementById('go');
    const cardsEl = document.getElementById('cards');

    function card(k, v) {
      const d = document.createElement('div');
      d.className = 'card';
      d.innerHTML = '<div class="k"></div><div class="v"></div>';
      d.querySelector('.k').textContent = k;
      d.querySelector('.v').textContent = v == null || v === '' ? '—' : String(v);
      return d;
    }

    async function run() {
      errEl.textContent = '';
      goEl.disabled = true;
      cardsEl.innerHTML = '';
      try {
        const res = await fetch(apiPath, {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ location: cityEl.value, standard: standardEl.value, fallback: true }),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
        const p = data.pollutants || {};
        const w = data.weather || {};
        const pol = data.pollen || {};
        ;[
          ['AQI', data.aqi],
          ['等级', data.level],
          ['PM2.5', p.pm25],
          ['PM10', p.pm10],
          ['O3', p.o3],
          ['NO2', p.no2],
          ['花粉', pol.raw],
          ['温度°C', w.temperatureC],
        ].forEach(([k, v]) => cardsEl.appendChild(card(k, v)));
        outEl.textContent = [
          data.summary,
          'source: ' + data.source,
          data.url ? 'url: ' + data.url : '',
          data.coverageNote || '',
          (data.warnings || []).join('\\n'),
        ].filter(Boolean).join('\\n');
      } catch (e) {
        outEl.textContent = '—';
        errEl.textContent = String(e && e.message ? e.message : e);
      } finally {
        goEl.disabled = false;
      }
    }

    goEl.addEventListener('click', run);
    cityEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
  </script>
</body>
</html>`
	return new Response(html, {
		headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
	})
}

async function handleApi(request: Request): Promise<Response> {
	if (request.method !== 'POST') {
		return Response.json({ error: 'POST required' }, { status: 405 })
	}
	let body: {
		location?: string
		place?: string
		standard?: AqiStandard
		fallback?: boolean
	} = {}
	try {
		body = (await request.json()) as typeof body
	} catch {
		return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
	}
	try {
		const result = await getSnapshot(body)
		return Response.json(result)
	} catch (e) {
		return Response.json(
			{ error: e instanceof Error ? e.message : String(e) },
			{ status: 400 },
		)
	}
}

export default {
	async fetch(request: Request): Promise<Response> {
		const path = new URL(request.url).pathname
		if (path === '/' || path === '') return renderPage()
		if (path === '/api/getSnapshot') return handleApi(request)
		return new Response('Not found', { status: 404 })
	},
}