Files
justice/www/html/testcase/index.php
T
2026-06-25 07:33:35 +00:00

237 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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{"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[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>