초기 커밋 - EV AS 관리 시스템
This commit is contained in:
72
frontend/static/js/api.js
Normal file
72
frontend/static/js/api.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const API = (() => {
|
||||
const BASE = '/api';
|
||||
|
||||
function token() { return localStorage.getItem('ev_token') || ''; }
|
||||
|
||||
async function req(method, path, body = null, isForm = false) {
|
||||
const headers = {};
|
||||
if (token()) headers['Authorization'] = 'Bearer ' + token();
|
||||
let fetchBody = null;
|
||||
if (body) {
|
||||
if (isForm) {
|
||||
fetchBody = body;
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
fetchBody = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
|
||||
if (res.status === 401) { Auth.logout(); return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
|
||||
throw new Error(err.detail || '오류');
|
||||
}
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('spreadsheet') || ct.includes('octet') || ct.includes('excel')) {
|
||||
return res.blob();
|
||||
}
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
// 엑셀 다운로드 전용 함수 — 인증 토큰 포함, 에러 처리 강화
|
||||
async function download(path, filename) {
|
||||
try {
|
||||
const headers = {};
|
||||
if (token()) headers['Authorization'] = 'Bearer ' + token();
|
||||
|
||||
const res = await fetch(BASE + path, { method: 'GET', headers });
|
||||
|
||||
if (res.status === 401) { Auth.logout(); return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '다운로드 오류' }));
|
||||
throw new Error(err.detail || '다운로드 오류');
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
alert('데이터가 없어 엑셀 파일을 생성할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch(e) {
|
||||
alert('엑셀 다운로드 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: (path) => req('GET', path),
|
||||
post: (path, body) => req('POST', path, body, body instanceof FormData),
|
||||
put: (path, body) => req('PUT', path, body, body instanceof FormData),
|
||||
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
|
||||
delete: (path) => req('DELETE', path),
|
||||
download,
|
||||
};
|
||||
})();
|
||||
42
frontend/static/js/api.js.bak
Normal file
42
frontend/static/js/api.js.bak
Normal file
@@ -0,0 +1,42 @@
|
||||
const API = (() => {
|
||||
const BASE = '/api';
|
||||
|
||||
function token() { return localStorage.getItem('ev_token') || ''; }
|
||||
|
||||
async function req(method, path, body = null, isForm = false) {
|
||||
const headers = {};
|
||||
if (token()) headers['Authorization'] = 'Bearer ' + token();
|
||||
let fetchBody = null;
|
||||
if (body) {
|
||||
if (isForm) {
|
||||
fetchBody = body; // FormData
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
fetchBody = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
|
||||
if (res.status === 401) { Auth.logout(); return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
|
||||
throw new Error(err.detail || '오류');
|
||||
}
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('spreadsheet') || ct.includes('octet')) return res.blob();
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
return {
|
||||
get: (path) => req('GET', path),
|
||||
post: (path, body) => req('POST', path, body, body instanceof FormData),
|
||||
put: (path, body) => req('PUT', path, body, body instanceof FormData),
|
||||
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
|
||||
delete: (path) => req('DELETE', path),
|
||||
download: async (path, filename) => {
|
||||
const blob = await req('GET', path);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = filename;
|
||||
a.click(); URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
})();
|
||||
63
frontend/static/js/auth.js
Normal file
63
frontend/static/js/auth.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const Auth = (() => {
|
||||
const KEY_TOKEN = 'ev_token';
|
||||
const KEY_ROLE = 'ev_role';
|
||||
const KEY_NAME = 'ev_name';
|
||||
const KEY_ID = 'ev_uid';
|
||||
|
||||
function save(token, role, name, id) {
|
||||
localStorage.setItem(KEY_TOKEN, token);
|
||||
localStorage.setItem(KEY_ROLE, role);
|
||||
localStorage.setItem(KEY_NAME, name);
|
||||
localStorage.setItem(KEY_ID, id);
|
||||
}
|
||||
function token() { return localStorage.getItem(KEY_TOKEN); }
|
||||
function role() { return localStorage.getItem(KEY_ROLE); }
|
||||
function name() { return localStorage.getItem(KEY_NAME); }
|
||||
function uid() { return localStorage.getItem(KEY_ID); }
|
||||
|
||||
function logout() {
|
||||
[KEY_TOKEN, KEY_ROLE, KEY_NAME, KEY_ID].forEach(k => localStorage.removeItem(k));
|
||||
location.href = '/pages/login.html';
|
||||
}
|
||||
|
||||
function require(allowedRoles) {
|
||||
if (!token()) { logout(); return false; }
|
||||
if (allowedRoles && !allowedRoles.includes(role())) {
|
||||
alert('접근 권한이 없습니다.');
|
||||
history.back();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderNav(el) {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<span class="nav-user">
|
||||
<span>${name()} <small style="color:var(--accent)">[${role()}]</small></span>
|
||||
<a onclick="Auth.logout()">로그아웃</a>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
pending_approval: '승인대기', pending: '접수', in_progress: '처리중',
|
||||
done: '완료', waiting: '부품대기', revisit: '재방문',
|
||||
registered: '등록', reviewing: '검토중', developing: '개발중',
|
||||
deployed: '배포완료',
|
||||
};
|
||||
return `<span class="badge s-${status}">${map[status] || status}</span>`;
|
||||
}
|
||||
|
||||
function costStatusBadge(s) {
|
||||
const map = { pending:'미처리', billed:'청구완료', waived:'면제', settled:'정산완료' };
|
||||
return `<span class="badge s-cost-${s}">${map[s] || s}</span>`;
|
||||
}
|
||||
|
||||
function fmtDt(dt) {
|
||||
if (!dt) return '-';
|
||||
return new Date(dt).toLocaleString('ko-KR', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
return { save, token, role, name, uid, logout, require, renderNav, statusBadge, costStatusBadge, fmtDt };
|
||||
})();
|
||||
174
frontend/static/js/imageCompress.js
Executable file
174
frontend/static/js/imageCompress.js
Executable file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* imageCompress.js
|
||||
* 업로드 전 브라우저에서 이미지를 리사이즈 + JPEG 압축
|
||||
* Canvas API 사용 — 외부 라이브러리 불필요
|
||||
*/
|
||||
const ImageCompressor = (() => {
|
||||
|
||||
// 서버에서 가져온 설정 캐시
|
||||
let _cfg = null;
|
||||
|
||||
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */
|
||||
async function loadConfig() {
|
||||
if (_cfg) return _cfg;
|
||||
try {
|
||||
const res = await fetch('/api/settings/public');
|
||||
_cfg = await res.json();
|
||||
} catch {
|
||||
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
|
||||
}
|
||||
return _cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* File 객체 하나를 압축해서 새 File 로 반환
|
||||
* @param {File} file - 원본 이미지 파일
|
||||
* @param {Object} cfg - { image_compress_enabled, image_max_px, image_quality }
|
||||
* @returns {Promise<File>}
|
||||
*/
|
||||
function compressOne(file, cfg) {
|
||||
return new Promise((resolve) => {
|
||||
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
|
||||
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
|
||||
return resolve(file);
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxPx = cfg.image_max_px;
|
||||
let { width, height } = img;
|
||||
|
||||
// 긴 변이 maxPx 초과하면 비율 유지하며 축소
|
||||
if (width > maxPx || height > maxPx) {
|
||||
if (width >= height) {
|
||||
height = Math.round((height / width) * maxPx);
|
||||
width = maxPx;
|
||||
} else {
|
||||
width = Math.round((width / height) * maxPx);
|
||||
height = maxPx;
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const compressed = new File(
|
||||
[blob],
|
||||
file.name.replace(/\.[^.]+$/, '') + '.jpg',
|
||||
{ type: 'image/jpeg', lastModified: Date.now() }
|
||||
);
|
||||
resolve(compressed);
|
||||
},
|
||||
'image/jpeg',
|
||||
cfg.image_quality / 100 // 0~1 범위
|
||||
);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FileList / File[] 전체를 압축
|
||||
* @param {FileList|File[]} files
|
||||
* @returns {Promise<File[]>}
|
||||
*/
|
||||
async function compressAll(files) {
|
||||
const cfg = await loadConfig();
|
||||
return Promise.all(Array.from(files).map(f => compressOne(f, cfg)));
|
||||
}
|
||||
|
||||
/**
|
||||
* input[type=file] + 미리보기 div 에 압축 프리뷰 설정
|
||||
* @param {string} inputId - file input 요소 id
|
||||
* @param {string} previewId - 미리보기 컨테이너 id
|
||||
* @param {string} infoId - 용량 정보 표시 span id (선택)
|
||||
*/
|
||||
function setupPreview(inputId, previewId, infoId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
const info = infoId ? document.getElementById(infoId) : null;
|
||||
|
||||
if (!input || !preview) return;
|
||||
|
||||
input.addEventListener('change', async function () {
|
||||
preview.innerHTML = '';
|
||||
if (info) info.textContent = '압축 중...';
|
||||
|
||||
const cfg = await loadConfig();
|
||||
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0);
|
||||
|
||||
const compressed = await compressAll(this.files);
|
||||
const compBytes = compressed.reduce((s, f) => s + f.size, 0);
|
||||
|
||||
// DataTransfer 로 input.files 교체 (압축된 파일로)
|
||||
const dt = new DataTransfer();
|
||||
compressed.forEach(f => dt.items.add(f));
|
||||
this.files = dt.files;
|
||||
|
||||
// 미리보기 렌더링
|
||||
compressed.forEach((f, i) => {
|
||||
const url = URL.createObjectURL(f);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;display:inline-block;';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;';
|
||||
img.onload = () => URL.revokeObjectURL(url);
|
||||
|
||||
// 삭제 버튼
|
||||
const del = document.createElement('button');
|
||||
del.textContent = '×';
|
||||
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;';
|
||||
del.onclick = () => {
|
||||
// 해당 파일 제거
|
||||
const cur = Array.from(input.files);
|
||||
cur.splice(i, 1);
|
||||
const dt2 = new DataTransfer();
|
||||
cur.forEach(f2 => dt2.items.add(f2));
|
||||
input.files = dt2.files;
|
||||
wrap.remove();
|
||||
updateInfo(input, info);
|
||||
};
|
||||
|
||||
wrap.appendChild(img);
|
||||
wrap.appendChild(del);
|
||||
preview.appendChild(wrap);
|
||||
});
|
||||
|
||||
// 용량 정보
|
||||
if (info) {
|
||||
const pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
|
||||
if (cfg.image_compress_enabled && pct > 0) {
|
||||
info.textContent = `${compressed.length}장 | ${fmt(origBytes)} → ${fmt(compBytes)} (${pct}% 절약) | 최대 ${cfg.image_max_px}px / 품질 ${cfg.image_quality}%`;
|
||||
info.style.color = '#00875A';
|
||||
} else {
|
||||
info.textContent = `${compressed.length}장 | ${fmt(compBytes)} (압축 비활성)`;
|
||||
info.style.color = '#8899BB';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateInfo(input, info) {
|
||||
if (!info) return;
|
||||
const bytes = Array.from(input.files).reduce((s, f) => s + f.size, 0);
|
||||
info.textContent = `${input.files.length}장 | ${fmt(bytes)}`;
|
||||
}
|
||||
|
||||
function fmt(bytes) {
|
||||
return bytes < 1024 * 1024
|
||||
? (bytes / 1024).toFixed(0) + ' KB'
|
||||
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
return { compressAll, setupPreview, loadConfig };
|
||||
})();
|
||||
Reference in New Issue
Block a user