Files
ev-charger-as/frontend/static/pages/admin/settings.html
2026-04-18 06:18:58 +09:00

270 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>시스템 설정</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.range-wrap{display:flex;align-items:center;gap:12px;}
.range-wrap input[type=range]{flex:1;accent-color:var(--accent);}
.range-val{min-width:48px;text-align:center;font-weight:700;color:var(--navy);font-size:14px;}
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--gray2);}
.toggle-row:last-child{border-bottom:none;}
.toggle-label h4{font-size:14px;font-weight:700;color:var(--navy);}
.toggle-label p{font-size:12px;color:var(--gray4);margin-top:2px;}
.toggle{position:relative;width:44px;height:24px;flex-shrink:0;}
.toggle input{opacity:0;width:0;height:0;}
.toggle-slider{position:absolute;inset:0;background:var(--gray3);border-radius:24px;cursor:pointer;transition:.2s;}
.toggle-slider::before{content:'';position:absolute;width:18px;height:18px;left:3px;bottom:3px;background:white;border-radius:50%;transition:.2s;}
.toggle input:checked + .toggle-slider{background:var(--accent);}
.toggle input:checked + .toggle-slider::before{transform:translateX(20px);}
.preset-btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;}
.preset-btn{padding:5px 14px;border-radius:6px;border:1px solid var(--gray3);background:white;font-size:12px;cursor:pointer;transition:all .15s;font-family:'Noto Sans KR',sans-serif;}
.preset-btn:hover{border-color:var(--accent);color:var(--accent);}
.preset-btn.active{border-color:var(--accent);background:#E3EDFF;color:var(--blue);font-weight:700;}
</style>
</head>
<body>
<nav class="nav"><span class="nav-brand">⚡ EV AS 관리 — 관리자</span><div id="navUser"></div></nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">AS 관리</div>
<a href="/pages/admin/dashboard.html">📊 대시보드</a>
<a href="/pages/admin/reports.html">📋 신고 목록</a>
<a href="/pages/admin/costs.html">💰 출장비 관리</a>
<div class="sidebar-section">시스템</div>
<a href="/pages/admin/improvements.html">🔧 개선항목</a>
<a href="/pages/admin/chargers.html">⚡ 충전기 관리</a>
<a href="/pages/admin/charger-types.html">🏷 충전기 종류</a>
<a href="/pages/admin/qr.html">📷 QR 생성</a>
<a href="/pages/admin/accounts.html">👥 계정 관리</a>
<a href="/pages/admin/settings.html" class="active">⚙️ 설정</a>
</div>
<div class="main">
<h2 style="font-size:18px;font-weight:700;color:var(--navy);margin-bottom:18px">시스템 설정</h2>
<!-- 신고 공개 정책 -->
<div class="card" style="max-width:560px">
<div class="card-title">📋 신고 공개 정책</div>
<div class="alert alert-info" style="margin-bottom:14px">
신고 접수 시 정비사에게 공개하는 방식을 선택합니다.
</div>
<div class="form-group">
<label class="check-item" style="display:flex;gap:12px;padding:14px;margin-bottom:8px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-immediate">
<input type="radio" name="policy" value="immediate" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
<div>
<div style="font-weight:700">⚡ 즉시 공개 (기본 권장)</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">신고 접수 즉시 정비사 목록에 표시됩니다. 빠른 대응이 가능합니다.</div>
</div>
</label>
<label class="check-item" style="display:flex;gap:12px;padding:14px;cursor:pointer;border-radius:8px;border:2px solid var(--gray3)" id="lbl-approval">
<input type="radio" name="policy" value="admin_approval" style="accent-color:var(--accent);width:auto;margin-top:2px;flex-shrink:0">
<div>
<div style="font-weight:700">🔒 관리자 승인 후 공개</div>
<div style="font-size:12px;color:var(--gray4);margin-top:3px">관리자가 신고를 확인·승인한 후 정비사에게 공개됩니다. 중복·허위 신고 방지에 유리합니다.</div>
</div>
</label>
</div>
<div id="saveOk" class="alert alert-success" style="display:none">설정이 저장되었습니다.</div>
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:4px">전체 설정 저장</button>
</div>
<!-- 이미지 압축 설정 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🖼️ 사진 업로드 압축 설정</div>
<div class="alert alert-info" style="margin-bottom:16px">
신고·조치 사진 업로드 시 브라우저에서 자동으로 압축합니다.<br>
서버 저장 용량 절약 및 업로드 속도를 개선합니다.
</div>
<!-- 압축 ON/OFF -->
<div class="toggle-row">
<div class="toggle-label">
<h4>📸 자동 압축 사용</h4>
<p>업로드 전 이미지를 자동으로 리사이즈·압축합니다</p>
</div>
<label class="toggle">
<input type="checkbox" id="compressEnabled">
<span class="toggle-slider"></span>
</label>
</div>
<!-- 최대 해상도 -->
<div class="toggle-row" style="flex-direction:column;align-items:flex-start;gap:12px;">
<div class="toggle-label">
<h4>📐 최대 해상도 (긴 변 기준)</h4>
<p>이 픽셀 수를 초과하면 비율 유지하며 축소합니다</p>
</div>
<div style="width:100%">
<div class="preset-btns" id="presetBtns">
<button class="preset-btn" onclick="setMaxPx(640)" data-px="640" >640px <small style="color:var(--gray4)">저화질</small></button>
<button class="preset-btn" onclick="setMaxPx(1024)" data-px="1024">1024px <small style="color:var(--gray4)">권장 ★</small></button>
<button class="preset-btn" onclick="setMaxPx(1920)" data-px="1920">1920px <small style="color:var(--gray4)">FHD</small></button>
<button class="preset-btn" onclick="setMaxPx(2560)" data-px="2560">2560px <small style="color:var(--gray4)">QHD</small></button>
<button class="preset-btn" onclick="setMaxPx(3840)" data-px="3840">3840px <small style="color:var(--gray4)">4K</small></button>
</div>
<div class="range-wrap" style="margin-top:10px;">
<input type="range" id="maxPx" min="320" max="3840" step="64"
value="1024" oninput="syncMaxPx(this.value)">
<span class="range-val" id="maxPxVal">1024px</span>
</div>
</div>
</div>
<!-- JPEG 품질 -->
<div class="toggle-row" style="flex-direction:column;align-items:flex-start;gap:10px;border-bottom:none;">
<div class="toggle-label">
<h4>🎨 JPEG 압축 품질</h4>
<p>높을수록 화질 좋고 용량 큼 / 낮을수록 용량 작고 화질 저하</p>
</div>
<div style="width:100%">
<div class="preset-btns" id="qualityPresets">
<button class="preset-btn" onclick="setQuality(60)" data-q="60" >60% <small style="color:var(--gray4)">압축 최대</small></button>
<button class="preset-btn" onclick="setQuality(75)" data-q="75" >75% <small style="color:var(--gray4)">균형</small></button>
<button class="preset-btn" onclick="setQuality(85)" data-q="85" >85% <small style="color:var(--gray4)">권장 ★</small></button>
<button class="preset-btn" onclick="setQuality(95)" data-q="95" >95% <small style="color:var(--gray4)">고화질</small></button>
</div>
<div class="range-wrap" style="margin-top:10px;">
<input type="range" id="quality" min="30" max="100" step="5"
value="85" oninput="syncQuality(this.value)">
<span class="range-val" id="qualityVal">85%</span>
</div>
</div>
</div>
<!-- 현재 효과 미리보기 텍스트 -->
<div id="effectDesc" style="background:var(--gray1);border-radius:8px;padding:12px 14px;font-size:13px;color:var(--text);margin-top:4px;line-height:1.7;">
</div>
<div id="imgSaveOk" class="alert alert-success" style="display:none;margin-top:12px">이미지 설정이 저장되었습니다.</div>
<button class="btn btn-primary" onclick="saveAll()" style="margin-top:12px">전체 설정 저장</button>
</div>
<!-- 비밀번호 변경 -->
<div class="card" style="max-width:560px;margin-top:20px">
<div class="card-title">🔑 내 비밀번호 변경</div>
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="curPw"></div>
<div class="form-group"><label>새 비밀번호</label><input type="password" id="newPw"></div>
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="newPw2"></div>
<div id="pwErr" class="alert alert-danger" style="display:none"></div>
<div id="pwOk" class="alert alert-success" style="display:none">비밀번호가 변경되었습니다.</div>
<button class="btn btn-outline" onclick="changePw()">비밀번호 변경</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['admin']); Auth.renderNav(document.getElementById('navUser'));
function syncMaxPx(v) {
document.getElementById('maxPxVal').textContent = v + 'px';
updatePresetBtns('presetBtns', 'data-px', v);
updateEffect();
}
function syncQuality(v) {
document.getElementById('qualityVal').textContent = v + '%';
updatePresetBtns('qualityPresets', 'data-q', v);
updateEffect();
}
function setMaxPx(v) {
document.getElementById('maxPx').value = v;
syncMaxPx(v);
}
function setQuality(v) {
document.getElementById('quality').value = v;
syncQuality(v);
}
function updatePresetBtns(containerId, attr, val) {
document.querySelectorAll(`#${containerId} .preset-btn`).forEach(b => {
b.classList.toggle('active', b.getAttribute(attr) == val);
});
}
function updateEffect() {
const enabled = document.getElementById('compressEnabled').checked;
const px = parseInt(document.getElementById('maxPx').value);
const q = parseInt(document.getElementById('quality').value);
const el = document.getElementById('effectDesc');
if (!enabled) {
el.innerHTML = '⚪ 압축 비활성 — 원본 파일 그대로 업로드됩니다.';
el.style.color = 'var(--gray4)';
return;
}
// 대략적 용량 절약 예측 (12MP 스마트폰 사진 기준 ~8MB)
const areaRatio = Math.min(1, (px * px) / (4032 * 3024));
const estMB = (8 * areaRatio * (q / 100) * 0.6).toFixed(1);
el.innerHTML = `✅ <strong>${px}px / JPEG ${q}%</strong> 로 압축<br>
스마트폰 고화질 사진(약 8MB) 기준 → 업로드 약 <strong>${estMB}MB</strong> 예상<br>
<span style="color:var(--green);font-size:12px">업로드 속도 향상 + 서버 용량 절약</span>`;
el.style.color = 'var(--text)';
}
async function load() {
const s = await API.get('/settings');
const policy = s.report_visibility_policy || 'immediate';
document.querySelector(`input[value="${policy}"]`).checked = true;
updateLabels();
const enabled = s.image_compress_enabled !== 'false';
document.getElementById('compressEnabled').checked = enabled;
const px = parseInt(s.image_max_px || '1024');
document.getElementById('maxPx').value = px;
syncMaxPx(px);
const q = parseInt(s.image_quality || '85');
document.getElementById('quality').value = q;
syncQuality(q);
updateEffect();
}
function updateLabels() {
document.querySelectorAll('input[name="policy"]').forEach(r => {
const lbl = r.closest('label');
lbl.style.borderColor = r.checked ? 'var(--accent)' : 'var(--gray3)';
lbl.style.background = r.checked ? '#E3EDFF' : 'white';
});
}
document.querySelectorAll('input[name="policy"]').forEach(r => r.addEventListener('change', updateLabels));
document.getElementById('compressEnabled').addEventListener('change', updateEffect);
async function saveAll() {
const fd = new FormData();
fd.append('report_visibility_policy', document.querySelector('input[name="policy"]:checked').value);
fd.append('image_compress_enabled', document.getElementById('compressEnabled').checked ? 'true' : 'false');
fd.append('image_max_px', document.getElementById('maxPx').value);
fd.append('image_quality', document.getElementById('quality').value);
await API.put('/settings', fd);
const ok = document.getElementById('saveOk');
ok.style.display = 'block';
setTimeout(() => ok.style.display = 'none', 2500);
const ok2 = document.getElementById('imgSaveOk');
ok2.style.display = 'block';
setTimeout(() => ok2.style.display = 'none', 2500);
}
async function changePw() {
const cur = document.getElementById('curPw').value;
const nw = document.getElementById('newPw').value;
const nw2 = document.getElementById('newPw2').value;
const errEl = document.getElementById('pwErr');
errEl.style.display = 'none';
if (!cur || !nw) { errEl.textContent = '현재·새 비밀번호를 입력하세요.'; errEl.style.display = 'block'; return; }
if (nw !== nw2) { errEl.textContent = '새 비밀번호가 일치하지 않습니다.'; errEl.style.display = 'block'; return; }
const fd = new FormData(); fd.append('current_password', cur); fd.append('new_password', nw);
try {
await API.patch('/accounts/me/password', fd);
document.getElementById('pwOk').style.display = 'block';
['curPw','newPw','newPw2'].forEach(id => document.getElementById(id).value = '');
setTimeout(() => document.getElementById('pwOk').style.display = 'none', 2500);
} catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; }
}
load();
</script>
</body>
</html>