Files
homeclaw/web-ui/html/xlsx-viewer.html
T
kimandClaude Sonnet 5 0963231327 v4.3.19-2: 회계사 앱 스프레드시트 뷰어 수정 + helpers.js 5개 파일 분리
- coder_write_file이 xlsx/pptx/docx/pdf/zip 같은 바이너리 확장자로 파일을
  만들려 하면 차단 (텍스트 escape로 바이너리를 흉내내다 파일이 깨지던 문제)
- 레거시 HTML 리다이렉트(/xlsx-viewer.html 등)가 쿼리스트링을 버리던 버그 수정
- xlsx-viewer: "새 시트"가 존재하지 않는 __new__.xlsx를 fetch하다 404나던
  것을 빈 워크북 생성 후 저장 시 파일명 입력받는 흐름으로 교체
- xlsx-viewer: LuckyExcel 변환이 내놓는 손상된 숫자 서식("???.???")을
  Luckysheet SSF 파서가 못 읽고 죽던 문제에 sanitizer 적용
- 회계사 앱 업로드가 존재하지 않는 /api/upload를 호출하던 것을
  /api/upload/file로 수정
- web-ui/js/helpers.js(2240줄)를 helpers-core/markdown/voice/media/files.js
  5개로 분리 (기능 변경 없음, 함수 단위 1:1 이관 확인됨)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:30:05 +09:00

193 lines
8.2 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>스프레드시트</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #fff; font-family: 'Nanum Gothic', sans-serif; }
#toolbar {
display: flex; align-items: center; gap: 8px;
padding: 7px 14px; background: #f5f5f5; border-bottom: 1px solid #ddd;
flex-shrink: 0; height: 46px;
}
#toolbar h1 { font-size: 14px; font-weight: 700; color: #222; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tb-btn {
background: linear-gradient(135deg, #3b82f6, #2563eb); color: #fff;
border: none; border-radius: 6px; padding: 5px 14px;
font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit;
}
.tb-btn:disabled { opacity: 0.6; cursor: default; }
#container {
position: absolute; top: 46px; left: 0; right: 0; bottom: 0;
color-scheme: light;
}
#container, #container * { font-family: 'Nanum Gothic', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; -webkit-font-smoothing: antialiased; }
#container .iconfont { font-family: 'iconfont' !important; }
#loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 13px; }
</style>
</head>
<body>
<div id="toolbar">
<h1 id="title">스프레드시트</h1>
<button class="tb-btn" id="save-btn" onclick="saveFile()">💾 저장</button>
</div>
<div id="container">
<div id="loading">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script>
(function() {
var params = new URLSearchParams(location.search);
var fileUrl = params.get('file');
var saveUrl = params.get('save') || fileUrl;
var title = decodeURIComponent(params.get('title') || '스프레드시트');
// "새 시트" opens with a sentinel path that doesn't exist on disk — create a
// blank sheet locally instead of fetching, and ask for a real filename on save.
var isNewFile = /\/__new__\.xlsx$/.test(fileUrl || '');
document.getElementById('title').textContent = title;
document.title = title;
function showError(msg) {
document.getElementById('container').innerHTML =
'<div style="padding:24px;color:#b91c1c;font-size:13px">' + msg + '</div>';
}
function renderSheets(sheets) {
document.getElementById('container').innerHTML = '';
luckysheet.create({
container: 'container',
data: sheets,
title: '',
lang: 'en',
showinfobar: false,
showstatisticBar: false,
sheetBottomConfig: true,
allowUpdate: true,
enableAddRow: true,
enableAddCol: true,
showToolbar: true,
plugins: [],
defaultFontSize: 11,
defaultFontName: 'Nanum Gothic',
});
}
// LuckyExcel occasionally emits a placeholder number-format string like
// "???.???" for formats it can't map during conversion; Luckysheet's own
// SSF parser then throws "unsupported format" on that literal placeholder
// and the sheet never renders. Strip any format string containing "?".
function sanitizeSheets(sheets) {
(sheets || []).forEach(function(sheet) {
(sheet.celldata || []).forEach(function(cell) {
var ct = cell && cell.v && cell.v.ct;
if (ct && typeof ct.fa === 'string' && ct.fa.indexOf('?') !== -1) {
ct.fa = 'General';
}
});
});
return sheets;
}
function loadFromFile(file) {
LuckyExcel.transformExcelToLucky(file, function(exportInfo) {
renderSheets(sanitizeSheets(exportInfo.sheets));
}, function(err) {
showError('Excel 변환 오류: ' + String(err?.message || err));
});
}
if (!fileUrl) { showError('파일 URL이 없습니다.'); return; }
if (isNewFile) {
// Build a real (if minimal) xlsx and run it through the same LuckyExcel
// conversion as a normal file, rather than hand-rolling Luckysheet's
// internal sheet JSON (undocumented and error-prone).
var blankWb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(blankWb, XLSX.utils.aoa_to_sheet([['']]), 'Sheet1');
var blankBuf = XLSX.write(blankWb, { type: 'array', bookType: 'xlsx' });
loadFromFile(new File([blankBuf], 'sheet.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}));
} else {
fetch(fileUrl, { credentials: 'include' })
.then(function(r) {
if (r.status === 401 || r.status === 403) throw new Error('인증 오류 — 메인 탭에서 먼저 로그인하세요.');
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
})
.then(function(buf) {
loadFromFile(new File([buf], 'sheet.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}));
})
.catch(function(e) { showError('불러오기 실패: ' + e.message); });
}
window.saveFile = function() {
if (!saveUrl) { alert('저장할 파일 경로가 없습니다.'); return; }
if (isNewFile) {
var name = prompt('저장할 파일 이름을 입력하세요', '새 시트.xlsx');
if (!name) return;
if (!/\.xlsx$/i.test(name)) name += '.xlsx';
saveUrl = '/api/files/' + name;
}
var btn = document.getElementById('save-btn');
btn.disabled = true; btn.textContent = '저장 중…';
var relPath = saveUrl.replace(/^\/api\/files\//, '');
try {
var sheets = luckysheet.getAllSheets();
var wb = XLSX.utils.book_new();
sheets.forEach(function(sheet) {
var aoa = (sheet.data || []).map(function(row) {
return (row || []).map(function(cell) { return cell ? cell.v : null; });
});
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet1');
});
var buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
fetch('/api/files/' + relPath, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/octet-stream' },
body: new Uint8Array(buf),
}).then(function(r) {
btn.disabled = false;
if (r.ok) {
btn.textContent = '✅ 저장됨'; setTimeout(function() { btn.textContent = '💾 저장'; }, 2000);
if (isNewFile) {
isNewFile = false;
title = relPath.split('/').pop();
document.getElementById('title').textContent = title;
document.title = title;
}
try { if (parent && parent !== window && typeof parent.loadExcelFiles === 'function') parent.loadExcelFiles(); } catch (e) {}
}
else { btn.textContent = '❌ 실패'; setTimeout(function() { btn.textContent = '💾 저장'; }, 2000); }
}).catch(function(e) {
btn.disabled = false; btn.textContent = '❌ 오류';
setTimeout(function() { btn.textContent = '💾 저장'; }, 2000);
});
} catch(e) {
btn.disabled = false; btn.textContent = '❌ 오류';
setTimeout(function() { btn.textContent = '💾 저장'; }, 2000);
alert('저장 오류: ' + e.message);
}
};
})();
</script>
</body>
</html>