237 lines
17 KiB
PHP
237 lines
17 KiB
PHP
<?php
|
||
// Justice — Test Case / Bug List tracker (QA verification + admin)
|
||
$DATA_DIR = __DIR__ . '/data';
|
||
$BUGS = $DATA_DIR . '/bugs.json';
|
||
$VERIFY = $DATA_DIR . '/verify.json';
|
||
$UPLOADS = __DIR__ . '/uploads';
|
||
$ADMIN_PASS = 'justice2026'; // รหัสโหมดแอดมิน
|
||
|
||
function tc_load($f){ return is_file($f) ? (json_decode(file_get_contents($f), true) ?: []) : []; }
|
||
|
||
// ---------- API: บันทึกผลตรวจ (POST) ----------
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (($_POST['api'] ?? '') === 'save')) {
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
if (($_POST['pass'] ?? '') !== $ADMIN_PASS) { http_response_code(403); echo json_encode(['ok'=>false,'error'=>'รหัสแอดมินไม่ถูกต้อง'], JSON_UNESCAPED_UNICODE); exit; }
|
||
$id = preg_replace('/[^0-9]/', '', (string)($_POST['id'] ?? ''));
|
||
if ($id === '') { echo json_encode(['ok'=>false,'error'=>'no id'], JSON_UNESCAPED_UNICODE); exit; }
|
||
if (!is_dir($DATA_DIR)) @mkdir($DATA_DIR, 0775, true);
|
||
$verify = tc_load($VERIFY);
|
||
$rec = $verify[$id] ?? ['result'=>'none','comment'=>'','images'=>[]];
|
||
if (!isset($rec['images']) || !is_array($rec['images'])) $rec['images'] = [];
|
||
$res = $_POST['result'] ?? '';
|
||
if (in_array($res, ['pass','fail','none'], true)) $rec['result'] = $res;
|
||
if (isset($_POST['comment'])) $rec['comment'] = mb_substr(trim((string)$_POST['comment']), 0, 2000);
|
||
// ลบรูป
|
||
if (($_POST['removeImage'] ?? '') !== '') {
|
||
$rm = basename((string)$_POST['removeImage']);
|
||
$rec['images'] = array_values(array_filter($rec['images'], function($x) use ($rm){ return $x !== $rm; }));
|
||
@unlink($UPLOADS . '/' . $rm);
|
||
}
|
||
// อัปโหลดรูป
|
||
if (!empty($_FILES['image']['tmp_name']) && is_uploaded_file($_FILES['image']['tmp_name'])) {
|
||
$ext = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION));
|
||
if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true) && $_FILES['image']['size'] < 8*1024*1024) {
|
||
if (!is_dir($UPLOADS)) @mkdir($UPLOADS, 0775, true);
|
||
$fn = $id . '_' . time() . '_' . bin2hex(random_bytes(3)) . '.' . $ext;
|
||
if (move_uploaded_file($_FILES['image']['tmp_name'], $UPLOADS . '/' . $fn)) $rec['images'][] = $fn;
|
||
}
|
||
}
|
||
$rec['at'] = date('Y-m-d H:i');
|
||
$verify[$id] = $rec;
|
||
file_put_contents($VERIFY, json_encode($verify, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
|
||
echo json_encode(['ok'=>true,'rec'=>$rec], JSON_UNESCAPED_UNICODE); exit;
|
||
}
|
||
|
||
$bugs = tc_load($BUGS);
|
||
$verify = tc_load($VERIFY);
|
||
?><!DOCTYPE html>
|
||
<html lang="th"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Justice — Test Case / Bug List</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{--bg:#0a0f1e;--panel:#121a30;--panel2:#0e1526;--line:#22304f;--txt:#dce6ff;--mut:#8aa0c8;--cyan:#22d3ee;--green:#22c55e;--red:#f87171;--amber:#fbbf24;}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:Kanit,system-ui,sans-serif;background:radial-gradient(circle at 50% -10%,#13213f,#070b16 70%);color:var(--txt);min-height:100vh;padding:24px 16px 80px}
|
||
.wrap{max-width:1240px;margin:0 auto}
|
||
h1{font-weight:600;font-size:26px;letter-spacing:.5px;margin-bottom:4px}
|
||
h1 .sub{color:var(--mut);font-size:14px;font-weight:300;display:block;margin-top:4px}
|
||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px;margin:18px 0}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
|
||
.card .v{font-size:26px;font-weight:600}.card .l{font-size:12px;color:var(--mut);margin-top:2px}
|
||
.card.ok .v{color:var(--green)}.card.open .v{color:var(--amber)}.card.pass .v{color:var(--cyan)}.card.fail .v{color:var(--red)}
|
||
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0}
|
||
.bar input[type=text],.bar input[type=password]{background:var(--panel2);border:1px solid var(--line);border-radius:10px;color:var(--txt);padding:9px 14px;font-family:inherit;font-size:14px}
|
||
#q{flex:1;min-width:180px}
|
||
.bar input:focus{outline:none;border-color:var(--cyan)}
|
||
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;color:var(--mut);padding:7px 15px;font-size:13px;cursor:pointer;font-family:inherit}
|
||
.chip:hover{border-color:var(--cyan);color:var(--txt)}
|
||
.chip.on{background:var(--cyan);border-color:var(--cyan);color:#04121a;font-weight:600}
|
||
.adminbox{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||
.adminbox .ok{color:var(--green);font-size:13px}
|
||
.lockhint{font-size:13px;color:var(--amber);white-space:nowrap}
|
||
.qabadge{cursor:pointer}
|
||
.qabadge:hover{filter:brightness(1.15);text-decoration:underline}
|
||
@keyframes apFlash{0%,100%{box-shadow:none}30%,70%{box-shadow:0 0 0 3px rgba(248,113,113,.55)}}
|
||
#adminpass.flash{animation:apFlash .7s ease}
|
||
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
||
th{text-align:left;font-size:12px;color:var(--mut);font-weight:500;padding:11px 12px;background:var(--panel2);border-bottom:1px solid var(--line)}
|
||
td{padding:11px 12px;border-bottom:1px solid var(--line);font-size:13.5px;vertical-align:top;line-height:1.45}
|
||
tr:last-child td{border-bottom:none}
|
||
.n{color:var(--mut);font-size:12px}.nm{font-weight:500}.dt{color:var(--mut);font-size:12.5px;margin-top:3px}.nt{color:#7fd4a8;font-size:12px;margin-top:4px}
|
||
.b{display:inline-block;border-radius:6px;padding:3px 9px;font-size:11.5px;font-weight:500;white-space:nowrap}
|
||
.s-open{background:rgba(251,191,36,.16);color:var(--amber)}.s-fix{background:rgba(34,197,94,.16);color:var(--green)}.s-other{background:rgba(138,160,200,.16);color:var(--mut)}
|
||
.p-crit{background:rgba(248,113,113,.16);color:var(--red)}.p-high{background:rgba(251,146,60,.16);color:#fb923c}.p-mid{background:rgba(251,191,36,.14);color:var(--amber)}.p-low{background:rgba(138,160,200,.14);color:var(--mut)}
|
||
.qa{min-width:240px}
|
||
.r-pass{background:rgba(34,211,238,.16);color:var(--cyan)}.r-fail{background:rgba(248,113,113,.18);color:var(--red)}.r-none{background:rgba(138,160,200,.14);color:var(--mut)}
|
||
.qcom{color:#cfe0ff;font-size:12.5px;margin-top:5px;white-space:pre-wrap}
|
||
.qat{color:var(--mut);font-size:11px;margin-top:3px}
|
||
.thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
|
||
.thumbs a{display:block;width:54px;height:54px;border-radius:7px;border:1px solid var(--line);background-size:cover;background-position:center;position:relative}
|
||
.thumbs .rmimg{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:var(--red);color:#fff;font-size:12px;line-height:18px;text-align:center;cursor:pointer;border:none}
|
||
.edit{margin-top:8px;display:none;border-top:1px dashed var(--line);padding-top:8px}
|
||
.edit.show{display:block}
|
||
.edit .rb{display:inline-flex;gap:6px;margin-bottom:6px}
|
||
.edit .rb button{border:1px solid var(--line);background:var(--panel2);color:var(--mut);border-radius:8px;padding:5px 11px;font-size:12.5px;cursor:pointer;font-family:inherit}
|
||
.edit .rb button.sel-pass{background:var(--cyan);color:#04121a;border-color:var(--cyan)}
|
||
.edit .rb button.sel-fail{background:var(--red);color:#fff;border-color:var(--red)}
|
||
.edit .rb button.sel-none{background:#33415c;color:#fff}
|
||
.edit textarea{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--txt);font-family:inherit;font-size:13px;padding:8px;resize:vertical;min-height:46px}
|
||
.edit .erow{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap}
|
||
.edit .save{background:var(--green);color:#042c14;border:none;border-radius:8px;padding:7px 16px;font-weight:600;cursor:pointer;font-family:inherit}
|
||
.edit .msg{font-size:12px}
|
||
.editbtn{margin-top:7px;background:none;border:1px dashed var(--line);color:var(--mut);border-radius:8px;padding:4px 11px;font-size:12px;cursor:pointer;font-family:inherit}
|
||
.foot{color:var(--mut);font-size:12px;margin-top:16px;text-align:center}
|
||
.hidden{display:none}
|
||
@media(max-width:760px){.hide-sm{display:none}td,th{padding:9px 7px}}
|
||
</style></head>
|
||
<body><div class="wrap">
|
||
<h1>Justice — Test Case / Bug List<span class="sub">รายการบัก + ผลตรวจ QA · srv1361159.hstgr.cloud/testcase</span></h1>
|
||
<div class="cards" id="cards"></div>
|
||
<div class="bar">
|
||
<input type="text" id="q" placeholder="ค้นหา… (ชื่อ / รายละเอียด / คำอธิบาย)">
|
||
<button class="chip on" data-f="all">ทั้งหมด</button>
|
||
<button class="chip" data-f="open">ยังเปิด</button>
|
||
<button class="chip" data-f="fixed">แก้แล้ว</button>
|
||
<button class="chip" data-f="pass">ผ่าน QA</button>
|
||
<button class="chip" data-f="fail">ยังไม่ผ่าน</button>
|
||
<button class="chip" data-f="untested">ยังไม่ตรวจ</button>
|
||
<span class="adminbox">
|
||
<span class="lockhint" id="lockhint">🔒 แก้ผลตรวจ — ใส่รหัส:</span>
|
||
<input type="password" id="adminpass" placeholder="รหัสแอดมิน" autocomplete="off">
|
||
<span id="adminok" class="ok hidden">โหมดแก้ไข ✓</span>
|
||
</span>
|
||
</div>
|
||
<table><thead><tr><th style="width:30px">#</th><th>ปัญหา</th><th style="width:78px">สำคัญ</th><th style="width:88px">Dev</th><th class="qa">ผลตรวจ QA</th></tr></thead>
|
||
<tbody id="tb"></tbody></table>
|
||
<div class="foot">Justice bug tracker · ผลตรวจบันทึกบนเซิร์ฟเวอร์ (แก้ไขได้เมื่อใส่รหัสแอดมิน) · <?php echo count($bugs); ?> รายการ</div>
|
||
</div>
|
||
<script>
|
||
var BUGS = <?php echo json_encode($bugs, JSON_UNESCAPED_UNICODE); ?>;
|
||
var VERIFY = <?php echo json_encode((object)$verify, JSON_UNESCAPED_UNICODE); ?>;
|
||
var filt="all",q="",adminPass="";
|
||
function esc(s){return (s||"").replace(/[&<>"]/g,function(c){return{"&":"&","<":"<",">":">","\"":"""}[c]})}
|
||
function sCls(s){return s.indexOf("แก้")>=0?"s-fix":s==="เปิด"?"s-open":"s-other"}
|
||
function pCls(p){return p.indexOf("วิกฤต")>=0?"p-crit":p.indexOf("สูง")>=0?"p-high":p.indexOf("กลาง")>=0?"p-mid":p?"p-low":""}
|
||
function vof(n){return VERIFY[n]||{result:"none",comment:"",images:[]}}
|
||
function rLabel(r){return r==="pass"?"✅ เช็คแล้ว (ผ่าน)":r==="fail"?"❌ ยังไม่ผ่าน":"⬜ ยังไม่ตรวจ"}
|
||
function rCls(r){return r==="pass"?"r-pass":r==="fail"?"r-fail":"r-none"}
|
||
function counts(){
|
||
var o=0,f=0,p=0,fa=0,un=0;
|
||
BUGS.forEach(function(b){if(b.status==="เปิด")o++;if(b.status.indexOf("แก้")>=0)f++;var v=vof(b.n);if(v.result==="pass")p++;else if(v.result==="fail")fa++;else un++;});
|
||
return {o:o,f:f,p:p,fa:fa,un:un};
|
||
}
|
||
function render(){
|
||
var c=counts();
|
||
document.getElementById("cards").innerHTML=
|
||
'<div class="card"><div class="v">'+BUGS.length+'</div><div class="l">ทั้งหมด</div></div>'+
|
||
'<div class="card open"><div class="v">'+c.o+'</div><div class="l">Dev: ยังเปิด</div></div>'+
|
||
'<div class="card ok"><div class="v">'+c.f+'</div><div class="l">Dev: แก้แล้ว</div></div>'+
|
||
'<div class="card pass"><div class="v">'+c.p+'</div><div class="l">QA: ผ่าน</div></div>'+
|
||
'<div class="card fail"><div class="v">'+c.fa+'</div><div class="l">QA: ยังไม่ผ่าน</div></div>'+
|
||
'<div class="card"><div class="v">'+c.un+'</div><div class="l">QA: ยังไม่ตรวจ</div></div>';
|
||
var list=BUGS.filter(function(b){
|
||
var v=vof(b.n);
|
||
if(filt==="open"&&b.status!=="เปิด")return false;
|
||
if(filt==="fixed"&&b.status.indexOf("แก้")<0)return false;
|
||
if(filt==="pass"&&v.result!=="pass")return false;
|
||
if(filt==="fail"&&v.result!=="fail")return false;
|
||
if(filt==="untested"&&v.result&&v.result!=="none")return false;
|
||
if(q){var h=(b.name+" "+b.detail+" "+b.note+" "+(v.comment||"")).toLowerCase();if(h.indexOf(q)<0)return false}
|
||
return true;});
|
||
document.getElementById("tb").innerHTML=list.map(rowHtml).join("")||'<tr><td colspan="5" style="text-align:center;color:var(--mut);padding:30px">ไม่พบรายการ</td></tr>';
|
||
}
|
||
function thumbsHtml(v,n,admin){
|
||
return (v.images||[]).map(function(im){
|
||
var rm=admin?'<button class="rmimg" data-rm="'+esc(im)+'" data-id="'+n+'" title="ลบรูป">×</button>':'';
|
||
return '<a href="uploads/'+encodeURIComponent(im)+'" target="_blank" style="background-image:url(uploads/'+encodeURIComponent(im)+')">'+rm+'</a>';
|
||
}).join("");
|
||
}
|
||
function rowHtml(b){
|
||
var v=vof(b.n),admin=!!adminPass;
|
||
var qa='<span class="b '+rCls(v.result)+' qabadge" data-qa="'+b.n+'" title="'+(admin?'คลิกเพื่อแก้ผลตรวจ':'ใส่รหัสแอดมินมุมขวาบนเพื่อแก้')+'">'+rLabel(v.result)+'</span>'+
|
||
(v.comment?'<div class="qcom">'+esc(v.comment)+'</div>':'')+
|
||
(v.at?'<div class="qat">อัปเดต '+esc(v.at)+'</div>':'')+
|
||
'<div class="thumbs">'+thumbsHtml(v,b.n,admin)+'</div>';
|
||
if(admin){
|
||
qa+='<button class="editbtn" data-edit="'+b.n+'">✎ แก้ผลตรวจ</button>'+
|
||
'<div class="edit" id="edit-'+b.n+'">'+
|
||
'<div class="rb" data-rbid="'+b.n+'">'+
|
||
'<button data-r="pass" class="'+(v.result==="pass"?"sel-pass":"")+'">ผ่าน</button>'+
|
||
'<button data-r="fail" class="'+(v.result==="fail"?"sel-fail":"")+'">ยังไม่ผ่าน</button>'+
|
||
'<button data-r="none" class="'+(v.result==="none"||!v.result?"sel-none":"")+'">ยังไม่ตรวจ</button>'+
|
||
'</div>'+
|
||
'<textarea id="com-'+b.n+'" placeholder="คำอธิบายผลตรวจ…">'+esc(v.comment||"")+'</textarea>'+
|
||
'<div class="erow"><input type="file" id="img-'+b.n+'" accept="image/*">'+
|
||
'<button class="save" data-save="'+b.n+'">บันทึก</button>'+
|
||
'<span class="msg" id="msg-'+b.n+'"></span></div>'+
|
||
'</div>';
|
||
}
|
||
return '<tr><td class="n">'+b.n+'</td><td><div class="nm">'+esc(b.name)+'</div>'+
|
||
(b.detail?'<div class="dt">'+esc(b.detail)+'</div>':'')+
|
||
(b.note?'<div class="nt">'+esc(b.note)+'</div>':'')+'</td>'+
|
||
'<td>'+(b.prio?'<span class="b '+pCls(b.prio)+'">'+esc(b.prio)+'</span>':'')+'</td>'+
|
||
'<td><span class="b '+sCls(b.status)+'">'+esc(b.status)+'</span></td>'+
|
||
'<td class="qa">'+qa+'</td></tr>';
|
||
}
|
||
// events (delegate)
|
||
function flashAdmin(){var a=document.getElementById("adminpass");a.classList.remove("flash");void a.offsetWidth;a.classList.add("flash");a.focus();}
|
||
document.getElementById("tb").addEventListener("click",function(e){
|
||
var t=e.target;
|
||
if(t.dataset.qa){ if(!adminPass){ flashAdmin(); } else { var ee=document.getElementById("edit-"+t.dataset.qa); if(ee) ee.classList.toggle("show"); } return; }
|
||
if(t.dataset.edit){var ed=document.getElementById("edit-"+t.dataset.edit);if(ed)ed.classList.toggle("show");return;}
|
||
if(t.dataset.r){var rb=t.parentNode;[].forEach.call(rb.children,function(x){x.className=""});t.className="sel-"+t.dataset.r;rb.dataset.sel=t.dataset.r;return;}
|
||
if(t.dataset.save){doSave(t.dataset.save);return;}
|
||
if(t.dataset.rm){doRemoveImg(t.dataset.id,t.dataset.rm);return;}
|
||
});
|
||
function curResult(n){var rb=document.querySelector('.rb[data-rbid="'+n+'"]');if(rb&&rb.dataset.sel)return rb.dataset.sel;var v=vof(n);return v.result||"none";}
|
||
function doSave(n){
|
||
var fd=new FormData();fd.append("api","save");fd.append("pass",adminPass);fd.append("id",n);
|
||
fd.append("result",curResult(n));fd.append("comment",document.getElementById("com-"+n).value);
|
||
var f=document.getElementById("img-"+n);if(f&&f.files&&f.files[0])fd.append("image",f.files[0]);
|
||
var msg=document.getElementById("msg-"+n);msg.textContent="กำลังบันทึก…";msg.style.color="var(--mut)";
|
||
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){
|
||
if(j.ok){VERIFY[n]=j.rec;msg.textContent="บันทึกแล้ว ✓";msg.style.color="var(--green)";setTimeout(render,500);}
|
||
else{msg.textContent=j.error||"ผิดพลาด";msg.style.color="var(--red)";}
|
||
}).catch(function(){msg.textContent="เชื่อมต่อไม่ได้";msg.style.color="var(--red)";});
|
||
}
|
||
function doRemoveImg(n,im){
|
||
if(!confirm("ลบรูปนี้?"))return;
|
||
var fd=new FormData();fd.append("api","save");fd.append("pass",adminPass);fd.append("id",n);fd.append("removeImage",im);
|
||
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){if(j.ok){VERIFY[n]=j.rec;render();}});
|
||
}
|
||
// admin pass
|
||
var ap=document.getElementById("adminpass");
|
||
ap.value=localStorage.getItem("tcAdminPass")||"";
|
||
function applyAdmin(){adminPass=ap.value.trim();localStorage.setItem("tcAdminPass",adminPass);
|
||
document.getElementById("adminok").classList.toggle("hidden",!adminPass);
|
||
document.getElementById("lockhint").classList.toggle("hidden",!!adminPass);render();}
|
||
ap.addEventListener("input",applyAdmin);applyAdmin();
|
||
document.getElementById("q").addEventListener("input",function(e){q=e.target.value.toLowerCase().trim();render()});
|
||
[].forEach.call(document.querySelectorAll(".chip"),function(b){b.addEventListener("click",function(){
|
||
[].forEach.call(document.querySelectorAll(".chip"),function(x){x.classList.remove("on")});
|
||
b.classList.add("on");filt=b.getAttribute("data-f");render();})});
|
||
render();
|
||
</script></body></html>
|