Files

501 lines
12 KiB
HTML
Raw Permalink Normal View History

2026-05-12 01:27:02 +00:00
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>超級快速的離線國語辭典!</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang TC", "Microsoft JhengHei", sans-serif;
background: #fafaf7;
color: #222;
line-height: 1.6;
}
header {
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e5e5e0;
position: sticky;
top: 0;
z-index: 10;
}
.top-row {
display: flex;
gap: 12px;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.dict-switch {
display: flex;
border: 1px solid #d0d0c8;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.dict-btn {
padding: 9px 14px;
font-size: 14px;
background: #fff;
border: none;
cursor: pointer;
color: #555;
transition: background 0.1s;
font-family: inherit;
}
.dict-btn + .dict-btn {
border-left: 1px solid #e5e5e0;
}
.dict-btn:hover { background: #f5f5f0; }
.dict-btn.active {
background: #333;
color: #fff;
}
.dict-btn:disabled {
opacity: 0.5;
cursor: wait;
}
#search {
flex: 1;
padding: 10px 14px;
font-size: 16px;
border: 1px solid #d0d0c8;
border-radius: 6px;
background: #fff;
}
#search:focus {
outline: none;
border-color: #888;
}
#status {
font-size: 13px;
color: #777;
white-space: nowrap;
}
main {
display: flex;
max-width: 1400px;
margin: 0 auto;
height: calc(100vh - 73px);
}
#list {
width: 240px;
border-right: 1px solid #e5e5e0;
overflow-y: auto;
background: #fff;
}
.item {
padding: 10px 16px;
border-bottom: 1px solid #f0f0ec;
cursor: pointer;
transition: background 0.1s;
}
.item:hover { background: #f5f5f0; }
.item.active { background: #e8e8df; }
.item-name {
font-size: 18px;
font-weight: 600;
margin-right: 8px;
}
.item-zhuyin {
font-size: 12px;
color: #888;
}
#detail {
flex: 1;
overflow-y: auto;
padding: 28px 36px;
}
.empty {
color: #999;
text-align: center;
margin-top: 40px;
font-size: 14px;
}
.detail-name {
font-size: 48px;
font-weight: 700;
margin: 0 0 4px;
line-height: 1.2;
}
.detail-zhuyin {
font-size: 20px;
color: #555;
margin-bottom: 24px;
}
.detail-content {
font-size: 16px;
white-space: pre-wrap;
line-height: 1.9;
}
.more-hint {
padding: 12px 16px;
color: #999;
font-size: 12px;
text-align: center;
}
@media (max-width: 700px) {
main { flex-direction: column; height: auto; }
#list { width: 100%; max-height: 40vh; }
#detail { padding: 20px; }
.detail-name { font-size: 36px; }
.top-row { flex-wrap: wrap; }
#search { order: 3; width: 100%; flex: none; }
}
</style>
</head>
<body>
<header>
<div class="top-row">
<div class="dict-switch">
<button class="dict-btn active" data-dict="0">重編修訂本</button>
<button class="dict-btn" data-dict="1">簡編本</button>
</div>
<input id="search" type="text" placeholder="載入中⋯⋯" disabled>
<div id="status">準備中</div>
</div>
</header>
<main>
<div id="list"></div>
<div id="detail">
<div class="empty">在上方輸入想查的字詞</div>
</div>
</main>
<script>
// ========================================
// 設定區:改成你的 CSV 檔名
const DICT_FILES = [
{ name: '重編修訂本', file: 'dict_revised.csv' },
{ name: '簡編本', file: 'dict_concise.csv' },
];
// ========================================
/**
* 自製 CSV parser
* 支援:欄位用雙引號包起來、雙引號內可有換行、""表示一個雙引號
* 不支援:複雜的引號跳脫(你的資料用不到)
*
* 規則RFC 4180
* - 一行一筆資料,欄位用逗號分隔
* - 若欄位包含逗號、雙引號、或換行,整個欄位用 " 包起來
* - 若被包起來的欄位內含 ",用兩個 "" 表示一個 "
*
* 用狀態機掃過去:兩個狀態 = 「在引號內」或「在引號外」
*/
function parseCSV(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
const len = text.length;
for (let i = 0; i < len; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
// 看下一個字元
if (text[i + 1] === '"') {
// 兩個雙引號 = 一個雙引號
field += '"';
i++;
} else {
// 引號結束
inQuotes = false;
}
} else {
// 引號內的任何字元(包括換行、逗號)都是欄位內容
field += ch;
}
} else {
if (ch === '"') {
// 進入引號(通常出現在欄位開頭)
inQuotes = true;
} else if (ch === ',') {
// 欄位結束
row.push(field);
field = '';
} else if (ch === '\n') {
// 列結束
row.push(field);
rows.push(row);
row = [];
field = '';
} else if (ch === '\r') {
// 忽略 CR等下個字元通常會接 LF
} else {
field += ch;
}
}
}
// 處理最後一個欄位/列(檔案結尾可能沒有換行)
if (field !== '' || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
/**
* 把第一列當 header其餘轉成 [{欄位名: 值}, ...]
*/
function rowsToObjects(rows) {
if (rows.length === 0) return [];
const headers = rows[0];
const result = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
if (row.length === 1 && row[0] === '') continue; // 跳過空行
const obj = {};
for (let j = 0; j < headers.length; j++) {
obj[headers[j]] = row[j] || '';
}
result.push(obj);
}
return result;
}
const dicts = DICT_FILES.map(d => ({
...d,
data: null,
loading: false,
loaded: false,
error: null,
}));
let currentDictIdx = 0;
let filtered = [];
let activeIndex = -1;
let searchTimer = null;
const $search = document.getElementById('search');
const $status = document.getElementById('status');
const $list = document.getElementById('list');
const $detail = document.getElementById('detail');
const $btns = document.querySelectorAll('.dict-btn');
async function loadDict(idx) {
const dict = dicts[idx];
if (dict.loaded || dict.loading) return;
dict.loading = true;
try {
if (idx === currentDictIdx) {
$status.textContent = `下載 ${dict.name}⋯⋯`;
}
// 用 streaming fetch + 進度回報
const response = await fetch(dict.file);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const total = +response.headers.get('Content-Length') || 0;
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let received = 0;
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
text += decoder.decode(value, { stream: true });
if (idx === currentDictIdx && total) {
const pct = Math.round(received / total * 100);
$status.textContent = `下載 ${dict.name}${pct}%`;
}
}
text += decoder.decode();
if (idx === currentDictIdx) {
$status.textContent = `解析 ${dict.name}⋯⋯`;
}
// 解析放到下一個 frame避免卡 UI
await new Promise(r => setTimeout(r, 0));
const rows = parseCSV(text);
const objects = rowsToObjects(rows);
dict.data = objects.filter(o => o['字詞名']);
dict.loaded = true;
dict.loading = false;
if (idx === currentDictIdx) {
onDictReady();
}
} catch (err) {
dict.error = err.message;
dict.loading = false;
if (idx === currentDictIdx) {
$status.textContent = `載入失敗:${err.message}`;
}
}
}
function onDictReady() {
const dict = dicts[currentDictIdx];
$search.disabled = false;
$search.placeholder = `搜尋 ${dict.data.length.toLocaleString()} 筆字詞⋯⋯`;
$status.textContent = `${dict.name}${dict.data.length.toLocaleString()} 筆`;
$btns.forEach((b, i) => {
b.disabled = false;
b.classList.toggle('active', i === currentDictIdx);
});
if ($search.value.trim()) {
doSearch($search.value);
} else {
$search.focus();
}
}
async function switchDict(idx) {
if (idx === currentDictIdx) return;
currentDictIdx = idx;
$btns.forEach((b, i) => b.classList.toggle('active', i === idx));
const dict = dicts[idx];
if (dict.loaded) {
onDictReady();
} else if (dict.error) {
$status.textContent = `載入失敗:${dict.error}`;
$btns.forEach(b => b.disabled = false);
} else {
$search.disabled = true;
$status.textContent = `載入 ${dict.name}⋯⋯`;
await loadDict(idx);
}
}
function render() {
if (filtered.length === 0) {
$list.innerHTML = '<div class="empty" style="padding:20px;">沒有匹配的字詞</div>';
$detail.innerHTML = '<div class="empty">沒有匹配的字詞</div>';
activeIndex = -1;
return;
}
const MAX = 500;
const slice = filtered.slice(0, MAX);
const moreHint = filtered.length > MAX
? `<div class="more-hint">⋯⋯還有 ${filtered.length - MAX} 筆未顯示,請縮小搜尋範圍</div>`
: '';
$list.innerHTML = slice.map((item, i) => `
<div class="item ${i === activeIndex ? 'active' : ''}" data-i="${i}">
<span class="item-name">${escapeHtml(item['字詞名'] || '')}</span>
<span class="item-zhuyin">${escapeHtml(item['注音一式'] || '')}</span>
</div>
`).join('') + moreHint;
if (activeIndex < 0 || activeIndex >= filtered.length) {
activeIndex = 0;
}
renderDetail();
}
function renderDetail() {
const item = filtered[activeIndex];
if (!item) {
$detail.innerHTML = '<div class="empty">沒有匹配的字詞</div>';
return;
}
$detail.innerHTML = `
<h1 class="detail-name">${escapeHtml(item['字詞名'] || '')}</h1>
<div class="detail-zhuyin">${escapeHtml(item['注音一式'] || '')}</div>
<div class="detail-content">${escapeHtml(item['釋義'] || '')}</div>
`;
$list.querySelectorAll('.item').forEach((el, i) => {
el.classList.toggle('active', i === activeIndex);
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function doSearch(q) {
q = q.trim();
const dict = dicts[currentDictIdx];
if (!dict.loaded) return;
if (!q) {
filtered = [];
$list.innerHTML = '<div class="empty" style="padding:20px;">在上方輸入想查的字詞</div>';
$detail.innerHTML = '<div class="empty">在上方輸入想查的字詞</div>';
$status.textContent = `${dict.name}${dict.data.length.toLocaleString()} 筆`;
return;
}
filtered = dict.data.filter(item => (item['字詞名'] || '').includes(q));
filtered.sort((a, b) => {
const an = a['字詞名'] || '';
const bn = b['字詞名'] || '';
const aExact = an === q ? 0 : 1;
const bExact = bn === q ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
const aStart = an.startsWith(q) ? 0 : 1;
const bStart = bn.startsWith(q) ? 0 : 1;
if (aStart !== bStart) return aStart - bStart;
return an.length - bn.length;
});
activeIndex = 0;
render();
$status.textContent = `${dict.name}|匹配 ${filtered.length.toLocaleString()} 筆`;
}
$search.addEventListener('input', e => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => doSearch(e.target.value), 100);
});
$list.addEventListener('click', e => {
const item = e.target.closest('.item');
if (!item) return;
activeIndex = +item.dataset.i;
renderDetail();
});
$search.addEventListener('keydown', e => {
if (filtered.length === 0) return;
const maxIdx = Math.min(filtered.length, 500) - 1;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, maxIdx);
renderDetail();
scrollActiveIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
renderDetail();
scrollActiveIntoView();
}
});
function scrollActiveIntoView() {
const el = $list.querySelector('.item.active');
if (el) el.scrollIntoView({ block: 'nearest' });
}
$btns.forEach((btn, i) => {
btn.addEventListener('click', () => switchDict(i));
});
// 先載第一本,第二本背景延遲載
loadDict(0).then(() => {
setTimeout(() => loadDict(1), 500);
});
</script>
</body>
</html>