0. 作品介紹
1. AI 提示詞
把這段指令貼給 AI,讓它幫你生成程式碼。
※使用前請務必閱讀本說明,並按照說明流程設置!
教學連結:https://canva.link/vkdqc5gf77oxg6k
教學連結:https://canva.link/vkdqc5gf77oxg6k
2. 資料表規則
請照下面規則建立分頁與欄位。
第1分頁: saved_results
A 欄: record_id
B 欄: timestamp
C 欄: student_id
D 欄: birthday_hash
E 欄: password_hash
F 欄: name
G 欄: class_name
H 欄: seat_no
I 欄: grade_stage
J 欄: group1_score
K 欄: group2_score
L 欄: group3_score
M 欄: total_score
N 欄: report_text
O 欄: input_json
P 欄: result_json
Q 欄: created_by_email
📄 解析結果(自動表格)
分頁:saved_results
| A 欄 | B 欄 | C 欄 | D 欄 | E 欄 | F 欄 | G 欄 | H 欄 | I 欄 | J 欄 | K 欄 | L 欄 | M 欄 | N 欄 | O 欄 | P 欄 | Q 欄 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| record_id | timestamp | student_id | birthday_hash | password_hash | name | class_name | seat_no | grade_stage | group1_score | group2_score | group3_score | total_score | report_text | input_json | result_json | created_by_email |
3. 開啟 Apps Script
從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 Code.gs。
const SHEET_NAME = 'saved_results';
const SECRET_SALT = '115_zhumiao_admission_score_tool_v2_change_before_real_use';
const HEADERS = [
'record_id',
'timestamp',
'student_id',
'birthday_hash',
'password_hash',
'name',
'class_name',
'seat_no',
'grade_stage',
'group1_score',
'group2_score',
'group3_score',
'total_score',
'report_text',
'input_json',
'result_json',
'created_by_email'
];
function doGet(e) {
const template = HtmlService.createTemplateFromFile('index');
template.webAppUrl = getWebAppUrl_();
return template.evaluate()
.setTitle('115學年度竹苗區免試入學積分小工具')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function getWebAppUrl_() {
try {
return ScriptApp.getService().getUrl() || '';
} catch (err) {
return '';
}
}
/**
* 第一次安裝或資料表異常時請執行這個函式。
* 它會建立 saved_results 工作表,並確認欄位數足夠,避免「那些欄超出邊界」錯誤。
*/
function setupDatabase() {
const sheet = getOrCreateDatabaseSheet_();
return '資料表已建立或確認完成:' + SHEET_NAME;
}
function getOrCreateDatabaseSheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
}
ensureSheetHasColumns_(sheet, HEADERS.length);
// 無論原本是否有不完整表頭,都統一修正為最新版欄位,避免舊版欄位不足造成寫入錯誤。
sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
sheet.setFrozenRows(1);
try {
sheet.autoResizeColumns(1, HEADERS.length);
} catch (err) {
// autoResize 不是必要功能,避免因權限或環境問題中斷主流程。
}
return sheet;
}
function ensureSheetHasColumns_(sheet, requiredColumns) {
const currentColumns = sheet.getMaxColumns();
if (currentColumns < requiredColumns) {
sheet.insertColumnsAfter(currentColumns, requiredColumns - currentColumns);
}
}
function saveResult(payload) {
payload = payload || {};
const studentId = cleanText_(payload.studentId);
const birthday = normalizeBirthday_(payload.birthday);
const password = String(payload.password || '');
if (!studentId) return { success: false, message: '請輸入學號。' };
if (!birthday) return { success: false, message: '請輸入生日。' };
if (password.length < 4) return { success: false, message: '查詢密碼至少需要 4 碼。' };
if (!payload.reportText) return { success: false, message: '尚未產生試算報告,請先計算後再儲存。' };
const sheet = getOrCreateDatabaseSheet_();
ensureSheetHasColumns_(sheet, HEADERS.length);
const now = new Date();
const recordId = Utilities.getUuid();
const row = [
recordId,
now,
studentId,
hashBirthday_(studentId, birthday),
hashPassword_(studentId, birthday, password),
cleanText_(payload.name),
cleanText_(payload.className),
cleanText_(payload.seatNo),
cleanText_(payload.gradeStage),
cleanText_(payload.group1Score),
cleanText_(payload.group2Score),
cleanText_(payload.group3Score),
cleanText_(payload.totalScore),
String(payload.reportText || ''),
JSON.stringify(payload.inputJson || {}),
JSON.stringify(payload.resultJson || {}),
getUserEmailSafe_()
];
const nextRow = Math.max(sheet.getLastRow() + 1, 2);
sheet.getRange(nextRow, 1, 1, row.length).setValues([row]);
return { success: true, message: '儲存成功', recordId: recordId };
}
function lookupResult(query) {
query = query || {};
const studentId = cleanText_(query.studentId);
const birthday = normalizeBirthday_(query.birthday);
const password = String(query.password || '');
if (!studentId) return { success: false, message: '請輸入學號。' };
if (!birthday) return { success: false, message: '請輸入生日。' };
if (!password) return { success: false, message: '請輸入個人查詢密碼。' };
const sheet = getOrCreateDatabaseSheet_();
const values = sheet.getDataRange().getValues();
if (values.length <= 1) return { success: false, message: '目前尚無已儲存的試算結果。' };
const birthdayHash = hashBirthday_(studentId, birthday);
const passwordHash = hashPassword_(studentId, birthday, password);
const headers = values[0].map(String);
// 從最後一筆往前找,所以若同一學生存了多次,會回傳最新一次。
for (let i = values.length - 1; i >= 1; i--) {
const row = values[i];
const obj = rowToObject_(headers, row);
if (
cleanText_(obj.student_id) === studentId &&
cleanText_(obj.birthday_hash) === birthdayHash &&
cleanText_(obj.password_hash) === passwordHash
) {
return {
success: true,
record: {
recordId: cleanText_(obj.record_id),
timestamp: formatDate_(obj.timestamp),
studentId: cleanText_(obj.student_id),
name: cleanText_(obj.name),
className: cleanText_(obj.class_name),
seatNo: cleanText_(obj.seat_no),
gradeStage: cleanText_(obj.grade_stage),
group1Score: cleanText_(obj.group1_score),
group2Score: cleanText_(obj.group2_score),
group3Score: cleanText_(obj.group3_score),
totalScore: cleanText_(obj.total_score),
reportText: String(obj.report_text || '')
}
};
}
}
return { success: false, message: '查無符合的試算結果,請確認學號、生日與查詢密碼是否正確。' };
}
/**
* 可用來測試後端是否能正常寫入與讀取。
* 在 Apps Script 執行 testSaveAndLookup_,如果看到 success: true 就代表後端與資料表正常。
*/
function testSaveAndLookup_() {
const payload = {
studentId: 'TEST001',
birthday: '2011-01-01',
password: '1234',
name: '測試學生',
className: '301',
seatNo: '01',
gradeStage: '國三下/會考前',
group1Score: '20',
group2Score: '35',
group3Score: '0',
totalScore: '55 分',
reportText: '這是一筆 Apps Script 後端測試資料。',
inputJson: { demo: true },
resultJson: { demo: true }
};
const save = saveResult(payload);
const lookup = lookupResult({ studentId: 'TEST001', birthday: '2011-01-01', password: '1234' });
return { save: save, lookup: lookup };
}
function rowToObject_(headers, row) {
const obj = {};
headers.forEach(function(header, index) {
obj[header] = row[index];
});
return obj;
}
function cleanText_(value) {
return String(value == null ? '' : value).trim();
}
function normalizeBirthday_(value) {
return String(value || '').replace(/[^0-9]/g, '');
}
function hashBirthday_(studentId, birthday) {
return sha256_(studentId + '|' + birthday + '|' + SECRET_SALT + '|birthday');
}
function hashPassword_(studentId, birthday, password) {
return sha256_(studentId + '|' + birthday + '|' + password + '|' + SECRET_SALT + '|password');
}
function sha256_(text) {
const bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, text, Utilities.Charset.UTF_8);
return bytes.map(function(b) {
const v = b < 0 ? b + 256 : b;
return ('0' + v.toString(16)).slice(-2);
}).join('');
}
function formatDate_(value) {
if (Object.prototype.toString.call(value) === '[object Date]') {
return Utilities.formatDate(value, 'Asia/Taipei', 'yyyy/MM/dd HH:mm:ss');
}
return String(value || '');
}
function getUserEmailSafe_() {
try {
return Session.getActiveUser().getEmail() || '';
} catch (err) {
return '';
}
} 貼到 Apps Script 的 Code.gs 檔案
5. 貼上前端(index.html)
把前端程式貼到 Apps Script 的 index.html。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>115學年度竹苗區免試入學積分小工具</title>
<style>
:root {
--bg: #f7f3ec;
--paper: #ffffff;
--ink: #2f2a26;
--muted: #766e66;
--coffee: #6f4e37;
--coffee-dark: #4f3625;
--coffee-soft: #efe3d6;
--blue: #2f6f9f;
--blue-soft: #eaf4fb;
--green: #2f8f5b;
--green-soft: #e9f6ef;
--orange-soft: #fff5e6;
--line: #e2d8cc;
--shadow: 0 12px 30px rgba(67, 47, 32, .10);
}
html, body {
width: 100%;
min-height: 100%;
margin: 0;
overflow-x: hidden;
scroll-behavior: smooth;
}
body {
font-family: 'Helvetica Neue', Arial, 'Noto Sans TC', sans-serif;
background: linear-gradient(180deg, #f7f3ec 0%, #eef5f3 100%);
color: var(--ink);
line-height: 1.6;
padding: 0;
margin: 0;
}
.container {
width: 100%;
max-width: 1160px;
margin: 0 auto;
background: transparent;
padding: 14px;
border-radius: 0;
box-shadow: none;
box-sizing: border-box;
}
.hero {
background: radial-gradient(circle at top left, #fff8ef 0%, #fff 44%, #eef7f4 100%);
border: 1px solid var(--line);
border-radius: 22px;
padding: 22px 18px;
margin-bottom: 14px;
box-shadow: var(--shadow);
overflow: hidden;
position: relative;
}
.hero::after {
content: '';
position: absolute;
width: 210px;
height: 210px;
right: -90px;
top: -90px;
background: rgba(111, 78, 55, .10);
border-radius: 50%;
}
.hero-kicker {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 5px 10px;
border-radius: 999px;
background: var(--coffee-soft);
color: var(--coffee-dark);
font-weight: 800;
font-size: .86em;
margin-bottom: 8px;
}
h1 {
margin: 0;
color: #263d50;
font-size: clamp(1.65rem, 4vw, 2.5rem);
letter-spacing: .02em;
line-height: 1.25;
}
.hero-subtitle {
margin: 8px 0 0 0;
color: var(--muted);
font-weight: 700;
font-size: 1em;
}
.flow-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 18px;
}
.flow-card {
background: rgba(255,255,255,.86);
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
min-height: 78px;
box-sizing: border-box;
}
.flow-card strong {
display: block;
color: var(--coffee-dark);
font-size: .98em;
margin-bottom: 3px;
}
.flow-card span {
color: var(--muted);
font-size: .85em;
line-height: 1.35;
}
.section {
background: var(--paper);
padding: 16px;
margin-bottom: 16px;
border-radius: 18px;
border: 1px solid var(--line);
border-left: 7px solid var(--coffee);
box-sizing: border-box;
box-shadow: 0 6px 18px rgba(67, 47, 32, .07);
}
.section-title {
font-weight: 900;
font-size: 1.12em;
margin-bottom: 10px;
color: var(--blue);
display: flex;
align-items: center;
gap: 8px;
line-height: 1.35;
}
.section-title::before {
content: '';
width: 9px;
height: 22px;
background: currentColor;
border-radius: 99px;
opacity: .42;
}
.main-score-section-title {
color: var(--coffee);
font-weight: 900;
font-size: 1.16em;
}
.section-helper {
margin: 0 0 12px 0;
padding: 10px 12px;
background: var(--orange-soft);
border: 1px dashed #d9a86b;
border-radius: 12px;
color: #74512f;
font-size: .9em;
font-weight: 700;
line-height: 1.5;
}
.section-desc {
font-size: 0.85em;
color: #e74c3c;
margin-top: -5px;
margin-bottom: 12px;
font-weight: bold;
background: #fff;
padding: 8px;
border-radius: 4px;
border: 1px dashed #e74c3c;
line-height: 1.4;
}
label {
display: block;
margin-top: 12px;
font-weight: bold;
font-size: 0.95em;
}
select, input[type="text"], input[type="number"], input[type="date"] {
width: 100%;
padding: 10px;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 0.95em;
box-sizing: border-box;
background: #fff;
}
select:disabled, input:disabled {
background-color: #f0f0f0;
color: #7f8c8d;
cursor: not-allowed;
}
.grade-status-box {
margin-top: 10px;
padding: 10px;
border-radius: 6px;
background: #fff;
border: 1px dashed #9b59b6;
color: #6c3483;
font-size: 0.9em;
font-weight: bold;
line-height: 1.5;
}
.field-locked-note {
display: block;
font-size: 0.76em;
color: #999;
font-weight: bold;
margin-top: 3px;
}
small {
display: block;
margin-top: 4px;
line-height: 1.4;
}
.query-section {
background: var(--blue-soft);
border: 1px dashed #7bb8df;
border-left: 7px solid var(--blue);
padding: 13px;
border-radius: 16px;
margin-bottom: 16px;
font-size: 0.92em;
font-weight: bold;
box-sizing: border-box;
}
.query-section a {
color: #2196f3;
text-decoration: underline;
}
.query-section a:hover { color: #0b7dda; }
.user-info-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-top: 8px;
border-radius: 14px;
border: 1px solid var(--line);
background: #fff;
}
.table-hint {
display: none;
margin-top: 6px;
color: var(--muted);
font-size: .8em;
font-weight: 700;
}
.calc-table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
background: #fff;
font-size: 0.9em;
}
.calc-table th, .calc-table td {
border: 1px solid #ddd;
padding: 8px 4px;
text-align: center;
vertical-align: middle;
}
.calc-table th {
background-color: #3f4f5d;
color: white;
font-size: 0.95em;
white-space: nowrap;
}
.calc-table td.left-align {
text-align: left;
font-weight: bold;
background: #f9f9f9;
padding-left: 8px;
width: 120px;
}
.calc-table input[type="checkbox"] {
transform: scale(1.15);
cursor: pointer;
}
.calc-table input[type="number"] {
width: 68px;
padding: 5px;
margin: 0;
text-align: center;
font-size: 0.9em;
}
.reward-table input[type="number"] { width: 72px; }
.semester-cell {
display: flex;
flex-direction: column;
gap: 5px;
align-items: center;
justify-content: center;
}
.exam-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 10px;
}
.hour-text {
display: block;
font-size: 0.8em;
color: #555;
margin-top: 3px;
}
button {
display: block;
width: 100%;
padding: 14px;
background: var(--green);
color: #fff;
border: none;
border-radius: 14px;
font-size: 1.08em;
cursor: pointer;
font-weight: 900;
margin-top: 18px;
box-shadow: 0 8px 18px rgba(47, 143, 91, .20);
}
button:hover { filter: brightness(1.05); }
.btn-primary-calc {
font-size: 1.18em;
padding: 16px;
border-radius: 18px;
background: linear-gradient(135deg, #2f8f5b 0%, #25764a 100%);
}
.action-btn-group {
display: block;
margin-top: 15px;
}
.btn-copy { background: #2980b9; margin: 0; font-size: 1.1em; padding: 12px; }
.btn-copy:hover { background: #3498db; }
.result-box {
margin-top: 15px;
display: none;
box-sizing: border-box;
}
.report-card {
background-color: #fff;
color: #333;
padding: 20px;
border: 2px solid #2c3e50;
border-radius: 8px;
box-sizing: border-box;
}
.report-title {
text-align: center;
color: #2c3e50;
border-bottom: 2px solid #2c3e50;
padding-bottom: 10px;
margin-top: 0;
font-size: 1.4em;
}
.student-info-badge {
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
margin: 15px 0;
font-weight: bold;
font-size: 0.95em;
border-left: 5px solid #9b59b6;
}
.report-sub-title {
color: #2980b9;
border-bottom: 1px solid #ddd;
padding-bottom: 3px;
margin-top: 15px;
margin-bottom: 8px;
font-size: 1.1em;
}
.report-item-detail {
font-size: 0.92em;
margin: 5px 0;
padding-left: 10px;
line-height: 1.5;
}
.report-formula-desc {
font-size: 0.85em;
color: #7f8c8d;
font-style: italic;
display: block;
margin-top: 1px;
margin-bottom: 5px;
}
.report-total-badge {
text-align: center;
margin: 20px 0;
padding: 12px;
background-color: #fff3cd;
border: 1px solid #ffeeba;
border-radius: 6px;
}
.report-total-badge .score {
font-size: 1.8em;
font-weight: bold;
color: #c0392b;
display: block;
margin-top: 5px;
}
.shortage-list {
margin-top: 5px;
padding-left: 20px;
color: #c0392b;
font-size: 0.9em;
}
.shortage-list li {
margin-bottom: 4px;
font-weight: bold;
}
.validation-warning {
display: none;
margin-top: 12px;
padding: 10px;
background: #fff3cd;
border: 1px solid #ffeeba;
border-radius: 6px;
color: #856404;
font-size: 0.9em;
line-height: 1.5;
box-sizing: border-box;
}
.validation-warning ul {
margin: 6px 0 0 18px;
padding: 0;
}
.is-auto-fixed {
outline: 2px solid #f39c12;
background-color: #fff8e1;
}
.muted-note {
font-size:0.85em;
color:#666;
margin: 4px 0;
line-height:1.5;
}
.blue-note {
color:#2980b9;
font-weight:bold;
}
.red-note {
color:#c0392b;
font-weight:bold;
}
.input-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 6px;
}
.input-card {
background: #fbfaf7;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
box-sizing: border-box;
}
.input-card label { margin-top: 0; }
.stage-panel {
margin-top: 12px;
padding: 14px;
background: #fbf6ef;
border: 1px solid #dec8b2;
border-radius: 16px;
}
.stage-panel label { margin-top: 0; }
.action-box {
background: linear-gradient(135deg, #ffffff 0%, #eef8f2 100%);
border: 1px solid #bfdec9;
border-radius: 18px;
padding: 14px;
margin: 16px 0;
box-shadow: 0 8px 18px rgba(47, 143, 91, .10);
}
.action-box-title {
color: #1f6f45;
font-weight: 900;
margin-bottom: 4px;
font-size: 1.06em;
}
.action-box-desc {
color: var(--muted);
font-size: .9em;
font-weight: 700;
}
.save-steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 10px 0 4px 0;
}
.save-step {
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px;
color: var(--muted);
font-size: .82em;
font-weight: 800;
text-align: center;
}
.top-tools {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin: 12px 0 2px 0;
}
.top-tools a {
display: inline-block;
padding: 9px 14px;
border-radius: 999px;
background: #ffffff;
border: 1px solid var(--line);
color: var(--coffee-dark);
text-decoration: none;
font-weight: 900;
box-shadow: 0 4px 12px rgba(67,47,32,.06);
}
.top-tools a.secondary { color: var(--blue); }
.muted-note { color: var(--muted); }
.blue-note { color: var(--blue); font-weight: 900; }
.site-footer {
text-align: center;
color: var(--muted);
font-size: 0.86em;
line-height: 1.7;
margin: 22px 0 8px;
padding: 14px 10px;
border-top: 1px solid var(--line);
}
.site-footer strong {
color: var(--coffee-dark);
font-weight: 900;
}
@media (min-width: 480px) {
body { padding: 0; }
.container { padding: 20px; margin: 0 auto; }
h2 { font-size: 1.75em; }
.user-info-grid { flex-direction: row; }
.user-info-grid > div { flex: 1; }
.calc-table { font-size: 0.95em; }
.calc-table td.left-align { width: 130px; padding-left: 10px; }
.calc-table input[type="number"] { width: 72px; padding: 6px; }
.reward-table input[type="number"] { width: 82px; }
}
.top-tools {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
margin: -5px 0 18px 0;
}
.top-tools a {
display: inline-block;
padding: 9px 14px;
border-radius: 999px;
background: #6b4f3a;
color: #fff;
text-decoration: none;
font-weight: bold;
font-size: 0.92em;
}
.top-tools a.secondary {
background: #2980b9;
}
.save-panel {
margin-top: 14px;
padding: 16px;
border-radius: 10px;
background: #fffaf3;
border: 1px solid #d7b98e;
box-sizing: border-box;
}
.save-panel-title {
font-weight: 800;
color: #6b4f3a;
font-size: 1.05em;
margin-bottom: 6px;
}
.save-panel-desc {
font-size: 0.88em;
color: #555;
line-height: 1.5;
margin-bottom: 8px;
}
.save-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
align-items: end;
}
.btn-save-result {
background: #6b4f3a;
margin-top: 12px;
}
.btn-save-result:hover { background: #7a5a42; }
.save-status {
display: none;
margin-top: 10px;
padding: 10px;
border-radius: 6px;
font-size: 0.9em;
line-height: 1.5;
}
.save-status.ok { display: block; background: #e8f5e9; color: #1b5e20; border: 1px solid #a5d6a7; }
.save-status.err { display: block; background: #ffebee; color: #b71c1c; border: 1px solid #ef9a9a; }
:root {
--bg: #f7f3ec;
--paper: #ffffff;
--ink: #2f2a26;
--muted: #766e66;
--coffee: #6f4e37;
--coffee-dark: #4f3625;
--coffee-soft: #efe3d6;
--blue: #2f6f9f;
--blue-soft: #eaf4fb;
--green: #2f8f5b;
--green-soft: #e9f6ef;
--red: #c0392b;
--red-soft: #fff0ed;
--orange-soft: #fff5e6;
--line: #e2d8cc;
--shadow: 0 12px 30px rgba(67, 47, 32, .10);
}
html, body {
width: 100%;
min-height: 100%;
margin: 0;
overflow-x: hidden;
scroll-behavior: smooth;
}
body {
font-family: 'Helvetica Neue', Arial, 'Noto Sans TC', sans-serif;
background:
radial-gradient(circle at top left, rgba(111, 78, 55, .10), transparent 32%),
linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%);
color: var(--ink);
line-height: 1.6;
}
.container {
width: 100%;
max-width: 1080px;
margin: 0 auto;
padding: 14px;
box-sizing: border-box;
}
.hero {
background: linear-gradient(135deg, #fffaf3 0%, #f1e3d3 100%);
border: 1px solid var(--line);
border-radius: 22px;
padding: 24px 18px;
box-shadow: var(--shadow);
margin: 10px 0 16px;
position: relative;
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
right: -50px;
top: -50px;
width: 170px;
height: 170px;
border-radius: 50%;
background: rgba(47, 111, 159, .12);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(111, 78, 55, .12);
color: var(--coffee-dark);
font-size: .9em;
font-weight: 800;
margin-bottom: 10px;
}
h1 {
margin: 0;
color: var(--coffee-dark);
font-size: 1.7em;
line-height: 1.25;
letter-spacing: .02em;
}
.hero-subtitle {
color: var(--muted);
margin: 10px 0 0;
font-size: .98em;
max-width: 760px;
}
.top-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
position: relative;
z-index: 1;
}
.pill-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 14px;
border-radius: 999px;
color: #fff;
background: var(--blue);
text-decoration: none;
font-weight: 800;
box-shadow: 0 8px 18px rgba(47, 111, 159, .18);
}
.pill-link.secondary {
color: var(--coffee-dark);
background: #fff;
border: 1px solid var(--line);
box-shadow: none;
}
.steps {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 16px;
}
.step-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
box-shadow: 0 6px 16px rgba(67, 47, 32, .06);
display: flex;
gap: 12px;
align-items: flex-start;
}
.step-num {
flex: 0 0 34px;
width: 34px;
height: 34px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--coffee);
color: #fff;
font-weight: 900;
}
.step-card strong {
display: block;
color: var(--coffee-dark);
margin-bottom: 2px;
}
.step-card span {
display: block;
color: var(--muted);
font-size: .9em;
}
.section {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
box-shadow: var(--shadow);
margin-bottom: 16px;
box-sizing: border-box;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 900;
color: var(--coffee-dark);
font-size: 1.15em;
margin-bottom: 8px;
}
.section-title .icon {
width: 36px;
height: 36px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--coffee-soft);
}
.section-desc {
margin: 0 0 14px;
color: var(--muted);
font-size: .92em;
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.field-card {
background: #fffaf6;
border: 1px solid #eaded0;
border-radius: 14px;
padding: 12px;
box-sizing: border-box;
}
label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 900;
color: var(--coffee-dark);
margin-bottom: 6px;
font-size: .95em;
}
.hint {
display: block;
color: var(--muted);
font-size: .82em;
font-weight: 500;
margin-top: 6px;
}
input {
width: 100%;
padding: 12px;
border: 1px solid #d7c9ba;
border-radius: 10px;
box-sizing: border-box;
font-size: 1em;
background: #fff;
color: var(--ink);
}
input:focus {
outline: 3px solid rgba(47, 111, 159, .15);
border-color: var(--blue);
}
.primary-action {
background: linear-gradient(135deg, var(--green) 0%, #2d9f67 100%);
color: #fff;
border: none;
width: 100%;
padding: 14px;
border-radius: 12px;
font-size: 1.08em;
font-weight: 900;
cursor: pointer;
margin-top: 14px;
box-shadow: 0 10px 22px rgba(47, 143, 91, .22);
}
.primary-action:hover { filter: brightness(1.03); }
.notice {
margin-top: 12px;
padding: 12px;
border-radius: 12px;
background: var(--orange-soft);
border: 1px dashed #d2a36b;
color: #77512a;
font-size: .9em;
}
.privacy-note {
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: var(--blue-soft);
border: 1px solid #c7e0f1;
color: #295b7e;
font-size: .9em;
}
.status {
display: none;
margin-top: 12px;
padding: 12px;
border-radius: 12px;
font-weight: 800;
line-height: 1.5;
}
.status.ok {
display: block;
background: var(--green-soft);
color: #1f6a43;
border: 1px solid #bfe4ce;
}
.status.err {
display: block;
background: var(--red-soft);
color: var(--red);
border: 1px solid #f0b8ae;
}
/* 查詢頁報告卡片:限定 lookupPage,避免蓋掉計算機頁的試算報告 */
#lookupPage .report-card {
display: none;
background: #fff;
border: 2px solid var(--coffee-dark);
border-radius: 18px;
padding: 18px;
margin-top: 16px;
box-shadow: var(--shadow);
overflow: hidden;
}
.report-header {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 14px;
}
.badge {
background: #f8f3ed;
border-left: 6px solid var(--coffee);
padding: 12px;
border-radius: 12px;
font-weight: 900;
color: var(--coffee-dark);
line-height: 1.55;
}
.mini-summary {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 14px;
}
.score-chip {
background: #fdfaf6;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
}
.score-chip .label {
color: var(--muted);
font-size: .86em;
font-weight: 800;
}
.score-chip .value {
color: var(--coffee-dark);
font-size: 1.25em;
font-weight: 900;
margin-top: 2px;
}
.report-title {
color: var(--coffee-dark);
font-size: 1.15em;
font-weight: 900;
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
margin-bottom: 10px;
}
pre {
white-space: pre-wrap;
word-break: break-word;
background: #fffdf9;
border: 1px solid var(--line);
padding: 14px;
border-radius: 12px;
font-family: inherit;
font-size: .95em;
line-height: 1.65;
max-height: none;
overflow: auto;
box-sizing: border-box;
}
.footer-tip {
text-align: center;
color: var(--muted);
font-size: .86em;
line-height: 1.7;
margin: 18px 0 8px;
padding: 14px 10px;
border-top: 1px solid var(--line);
}
.footer-tip strong {
color: var(--coffee-dark);
font-weight: 900;
}
@media (min-width: 640px) {
.container { padding: 20px; }
.hero { padding: 30px; }
h1 { font-size: 2.1em; }
.steps { grid-template-columns: repeat(3, 1fr); }
.form-grid { grid-template-columns: repeat(3, 1fr); }
.report-header { grid-template-columns: 1fr auto; align-items: center; }
.mini-summary { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 420px) {
.hero { border-radius: 16px; padding: 20px 14px; }
h1 { font-size: 1.45em; }
.top-actions { flex-direction: column; }
.pill-link { width: 100%; box-sizing: border-box; }
.section { padding: 14px; border-radius: 16px; }
.step-card { padding: 12px; }
.mini-summary { grid-template-columns: 1fr 1fr; }
}
/* 單一 HTML 分頁切換 */
.app-nav {
width: 100%;
max-width: 1080px;
margin: 0 auto;
padding: 12px 14px 0;
box-sizing: border-box;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.app-nav button {
flex: 1;
min-width: 160px;
border: 1px solid var(--line);
background: rgba(255,255,255,.88);
color: var(--coffee-dark);
border-radius: 999px;
padding: 11px 14px;
font-size: .95em;
font-weight: 900;
cursor: pointer;
box-shadow: 0 6px 16px rgba(67, 47, 32, .08);
margin: 0;
}
.app-nav button.active {
background: var(--coffee);
color: #fff;
border-color: var(--coffee);
}
.app-page[hidden] { display: none !important; }
#lookupPage .top-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; }
/* 計算機頁試算報告:確保按下開始計算後可正常顯示 */
#calculatorPage #resultBox .report-card {
display: block;
}
</style>
</head>
<body>
<nav class="app-nav" aria-label="工具分頁">
<button id="tabCalculator" class="active" type="button" onclick="showPage('calculator')">🧮 積分計算機</button>
<button id="tabLookup" type="button" onclick="showPage('lookup')">🔎 個人結果查詢</button>
</nav>
<main id="calculatorPage" class="app-page">
<div class="container">
<div class="hero">
<div class="hero-kicker">🎓 115 學年度|竹苗區免試入學</div>
<h1>免試入學積分計算機</h1>
<p class="hero-subtitle">看懂規則、掌握現況、規劃下一步。適合國一下、國二、國三學生依目前階段試算。</p>
<div class="flow-cards" aria-label="操作流程">
<div class="flow-card"><strong>① 選階段</strong><span>先選目前年級,系統只開放已完成學期。</span></div>
<div class="flow-card"><strong>② 填資料</strong><span>依成績單、獎懲、服務時數輸入資料。</span></div>
<div class="flow-card"><strong>③ 算分並儲存</strong><span>產生報告後,可用學號、生日與密碼保存查詢。</span></div>
</div>
</div>
<div class="top-tools">
<a href="javascript:void(0)" onclick="showPage('lookup')">🔎 查詢已儲存結果</a>
<a class="secondary" href="javascript:void(0)" onclick="showPage('calculator')">🧮 回到計算機</a>
</div>
<div class="section" style="border-left-color: #8e44ad;">
<div class="section-title" style="color: #8e44ad;">學生基本資料與試算階段</div>
<p class="section-helper">先完成這一區,下面的成績、曠課、獎懲、服務學習欄位才會依照「已完成學期」自動開放。</p>
<div class="stage-panel">
<label>目前年級/試算階段</label>
<select id="gradeStage">
<option value="" selected>請先選擇年級後再開始填寫</option>
<option value="1b">國一下(採計一上)</option>
<option value="2a">國二上(採計一上、一下)</option>
<option value="2b">國二下(採計一上至二上)</option>
<option value="3a">國三上(採計一上至二下)</option>
<option value="3b_pre">國三下/會考前(採計一上至三上)</option>
<option value="3b_post">國三下/會考後或正式選填志願</option>
</select>
<div id="gradeNotice" class="grade-status-box">請先選擇目前年級/試算階段,系統會依剛開學時已完成學期自動開放欄位;尚未結算的學期不會列入計算。</div>
</div>
<div class="input-card-grid">
<div class="input-card">
<label>學生姓名</label>
<input type="text" id="stu_name" placeholder="請輸入姓名">
</div>
<div class="input-card">
<label>班級</label>
<input type="text" id="stu_class" placeholder="例如:301">
</div>
<div class="input-card">
<label>座號</label>
<input type="number" id="stu_seat" placeholder="例如:05">
</div>
<div class="input-card">
<label>資料用途</label>
<div style="font-size:.9em; color:#766e66; font-weight:800; padding-top:8px;">基本資料只會用於報告顯示與儲存查詢,試算時請盡量與自己的資料一致。</div>
</div>
</div>
</div>
<div class="query-section">
🔍 成績、獎懲與服務學習紀錄不確定?請先登入:<a href="https://pts.hc.edu.tw/" target="_blank" rel="noopener noreferrer">新竹市親師生平台</a> 進行查詢。<br>
💡 請先選擇目前年級/試算階段;系統會依「剛開學時已完成的學期」開放欄位,尚未結算的學期會鎖定並排除於計算之外。
</div>
<div class="section">
<div class="section-title main-score-section-title">一、均衡發展 (最高採計 30 分)</div>
<p class="section-helper">這一區主要看「身分條件、就學區、志願序位、四個領域是否平均達 60 分」。請依目前資料填寫即可。</p>
<label>1. 扶助弱勢</label>
<select id="weak">
<option value="0" selected>不符 (0分)</option>
<option value="5">偏遠地區學生 或 經濟弱勢 (5分)</option>
<option value="3">非山非市國中學生 (3分)</option>
</select>
<label>2. 就近入學 (竹苗區國中、共同/變更就學區學生)</label>
<select id="local">
<option value="5">符合 (5分)</option>
<option value="0">不符合 (0分)</option>
</select>
<label>3. 志願序位積分</label>
<select id="volunteer">
<option value="0" selected>不採計:會考前積分試算 (0分)</option>
<option value="10">第 1~5 志願序位積分 (10分)</option>
<option value="9">第 6~10 志願序位積分 (9分)</option>
<option value="8">第 11~15 志願序位積分 (8分)</option>
<option value="7">第 16~20 志願序位積分 (7分)</option>
<option value="6">第 21~25 志願序位積分 (6分)</option>
</select>
<small style="color: #e67e22; font-weight: bold;">*系統會依年級自動控制是否可選。國三會考後或正式選填志願時,才開放改選對應區間。</small>
<label>4. 均衡學習 (請輸入已完成學期成績,系統依試算階段自動採計平均值)</label>
<p class="muted-note">
*各領域依「已採計學期」平均,平均達 60 分以上(含)即算該領域符合。四領域皆符合15分 / 三領域12分 / 二領域8分 / 一領域4分。<br>
<span class="blue-note">💡【查詢路徑:親師生平台⮕畢業預警查詢】若各領域皆及格,成績輸入維持60分即可!</span>
</p>
<div class="table-hint">手機版表格可左右滑動查看完整欄位。</div>
<div class="table-responsive">
<table class="calc-table">
<thead><tr><th>學習領域</th><th>一上</th><th>一下</th><th>二上</th><th>二下</th><th>三上</th></tr></thead>
<tbody>
<tr>
<td class="left-align">健康與體育</td>
<td><div class="semester-cell"><input type="number" id="fit_1a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="fit_1b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="fit_2a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="fit_2b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="fit_3a" min="0" max="100" value="60"></div></td>
</tr>
<tr>
<td class="left-align">藝術</td>
<td><div class="semester-cell"><input type="number" id="art_1a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="art_1b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="art_2a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="art_2b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="art_3a" min="0" max="100" value="60"></div></td>
</tr>
<tr>
<td class="left-align">綜合活動</td>
<td><div class="semester-cell"><input type="number" id="mix_1a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="mix_1b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="mix_2a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="mix_2b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="mix_3a" min="0" max="100" value="60"></div></td>
</tr>
<tr>
<td class="left-align">科技</td>
<td><div class="semester-cell"><input type="number" id="tech_1a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="tech_1b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="tech_2a" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="tech_2b" min="0" max="100" value="60"></div></td>
<td><div class="semester-cell"><input type="number" id="tech_3a" min="0" max="100" value="60"></div></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="section">
<div class="section-title main-score-section-title">二、多元學習表現 (最高採計 40 分)</div>
<p class="section-helper">這一區是最適合追蹤與補強的部分。系統會判斷是否仍可透過無曠課、服務學習、本土語言認證或獎懲紀錄補足到 40 分上限。</p>
<div class="section-desc">※說明:多元學習表現原始總分 54 分,最高採計 40 分。【日常生活表現評量】、【無曠課紀錄的學期】、【功過相抵後的獎懲紀錄】採計國一上至國三下共六學期,國三下採計至當年度 4 月 20 日止;【服務學習】採計國一上至國三上五學期;【本土語言認證】屬於多元學習表現,最高 2 分。</div>
<label>1. 日常生活表現評量</label>
<select id="punish">
<option value="10">功過相抵後或銷過後無懲罰紀錄 (10分)</option>
<option value="0">否 (0分)</option>
</select>
<label>2. 無曠課紀錄的學期 (每學期 2 分,最高 12 分)</label>
<div class="table-hint">手機版表格可左右滑動查看完整欄位。</div>
<div class="table-responsive">
<table class="calc-table" style="min-width: 420px;">
<thead><tr><th>一上</th><th>一下</th><th>二上</th><th>二下</th><th>三上</th><th>三下</th></tr></thead>
<tbody>
<tr>
<td><input type="checkbox" class="chk-absent" data-term="一上"></td>
<td><input type="checkbox" class="chk-absent" data-term="一下"></td>
<td><input type="checkbox" class="chk-absent" data-term="二上"></td>
<td><input type="checkbox" class="chk-absent" data-term="二下"></td>
<td><input type="checkbox" class="chk-absent" data-term="三上"></td>
<td><input type="checkbox" class="chk-absent" data-term="三下"></td>
</tr>
</tbody>
</table>
</div>
<label>3. 功過相抵後的獎懲紀錄 (分學期矩陣輸入,最高採計 20 分)</label>
<div style="font-size:0.85em; color:#c0392b; font-weight:bold; background:#fff; border:1px dashed #c0392b; border-radius:4px; padding:8px; margin:5px 0 8px 0; line-height:1.4;">⚠️【查詢路徑:親師生平台⮕缺曠獎懲】輸入「功過相抵後」的累積獎勵次數!</div>
<div class="table-hint">手機版表格可左右滑動查看完整欄位。</div>
<div class="table-responsive">
<table class="calc-table reward-table" style="min-width: 560px;">
<thead>
<tr>
<th>學期別</th>
<th>大功數量 (每次4.5分)</th>
<th>小功數量 (每次1.5分)</th>
<th>嘉獎數量 (每次0.5分)</th>
</tr>
</thead>
<tbody>
<tr><td class="left-align">一上</td><td><input type="number" id="dg_1a" min="0" value="0"></td><td><input type="number" id="xg_1a" min="0" value="0"></td><td><input type="number" id="jj_1a" min="0" value="0"></td></tr>
<tr><td class="left-align">一下</td><td><input type="number" id="dg_1b" min="0" value="0"></td><td><input type="number" id="xg_1b" min="0" value="0"></td><td><input type="number" id="jj_1b" min="0" value="0"></td></tr>
<tr><td class="left-align">二上</td><td><input type="number" id="dg_2a" min="0" value="0"></td><td><input type="number" id="xg_2a" min="0" value="0"></td><td><input type="number" id="jj_2a" min="0" value="0"></td></tr>
<tr><td class="left-align">二下</td><td><input type="number" id="dg_2b" min="0" value="0"></td><td><input type="number" id="xg_2b" min="0" value="0"></td><td><input type="number" id="jj_2b" min="0" value="0"></td></tr>
<tr><td class="left-align">三上</td><td><input type="number" id="dg_3a" min="0" value="0"></td><td><input type="number" id="xg_3a" min="0" value="0"></td><td><input type="number" id="jj_3a" min="0" value="0"></td></tr>
<tr><td class="left-align">三下 (至4/20)</td><td><input type="number" id="dg_3b" min="0" value="0"></td><td><input type="number" id="xg_3b" min="0" value="0"></td><td><input type="number" id="jj_3b" min="0" value="0"></td></tr>
</tbody>
</table>
</div>
<label style="margin-top: 15px;">4. 服務學習時數 (前五學期,單期上限 2 分,總最高 10 分)</label>
<div style="font-size:0.85em; color:#2980b9; font-weight:bold; background:#fff; border:1px dashed #2980b9; border-radius:4px; padding:8px; margin:5px 0 8px 0; line-height:1.4;">💡 請依實際服務學習時數輸入。每 3 小時折算 1 分,單一學期最高 2 分;前五學期總分最高 10 分。</div>
<div class="table-responsive">
<table class="calc-table" style="min-width: 420px;">
<thead><tr><th>一上</th><th>一下</th><th>二上</th><th>二下</th><th>三上</th></tr></thead>
<tbody>
<tr>
<td><input type="number" id="srv_1a" min="0" value="0"><br><span class="hour-text">小時</span></td>
<td><input type="number" id="srv_1b" min="0" value="0"><br><span class="hour-text">小時</span></td>
<td><input type="number" id="srv_2a" min="0" value="0"><br><span class="hour-text">小時</span></td>
<td><input type="number" id="srv_2b" min="0" value="0"><br><span class="hour-text">小時</span></td>
<td><input type="number" id="srv_3a" min="0" value="0"><br><span class="hour-text">小時</span></td>
</tr>
</tbody>
</table>
</div>
<label style="margin-top: 15px;">5. 本土語言認證 (最高 2 分)</label>
<select id="localLang">
<option value="0" selected>未取得或不採計 (0分)</option>
<option value="2">已通過原住民族語、客語或閩南語初級以上認證 (2分)</option>
</select>
<small style="color: #666;">*本項屬於「多元學習表現」,獲得其中一種認證即採計 2 分,最多 2 分。</small>
</div>
<div class="section">
<div class="section-title main-score-section-title">三、國中教育會考 (最高採計 30 分)</div>
<p class="muted-note"><span class="blue-note">💡 系統會依年級自動控制:會考前維持不採計;國三會考後或正式選填階段才開放改選各科等級。</span></p>
<div class="exam-grid">
<div><label>國文科</label><select id="chinese" class="exam-select"><option value="0" selected>不採計:會考前積分試算 (0分)</option><option value="6">精熟 (6分)</option><option value="4">基礎 (4分)</option><option value="2">待加強 (2分)</option></select></div>
<div><label>數學科</label><select id="math" class="exam-select"><option value="0" selected>不採計:會考前積分試算 (0分)</option><option value="6">精熟 (6分)</option><option value="4">基礎 (4分)</option><option value="2">待加強 (2分)</option></select></div>
<div><label>英語科</label><select id="english" class="exam-select"><option value="0" selected>不採計:會考前積分試算 (0分)</option><option value="6">精熟 (6分)</option><option value="4">基礎 (4分)</option><option value="2">待加強 (2分)</option></select></div>
<div><label>社會科</label><select id="social" class="exam-select"><option value="0" selected>不採計:會考前積分試算 (0分)</option><option value="6">精熟 (6分)</option><option value="4">基礎 (4分)</option><option value="2">待加強 (2分)</option></select></div>
<div><label>自然科</label><select id="science" class="exam-select"><option value="0" selected>不採計:會考前積分試算 (0分)</option><option value="6">精熟 (6分)</option><option value="4">基礎 (4分)</option><option value="2">待加強 (2分)</option></select></div>
</div>
<label style="margin-top: 15px;">會考違規記點扣分</label>
<select id="examViolation">
<option value="0" selected>無違規記點 (不扣分)</option>
<option value="1">記違規 1 點:扣會考總積分 0.3 分</option>
<option value="2">記違規 2 點:扣會考總積分 0.6 分</option>
</select>
<small style="color:#666;">*違規記點僅扣國中教育會考總積分,不另行扣單科積分。寫作測驗級分與各科標示屬同分比序,不列入總積分計算。</small>
</div>
<div id="validationWarning" class="validation-warning" aria-live="polite"></div>
<div class="action-box">
<div class="action-box-title">準備好了嗎?</div>
<div class="action-box-desc">確認資料輸入完畢後,點擊下方按鈕產生積分明細、補分策略與可儲存報告。</div>
<button onclick="calculateScore()" class="btn-primary-calc">開始計算總積分</button>
</div>
<div id="resultBox" class="result-box">
<div id="report-node" class="report-card">
<h3 class="report-title">115學年度竹苗區免試入學積分試算報告</h3>
<div id="studentDataBadge" class="student-info-badge"></div>
<div class="report-sub-title">一、均衡發展積分明細 (得分:<span id="scoreGroup1">0</span> / 30 分)</div>
<div id="detailGroup1"></div>
<div class="report-sub-title">二、多元學習表現積分明細 (得分:<span id="scoreGroup2">0</span> / 40 分)</div>
<div id="detailGroup2"></div>
<div class="report-sub-title">三、國中教育會考積分明細 (得分:<span id="scoreGroup3">0</span> / 30 分)</div>
<div id="detailGroup3"></div>
<div id="shortageAlertArea"></div>
<div class="report-total-badge">
<strong>🌟 目前階段免試入學比序預估總積分 🌟</strong>
<span id="reportTotalScore" class="score">0 分</span>
</div>
</div>
<div class="action-btn-group">
<button onclick="copyTextReport()" class="btn-copy">📋 複製精華報告</button>
</div>
<div class="save-panel">
<div class="save-panel-title">💾 儲存我的試算結果</div>
<div class="save-panel-desc">
請先完成試算,再輸入學號、生日與個人查詢密碼。系統會將本次試算報告儲存;之後可到「查詢已儲存結果」頁面查詢。
</div>
<div class="save-steps">
<div class="save-step">① 完成試算</div>
<div class="save-step">② 設定查詢資料</div>
<div class="save-step">③ 儲存並回查</div>
</div>
<div class="save-panel-desc"><strong>提醒:</strong>生日與查詢密碼後端會以雜湊方式儲存。</div>
<div class="save-grid">
<div>
<label>學號</label>
<input type="text" id="saveStudentId" placeholder="例如:1150001">
</div>
<div>
<label>生日</label>
<input type="date" id="saveBirthday">
</div>
<div>
<label>個人查詢密碼</label>
<input type="text" id="savePassword" placeholder="請自行設定,確認後再儲存" autocomplete="off">
<small style="color:#8a6d3b; font-weight:700;">請謹記查詢密碼,以免日後無法查詢。</small>
</div>
</div>
<button onclick="saveCurrentResult()" class="btn-save-result">💾 儲存試算結果</button>
<div id="saveStatus" class="save-status"></div>
</div>
</div>
<div class="site-footer">
<strong>竹苗區免試入學積分小工具|積分計算機頁</strong><br>
新竹市立三民國中 楊翔紘老師 製作
</div>
</div>
</main>
<main id="lookupPage" class="app-page" hidden>
<div class="container">
<section class="hero">
<div class="eyebrow">🔐 個人試算結果查詢</div>
<h1>我的免試入學積分查詢</h1>
<p class="hero-subtitle">輸入你儲存試算結果時設定的「學號、生日、個人查詢密碼」,系統會找出最近一次儲存的積分報告。</p>
<div class="top-actions">
<a class="pill-link" href="javascript:void(0)" onclick="showPage('calculator')">🧮 回到積分計算機</a>
<a class="pill-link secondary" href="#querySection">直接查詢 ↓</a>
</div>
</section>
<section class="steps" aria-label="查詢步驟">
<div class="step-card">
<div class="step-num">1</div>
<div><strong>準備查詢資料</strong><span>使用儲存時輸入的學號、生日與密碼。</span></div>
</div>
<div class="step-card">
<div class="step-num">2</div>
<div><strong>查詢最近紀錄</strong><span>系統會自動比對並取出最新一次試算。</span></div>
</div>
<div class="step-card">
<div class="step-num">3</div>
<div><strong>檢視積分報告</strong><span>確認分數明細與下一步補分提醒。</span></div>
</div>
</section>
<section id="querySection" class="section">
<div class="section-title"><span class="icon">🔎</span><span>查詢已儲存結果</span></div>
<p class="section-desc">請輸入與儲存時完全相同的資料。若查不到,通常是學號、生日或個人查詢密碼其中一項不同。</p>
<div class="form-grid">
<div class="field-card">
<label for="studentId">學號</label>
<input type="text" id="studentId" placeholder="例如:S1150001" autocomplete="username">
<span class="hint">請輸入你儲存試算結果時使用的學號。</span>
</div>
<div class="field-card">
<label for="birthday">生日</label>
<input type="date" id="birthday" autocomplete="bday">
<span class="hint">請選擇儲存時輸入的生日。</span>
</div>
<div class="field-card">
<label for="password">個人查詢密碼</label>
<input type="text" id="password" placeholder="請輸入查詢密碼" autocomplete="off">
<span class="hint">這不是 Google 密碼,是你自己在計算機頁設定的查詢密碼;文字會直接顯示,方便確認。</span>
</div>
</div>
<button class="primary-action" onclick="lookupSavedResult()">查詢我的試算結果</button>
<div class="notice">提醒:系統只會回傳與「學號、生日、查詢密碼」完全符合的最近一筆試算結果。</div>
<div class="privacy-note">隱私說明:生日與查詢密碼不會以明碼儲存在試算表,後端會以雜湊形式保存。</div>
<div id="status" class="status" aria-live="polite"></div>
</section>
<section id="reportCard" class="report-card">
<div class="report-header">
<div>
<div class="report-title">📄 最近一次儲存的試算報告</div>
<div id="recordBadge" class="badge"></div>
</div>
</div>
<div class="mini-summary" id="summaryChips" style="display:none;">
<div class="score-chip"><div class="label">均衡發展</div><div class="value" id="g1Chip">──</div></div>
<div class="score-chip"><div class="label">多元學習表現</div><div class="value" id="g2Chip">──</div></div>
<div class="score-chip"><div class="label">國中教育會考</div><div class="value" id="g3Chip">──</div></div>
<div class="score-chip"><div class="label">目前總積分</div><div class="value" id="totalChip">──</div></div>
</div>
<pre id="reportText"></pre>
</section>
<div class="footer-tip"><strong>竹苗區免試入學積分小工具|個人結果查詢頁</strong><br>新竹市立三民國中 楊翔紘老師 製作</div>
</div>
</main>
<script>
function showPage(page) {
var calcPage = document.getElementById('calculatorPage');
var lookupPage = document.getElementById('lookupPage');
var tabCalculator = document.getElementById('tabCalculator');
var tabLookup = document.getElementById('tabLookup');
var showLookup = page === 'lookup';
if (calcPage) calcPage.hidden = showLookup;
if (lookupPage) lookupPage.hidden = !showLookup;
if (tabCalculator) tabCalculator.classList.toggle('active', !showLookup);
if (tabLookup) tabLookup.classList.toggle('active', showLookup);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
let textReportContent = "";
const fiveTermDefs = [
{ key: '1a', label: '一上', rank: 1 },
{ key: '1b', label: '一下', rank: 2 },
{ key: '2a', label: '二上', rank: 3 },
{ key: '2b', label: '二下', rank: 4 },
{ key: '3a', label: '三上', rank: 5 }
];
const sixTermDefs = [
{ key: '1a', label: '一上', rank: 1 },
{ key: '1b', label: '一下', rank: 2 },
{ key: '2a', label: '二上', rank: 3 },
{ key: '2b', label: '二下', rank: 4 },
{ key: '3a', label: '三上', rank: 5 },
{ key: '3b', label: '三下', rank: 6 }
];
const domainDefs = [
{ name: '健康與體育', prefix: 'fit' },
{ name: '藝術', prefix: 'art' },
{ name: '綜合活動', prefix: 'mix' },
{ name: '科技', prefix: 'tech' }
];
const examDefs = [
{ id: 'chinese', label: '國文科' },
{ id: 'math', label: '數學科' },
{ id: 'english', label: '英語科' },
{ id: 'social', label: '社會科' },
{ id: 'science', label: '自然科' }
];
const gradeStageConfigs = {
'1b': { label: '國一下(採計一上)', termUntil: 1, serviceUntil: 1, examOpen: false, volunteerOpen: false },
'2a': { label: '國二上(採計一上、一下)', termUntil: 2, serviceUntil: 2, examOpen: false, volunteerOpen: false },
'2b': { label: '國二下(採計一上至二上)', termUntil: 3, serviceUntil: 3, examOpen: false, volunteerOpen: false },
'3a': { label: '國三上(採計一上至二下)', termUntil: 4, serviceUntil: 4, examOpen: false, volunteerOpen: false },
'3b_pre': { label: '國三下/會考前(採計一上至三上)', termUntil: 5, serviceUntil: 5, examOpen: false, volunteerOpen: false },
'3b_post': { label: '國三下/會考後或正式選填志願', termUntil: 6, serviceUntil: 5, examOpen: true, volunteerOpen: true }
};
function getCurrentGradeConfig() {
const stageEl = document.getElementById('gradeStage');
const value = stageEl ? stageEl.value : '';
return gradeStageConfigs[value] || null;
}
function getCurrentGradeLabel() {
const config = getCurrentGradeConfig();
return config ? config.label : '尚未選擇';
}
function escapeHTML(value) {
return String(value).replace(/[&<>"']/g, function(ch) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch];
});
}
function formatNumber(value) {
let num = Number(value) || 0;
return Number.isInteger(num) ? String(num) : num.toFixed(1).replace(/\.0$/, "");
}
function getSelectedText(id) {
const el = document.getElementById(id);
if (!el || !el.options || el.selectedIndex < 0) return '';
return el.options[el.selectedIndex].text;
}
function countChecked(className) {
let checkboxes = document.querySelectorAll('.' + className);
let count = 0;
checkboxes.forEach(function(cb) { if (cb.checked) count++; });
return count;
}
function calcSingleSemesterService(hours) {
hours = Number(hours) || 0;
return Math.min(2, Math.floor(hours / 3));
}
function markFixedField(el) {
if (!el) return;
el.classList.add('is-auto-fixed');
setTimeout(function() { el.classList.remove('is-auto-fixed'); }, 2200);
}
function sanitizeNumber(id, label, options, warnings) {
const el = document.getElementById(id);
if (!el || el.disabled) return 0;
const min = options.min ?? 0;
const max = options.max ?? null;
const defaultValue = options.defaultValue ?? 0;
const integer = options.integer ?? false;
let raw = String(el.value).trim();
let num;
let changed = false;
if (raw === "") {
num = defaultValue;
changed = true;
warnings.push(`${label} 空白,已自動改以 ${defaultValue} 計算。`);
} else {
num = Number(raw);
if (!Number.isFinite(num)) {
num = defaultValue;
changed = true;
warnings.push(`${label} 不是有效數字,已自動改以 ${defaultValue} 計算。`);
}
}
if (integer && !Number.isInteger(num)) {
num = Math.floor(num);
changed = true;
warnings.push(`${label} 應為整數,已自動無條件捨去為 ${num}。`);
}
if (num < min) { num = min; changed = true; warnings.push(`${label} 低於下限 ${min},已自動改為 ${min}。`); }
if (max !== null && num > max) { num = max; changed = true; warnings.push(`${label} 超過上限 ${max},已自動改為 ${max}。`); }
if (changed || String(el.value).trim() !== String(num)) {
el.value = String(num);
if (changed) markFixedField(el);
}
return num;
}
function sanitizeAllInputs() {
const warnings = [];
const seatEl = document.getElementById('stu_seat');
if (seatEl && String(seatEl.value).trim() !== "") {
sanitizeNumber('stu_seat', '座號', { min: 1, max: 99, defaultValue: 1, integer: true }, warnings);
}
domainDefs.forEach(function(domain) {
fiveTermDefs.forEach(function(term) {
const id = `${domain.prefix}_${term.key}`;
sanitizeNumber(id, `${domain.name}${term.label}`, { min: 0, max: 100, defaultValue: 60 }, warnings);
});
});
sixTermDefs.forEach(function(term) {
['dg', 'xg', 'jj'].forEach(function(type) {
const labelMap = { dg: '大功', xg: '小功', jj: '嘉獎' };
sanitizeNumber(`${type}_${term.key}`, `${term.label}${labelMap[type]}`, { min: 0, max: 99, defaultValue: 0, integer: true }, warnings);
});
});
fiveTermDefs.forEach(function(term) {
sanitizeNumber(`srv_${term.key}`, `服務學習${term.label}`, { min: 0, max: 999, defaultValue: 0 }, warnings);
});
showValidationWarnings(warnings);
return warnings;
}
function showValidationWarnings(warnings) {
const box = document.getElementById('validationWarning');
if (!box) return;
if (!warnings || warnings.length === 0) { box.style.display = 'none'; return; }
const maxVisible = 6;
const visibleItems = warnings.slice(0, maxVisible).map(function(msg) { return `<li>${escapeHTML(msg)}</li>`; }).join('');
box.innerHTML = `⚠️ 系統已啟動輸入防呆,並自動修正以下欄位:<ul>${visibleItems}${warnings.length > maxVisible ? `<li>另有 ${warnings.length - maxVisible} 個欄位已一併修正。</li>` : ''}</ul>`;
box.style.display = 'block';
}
function setNumberDisabled(id, disabled) {
const el = document.getElementById(id);
if (!el) return;
el.disabled = disabled;
if (disabled) el.classList.add('is-auto-fixed');
else el.classList.remove('is-auto-fixed');
}
function updateGradeGating() {
const config = getCurrentGradeConfig();
const hasGrade = !!config;
const notice = document.getElementById('gradeNotice');
if (notice) {
if (!hasGrade) {
notice.innerHTML = '請先選擇目前年級/試算階段,系統會依剛開學時已完成學期自動開放欄位;尚未結算的學期不會列入計算。';
} else {
const termLabel = sixTermDefs.filter(function(term) { return term.rank <= config.termUntil; }).map(function(term) { return term.label; }).join('、') || '尚未開放';
const serviceLabel = fiveTermDefs.filter(function(term) { return term.rank <= config.serviceUntil; }).map(function(term) { return term.label; }).join('、') || '尚未開放';
notice.innerHTML = `目前階段:${config.label}。採計已完成學期:${termLabel}。服務學習開放:${serviceLabel}。${config.examOpen ? '會考與志願序位已開放。' : '會考與志願序位目前維持不採計。'}`;
}
}
const volunteer = document.getElementById('volunteer');
if (volunteer) {
volunteer.disabled = !hasGrade || !config.volunteerOpen;
if (volunteer.disabled) volunteer.value = '0';
}
examDefs.forEach(function(item) {
const el = document.getElementById(item.id);
if (!el) return;
el.disabled = !hasGrade || !config.examOpen;
if (el.disabled) el.value = '0';
});
const punish = document.getElementById('punish');
if (punish) punish.disabled = !hasGrade;
const localLang = document.getElementById('localLang');
if (localLang) {
localLang.disabled = !hasGrade;
if (!hasGrade) localLang.value = '0';
}
const examViolation = document.getElementById('examViolation');
if (examViolation) {
examViolation.disabled = !hasGrade || !config.examOpen;
if (examViolation.disabled) examViolation.value = '0';
}
domainDefs.forEach(function(domain) {
fiveTermDefs.forEach(function(term) {
const id = `${domain.prefix}_${term.key}`;
const allowed = hasGrade && term.rank <= config.termUntil;
setNumberDisabled(id, !allowed);
});
});
document.querySelectorAll('.chk-absent').forEach(function(cb, index) {
const term = sixTermDefs[index];
const allowed = hasGrade && term && term.rank <= config.termUntil;
cb.disabled = !allowed;
if (!allowed) cb.checked = false;
});
sixTermDefs.forEach(function(term) {
const allowed = hasGrade && term.rank <= config.termUntil;
['dg', 'xg', 'jj'].forEach(function(type) {
setNumberDisabled(`${type}_${term.key}`, !allowed);
});
});
fiveTermDefs.forEach(function(term) {
const allowed = hasGrade && term.rank <= config.serviceUntil;
setNumberDisabled(`srv_${term.key}`, !allowed);
});
}
function setupGradeHandlers() {
const gradeStage = document.getElementById('gradeStage');
if (gradeStage) gradeStage.addEventListener('change', function() {
updateGradeGating();
document.getElementById('resultBox').style.display = 'none';
textReportContent = '';
});
updateGradeGating();
}
function getDomainCalculation(domain) {
let included = [];
let excluded = [];
let sum = 0;
fiveTermDefs.forEach(function(term) {
const id = `${domain.prefix}_${term.key}`;
const el = document.getElementById(id);
if (!el || el.disabled) {
excluded.push(term.label);
} else {
const score = parseFloat(el.value) || 0;
included.push({ label: term.label, score: score });
sum += score;
}
});
const count = included.length;
const avg = count > 0 ? sum / count : 0;
const passed = count > 0 && avg >= 60;
const includedText = included.length ? included.map(function(item) { return `${item.label} ${formatNumber(item.score)}`; }).join('、') : '尚未採計任何學期';
const excludedText = excluded.length ? excluded.join('、') : '無';
return {
name: domain.name,
sum: sum,
count: count,
avg: avg,
passed: passed,
includedText: includedText,
excludedText: excludedText,
statusText: count === 0 ? '【未採計】' : (passed ? '【符合及格】' : '【不符合】')
};
}
function getTermReward(prefix) {
const dgEl = document.getElementById('dg_' + prefix);
const xgEl = document.getElementById('xg_' + prefix);
const jjEl = document.getElementById('jj_' + prefix);
if (!dgEl || dgEl.disabled) return { dg: 0, xg: 0, jj: 0, score: 0, active: false };
let dg = parseFloat(dgEl.value) || 0;
let xg = parseFloat(xgEl.value) || 0;
let jj = parseFloat(jjEl.value) || 0;
return { dg: dg, xg: xg, jj: jj, score: (dg * 4.5) + (xg * 1.5) + (jj * 0.5), active: true };
}
function getExamLevelText(score) {
if (score === 6) return '精熟';
if (score === 4) return '基礎';
if (score === 2) return '待加強';
return '不採計';
}
function calculateScore() {
const gradeConfig = getCurrentGradeConfig();
if (!gradeConfig) {
alert('請先選擇目前年級/試算階段,再開始計算。');
const gradeEl = document.getElementById('gradeStage');
if (gradeEl) gradeEl.focus();
return;
}
updateGradeGating();
sanitizeAllInputs();
let name = document.getElementById('stu_name').value.trim();
let sClass = document.getElementById('stu_class').value.trim();
let seat = document.getElementById('stu_seat').value.trim();
let safeName = escapeHTML(name);
let safeClass = escapeHTML(sClass);
let safeSeat = escapeHTML(seat);
document.getElementById('studentDataBadge').innerHTML = `試算學生:${safeClass ? safeClass + ' 班 ' : '── 班 '}${safeSeat ? safeSeat + ' 號 ' : '── 號 '}${safeName ? '【' + safeName + '】' : '【一般學生】'}|目前階段:${escapeHTML(gradeConfig.label)}`;
// 一、均衡發展 (最高採計 30 分)
let weak = parseInt(document.getElementById('weak').value) || 0;
let local = parseInt(document.getElementById('local').value) || 0;
let volunteer = parseInt(document.getElementById('volunteer').value) || 0;
let domainCalculations = domainDefs.map(getDomainCalculation);
let passedDomains = domainCalculations.filter(function(item) { return item.passed; }).length;
let domainScore = [0, 4, 8, 12, 15][passedDomains];
let group1Raw = weak + local + volunteer + domainScore;
let group1Score = Math.min(30, group1Raw);
let domainDetailHTML = domainCalculations.map(function(item) {
return `- ${item.name}:採計 ${item.includedText};排除 ${item.excludedText} → 平均 ${item.count ? item.avg.toFixed(1) : '未採計'} 分 ${item.statusText}`;
}).join('<br>');
document.getElementById('detailGroup1').innerHTML = `
<div class="report-item-detail">• 扶助弱勢審查:${weak === 5 ? "符合偏遠/經濟弱勢 (+5分)" : (weak === 3 ? "符合非山非市 (+3分)" : "不符資格 (0分)")}</div>
<div class="report-item-detail">• 就近入學審查:${local === 5 ? "符合竹苗就學區規範 (+5分)" : "不符 (0分)"}</div>
<div class="report-item-detail">• 志願序位積分:${escapeHTML(getSelectedText('volunteer'))}</div>
<div class="report-item-detail">• 均衡學習精算:共符合 <strong>${passedDomains}</strong> 個領域,核發 <strong>${domainScore}</strong> 分。</div>
<span class="report-formula-desc">各領域有效學期平均精算過程:<br>${domainDetailHTML}<br>本大項原始加總 ${formatNumber(group1Raw)} 分,最高採計 30 分。</span>
`;
document.getElementById('scoreGroup1').innerText = formatNumber(group1Score);
// 二、多元學習表現 (最高採計 40 分)
let punish = parseInt(document.getElementById('punish').value) || 0;
let localLang = parseInt(document.getElementById('localLang').value) || 0;
let checkedAbsent = countChecked('chk-absent');
let absentCheckedTerms = Array.from(document.querySelectorAll('.chk-absent')).map(function(cb) {
return cb.checked ? cb.dataset.term : null;
}).filter(Boolean);
let absentTermText = absentCheckedTerms.length ? absentCheckedTerms.join('、') : '尚未勾選';
let absentScore = Math.min(12, checkedAbsent * 2);
let openedAbsentTerms = sixTermDefs.filter(function(term) { return term.rank <= gradeConfig.termUntil; }).length;
let currentAbsentMax = Math.min(12, openedAbsentTerms * 2);
let rewardByTerm = {};
sixTermDefs.forEach(function(term) { rewardByTerm[term.key] = getTermReward(term.key); });
let totalDG = sixTermDefs.reduce(function(sum, term) { return sum + rewardByTerm[term.key].dg; }, 0);
let totalXG = sixTermDefs.reduce(function(sum, term) { return sum + rewardByTerm[term.key].xg; }, 0);
let totalJJ = sixTermDefs.reduce(function(sum, term) { return sum + rewardByTerm[term.key].jj; }, 0);
let rewardRaw = (totalDG * 4.5) + (totalXG * 1.5) + (totalJJ * 0.5);
let rewardScore = Math.min(20, rewardRaw);
let rewardTermDetailHTML = sixTermDefs.map(function(term) {
let r = rewardByTerm[term.key];
if (!r.active) return `- ${term.label}:尚未開放,不列入目前試算`;
return `- ${term.label}:大功 ${formatNumber(r.dg)} | 小功 ${formatNumber(r.xg)} | 嘉獎 ${formatNumber(r.jj)} (換算得分:${formatNumber(r.score)} 分)`;
}).join('<br>');
let serviceByTerm = {};
let serviceHoursTotal = 0;
fiveTermDefs.forEach(function(term) {
const el = document.getElementById('srv_' + term.key);
const active = el && !el.disabled;
const hours = active ? (parseFloat(el.value) || 0) : 0;
serviceByTerm[term.key] = { hours: hours, score: active ? calcSingleSemesterService(hours) : 0, active: active };
serviceHoursTotal += hours;
});
let serviceRaw = fiveTermDefs.reduce(function(sum, term) { return sum + serviceByTerm[term.key].score; }, 0);
let serviceScore = Math.min(10, serviceRaw);
let openedServiceTerms = fiveTermDefs.filter(function(term) { return term.rank <= gradeConfig.serviceUntil; }).length;
let currentServiceMax = Math.min(10, openedServiceTerms * 2);
let serviceTermText = fiveTermDefs.map(function(term) { return `${term.label} ${serviceByTerm[term.key].active ? formatNumber(serviceByTerm[term.key].score) + '分' : '未開放'}`; }).join(' | ');
let group2Raw = punish + absentScore + rewardScore + serviceScore + localLang;
let group2Score = Math.min(40, group2Raw);
document.getElementById('detailGroup2').innerHTML = `
<div class="report-item-detail">• 日常生活表現評量:<strong>${formatNumber(punish)}</strong> / 10 分</div>
<div class="report-item-detail">• 無曠課紀錄學期:<strong>${formatNumber(absentScore)}</strong> / 12 分 <span class="report-formula-desc">目前已開放學期最高 ${formatNumber(currentAbsentMax)} 分;已勾選 ${checkedAbsent} 期:${escapeHTML(absentTermText)}</span></div>
<div class="report-item-detail">• 功過相抵後的獎懲紀錄:<strong>${formatNumber(rewardScore)}</strong> / 20 分
<span class="report-formula-desc">分學期累積明細:<br>${rewardTermDetailHTML}<br>總數累加:大功 ${formatNumber(totalDG)} | 小功 ${formatNumber(totalXG)} | 嘉獎 ${formatNumber(totalJJ)} → 原始加總為 ${formatNumber(rewardRaw)} 分,最高採計 20 分。</span>
</div>
<div class="report-item-detail">• 服務學習時數折算:<strong>${formatNumber(serviceScore)}</strong> / 10 分 <span class="report-formula-desc">目前已開放學期最高 ${formatNumber(currentServiceMax)} 分;分期限制精算:${serviceTermText},已開放學期總時數 ${formatNumber(serviceHoursTotal)} 小時,最終最高採計 10 分。</span></div>
<div class="report-item-detail">• 本土語言認證:<strong>${formatNumber(localLang)}</strong> / 2 分 <span class="report-formula-desc">${localLang === 2 ? "已通過原住民族語、客語或閩南語初級以上認證" : "未取得或不採計"}</span></div>
<span class="report-formula-desc">本大項原始加總 ${formatNumber(group2Raw)} 分,最高採計 40 分。</span>
`;
document.getElementById('scoreGroup2').innerText = formatNumber(group2Score);
// 三、國中教育會考 (最高採計 30 分)
let examScore = 0;
let examReportItems = examDefs.map(function(item) {
const score = parseInt(document.getElementById(item.id).value) || 0;
examScore += score;
return `${item.label}:${getExamLevelText(score)} ${score} 分`;
});
let examViolationPoints = parseInt(document.getElementById('examViolation').value) || 0;
let examDeduction = examViolationPoints === 1 ? 0.3 : (examViolationPoints === 2 ? 0.6 : 0);
let group3Score = Math.max(0, Math.min(30, examScore) - examDeduction);
document.getElementById('detailGroup3').innerHTML = `
<div class="report-item-detail">• 各科等級積分:<strong>${formatNumber(group3Score)}</strong> / 30 分
<span class="report-formula-desc">${examReportItems.map(escapeHTML).join('<br>')}<br>違規記點:${examViolationPoints} 點,扣 ${formatNumber(examDeduction)} 分。寫作測驗級分與各科標示為同分比序項目,不列入總積分。</span></div>
`;
document.getElementById('scoreGroup3').innerText = formatNumber(group3Score);
let shortageHTML = "";
let shortageText = "";
let shortageItems = [];
if (gradeConfig.volunteerOpen && volunteer === 0) {
shortageItems.push(`<li>志願序位積分:目前設定為不採計;正式選填志願後可改選第 1~25 志願序位區間。</li>`);
shortageText += `• 志願序位積分:目前不採計,正式選填志願後再調整。\n`;
}
if (punish < 10) {
shortageItems.push(`<li>日常生活表現評量:尚未拿滿,還差 ${formatNumber(10 - punish)} 分。</li>`);
shortageText += `• 日常生活表現評量:還差 ${formatNumber(10 - punish)} 分。\n`;
}
if (absentScore < currentAbsentMax) {
shortageItems.push(`<li>無曠課紀錄的學期:目前已開放學期最高 ${formatNumber(currentAbsentMax)} 分,現在取得 ${formatNumber(absentScore)} 分,還差 ${formatNumber(currentAbsentMax - absentScore)} 分。</li>`);
shortageText += `• 無曠課紀錄的學期:目前已開放學期還差 ${formatNumber(currentAbsentMax - absentScore)} 分。\n`;
}
if (rewardScore < 20) {
let diffPoints = 20 - rewardScore;
let neededJiajiang = Math.ceil(diffPoints / 0.5);
let neededXiaogong = Math.ceil(diffPoints / 1.5);
shortageItems.push(`<li>功過相抵後的獎懲紀錄:目前 ${formatNumber(rewardScore)} 分,距離完整採計上限還差 <strong>${formatNumber(diffPoints)}</strong> 分;此項可在後續學期持續累積。</li>`);
shortageText += `• 功過相抵後的獎懲紀錄:目前 ${formatNumber(rewardScore)} 分,距離完整採計上限還差 ${formatNumber(diffPoints)} 分。\n`;
}
if (serviceScore < currentServiceMax) {
shortageItems.push(`<li>服務學習時數積分:目前已開放學期最高 ${formatNumber(currentServiceMax)} 分,現在取得 ${formatNumber(serviceScore)} 分,還差 ${formatNumber(currentServiceMax - serviceScore)} 分。</li>`);
shortageText += `• 服務學習時數積分:目前已開放學期還差 ${formatNumber(currentServiceMax - serviceScore)} 分。\n`;
}
if (gradeConfig.examOpen && group3Score === 0) {
shortageItems.push(`<li>國中教育會考:目前設定為不採計;取得會考成績後可改選精熟、基礎或待加強。</li>`);
shortageText += `• 國中教育會考:目前不採計,取得成績後再調整。\n`;
}
const multiBuffer = Math.max(0, group2Raw - group2Score);
const officialMultiMax = 40;
const multiGap = Math.max(0, officialMultiMax - group2Score);
let strategyHTML = '';
let strategyText = '';
if (group2Score >= officialMultiMax) {
strategyHTML = `<div style="margin-top: 12px; color:#2c3e50; font-size:0.9em; background:#eefaf1; border:1px solid #c8e6c9; border-radius:6px; padding:10px; line-height:1.5;">✅ <strong>補分策略判讀:</strong>多元學習表現已達採計上限 40 分。即使單一項目未滿,例如獎懲未達 20 分,仍可能已由無曠課、服務學習或本土語認證等項目補足;報告重點可放在「維持無懲罰、無曠課與已取得分數」。${multiBuffer > 0 ? `目前原始分數比採計上限多 ${formatNumber(multiBuffer)} 分,可視為緩衝分數。` : ''}</div>`;
strategyText = `多元學習表現已達採計上限 40 分;單項未滿不一定影響總分,請維持無懲罰、無曠課與既有分數。`;
} else {
let possibleIdeas = [];
if (localLang === 0) possibleIdeas.push('本土語言認證可補 2 分');
if (serviceScore < 10) possibleIdeas.push('服務學習每 3 小時 1 分、每學期最多 2 分');
if (absentScore < 12) possibleIdeas.push('後續學期維持無曠課,每學期可增加 2 分');
if (rewardScore < 20) possibleIdeas.push('獎懲紀錄可透過後續嘉獎、小功或大功累積');
strategyHTML = `<div style="margin-top: 12px; color:#2c3e50; font-size:0.9em; background:#f8fbff; border:1px solid #bbdefb; border-radius:6px; padding:10px; line-height:1.5;">🧭 <strong>補分策略判讀:</strong>多元學習表現距離採計上限 40 分還差 ${formatNumber(multiGap)} 分。可優先看「可掌握、可累積」的項目:${possibleIdeas.length ? possibleIdeas.join(';') : '維持目前已取得項目,並等待後續學期成績或紀錄完成。'}。</div>`;
strategyText = `多元學習表現距離採計上限 40 分還差 ${formatNumber(multiGap)} 分;可從無曠課、服務學習、本土語認證與獎懲紀錄中找可補分項目。`;
}
if (shortageItems.length > 0) {
shortageHTML = `<div style="margin-top: 15px; color: #c0392b; font-weight: bold; font-size:0.95em; border-top: 1px dashed #ccc; padding-top: 10px;">⚠️ 積分提醒:</div><ul class="shortage-list">${shortageItems.join('')}</ul><div style="font-size:0.85em; color:#666; margin-top:5px;">※系統已依目前年級排除尚未發生學期;獎懲、會考與志願序位提醒屬於後續可持續累積或完成的方向。</div>`;
} else {
shortageHTML = `<div style="margin-top: 15px; color: #27ae60; font-weight: bold; font-size:0.95em; border-top: 1px dashed #ccc; padding-top: 10px;">🎉 太完美了!目前所有可輸入項目皆已達到滿分水準!</div>`;
shortageText += `🎉 目前所有可輸入項目皆已達到滿分水準!\n`;
}
document.getElementById('shortageAlertArea').innerHTML = shortageHTML + strategyHTML;
let totalScoreNow = group1Score + group2Score + group3Score;
document.getElementById('reportTotalScore').innerText = formatNumber(totalScoreNow) + " 分";
let cleanName = name || "一般學生";
let weakLabel = weak === 5 ? "偏遠/經濟弱勢 5 分" : (weak === 3 ? "非山非市 3 分" : "不符合 0 分");
let localLabel = local === 5 ? "符合 5 分" : "不符合 0 分";
let localLangLabel = localLang === 2 ? "已通過 2 分" : "未取得或不採計 0 分";
let shortageReportText = shortageText ? shortageText.trim() : "🎉 目前所有可輸入項目皆已達到滿分水準!";
let strategyReportText = strategyText || "";
let domainCopyText = domainCalculations.map(function(item) {
return ` - ${item.name}:平均 ${item.count ? item.avg.toFixed(1) : '未採計'} 分,${item.passed ? '符合' : '不符合或未採計'}(採計:${item.includedText};排除:${item.excludedText})`;
}).join('\n');
let examCopyText = examReportItems.join(';') + `;違規記點 ${examViolationPoints} 點,扣 ${formatNumber(examDeduction)} 分`;
textReportContent =
`【115學年度竹苗區免試入學積分精華報告】\n` +
`班級:${sClass || '──'} 座號:${seat || '──'} 姓名:${cleanName}\n` +
`試算階段:${gradeConfig.label}(系統僅採計已完成學期)\n` +
` \n` +
`一、均衡發展:${formatNumber(group1Score)} / 30 分\n` +
`• 扶助弱勢:${weakLabel}\n` +
`• 就近入學:${localLabel}\n` +
`• 志願序位:${getSelectedText('volunteer')}\n` +
`• 均衡學習:符合 ${passedDomains} / 4 個領域,取得 ${domainScore} 分\n${domainCopyText}\n` +
` \n` +
`二、多元學習表現:${formatNumber(group2Score)} / 40 分\n` +
`• 日常生活表現:${formatNumber(punish)} / 10 分\n` +
`• 無曠課紀錄:${formatNumber(absentScore)} / 12 分(已符合 ${checkedAbsent} 個學期:${absentTermText})\n` +
`• 獎懲紀錄:${formatNumber(rewardScore)} / 20 分(大功 ${formatNumber(totalDG)}、小功 ${formatNumber(totalXG)}、嘉獎 ${formatNumber(totalJJ)})\n` +
`• 服務學習:${formatNumber(serviceScore)} / 10 分(前五學期共 ${formatNumber(serviceHoursTotal)} 小時)\n` +
`• 本土語言認證:${localLangLabel}\n` +
` \n` +
`三、國中教育會考:${formatNumber(group3Score)} / 30 分\n` +
`• ${examCopyText}\n` +
` \n` +
`四、積分提醒\n` +
`${shortageReportText}\n` +
`${strategyReportText ? "\n五、補分策略判讀\n" + strategyReportText + "\n" : ""}` +
` \n` +
`★ 免試入學比序預估總積分:${formatNumber(totalScoreNow)} 分 ★`;
document.getElementById('resultBox').style.display = 'block';
document.getElementById('resultBox').scrollIntoView({ behavior: 'smooth' });
}
function copyTextReport() {
if (!textReportContent) { alert("請先點擊「開始計算總積分」後再複製報告喔!"); return; }
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { console.error(err); }
document.body.removeChild(textarea);
if (ok) { alert("🎉 精華報告已成功複製到剪貼簿!已可直接貼上使用。"); }
else { alert("複製失敗,請手動全選文字進行複製。"); }
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(textReportContent).then(function() {
alert("🎉 精華報告已成功複製到剪貼簿!已可直接貼上使用。");
}).catch(function() { fallbackCopy(textReportContent); });
} else { fallbackCopy(textReportContent); }
}
document.addEventListener('DOMContentLoaded', setupGradeHandlers);
function getCurrentResultForSaving() {
const resultBox = document.getElementById('resultBox');
if (!textReportContent || !resultBox || resultBox.style.display === 'none') {
throw new Error('請先點擊「開始計算總積分」,產生試算報告後再儲存。');
}
const formData = {};
document.querySelectorAll('input, select').forEach(function(el) {
if (!el.id) return;
if (el.id === 'savePassword' || el.id === 'saveBirthday') return;
if (el.type === 'checkbox') formData[el.id || el.name] = el.checked;
else formData[el.id] = el.value;
});
const resultData = {
group1Score: document.getElementById('scoreGroup1') ? document.getElementById('scoreGroup1').innerText : '',
group2Score: document.getElementById('scoreGroup2') ? document.getElementById('scoreGroup2').innerText : '',
group3Score: document.getElementById('scoreGroup3') ? document.getElementById('scoreGroup3').innerText : '',
totalScore: document.getElementById('reportTotalScore') ? document.getElementById('reportTotalScore').innerText : '',
detailGroup1Text: document.getElementById('detailGroup1') ? document.getElementById('detailGroup1').innerText : '',
detailGroup2Text: document.getElementById('detailGroup2') ? document.getElementById('detailGroup2').innerText : '',
detailGroup3Text: document.getElementById('detailGroup3') ? document.getElementById('detailGroup3').innerText : '',
shortageText: document.getElementById('shortageAlertArea') ? document.getElementById('shortageAlertArea').innerText : ''
};
return {
studentId: document.getElementById('saveStudentId').value.trim(),
birthday: document.getElementById('saveBirthday').value.trim(),
password: document.getElementById('savePassword').value,
name: document.getElementById('stu_name').value.trim(),
className: document.getElementById('stu_class').value.trim(),
seatNo: document.getElementById('stu_seat').value.trim(),
gradeStage: (typeof getSelectedText === 'function' && document.getElementById('gradeStage')) ? getSelectedText('gradeStage') : (document.getElementById('gradeStage') ? document.getElementById('gradeStage').value : ''),
group1Score: resultData.group1Score,
group2Score: resultData.group2Score,
group3Score: resultData.group3Score,
totalScore: resultData.totalScore,
reportText: textReportContent,
inputJson: formData,
resultJson: resultData
};
}
function setSaveStatus(message, ok) {
const box = document.getElementById('saveStatus');
if (!box) return;
box.className = 'save-status ' + (ok ? 'ok' : 'err');
box.innerText = message;
}
function saveCurrentResult() {
let payload;
try {
payload = getCurrentResultForSaving();
if (!payload.studentId) throw new Error('請輸入學號。');
if (!payload.birthday) throw new Error('請輸入生日。');
if (!payload.password || payload.password.length < 4) throw new Error('請設定至少 4 碼的個人查詢密碼。');
} catch (err) {
setSaveStatus(err.message, false);
return;
}
if (typeof google === 'undefined' || !google.script || !google.script.run) {
setSaveStatus('目前不是在 Apps Script Web App 環境中,無法寫入 Google 試算表。請依 README 部署後再測試儲存功能。', false);
return;
}
setSaveStatus('儲存中,請稍候……', true);
google.script.run
.withSuccessHandler(function(res) {
if (res && res.success) {
setSaveStatus('✅ 儲存成功!你可以到「查詢已儲存結果」頁面,用學號、生日與查詢密碼查詢本次報告。儲存序號:' + res.recordId, true);
} else {
setSaveStatus((res && res.message) ? res.message : '儲存失敗,請稍後再試。', false);
}
})
.withFailureHandler(function(err) {
setSaveStatus('儲存失敗:' + (err && err.message ? err.message : err), false);
})
.saveResult(payload);
}
function escapeText(value) {
return String(value == null ? '' : value);
}
function setStatus(message, ok) {
const box = document.getElementById('status');
box.className = 'status ' + (ok ? 'ok' : 'err');
box.innerText = message;
}
function getNumberFromRecord(record, keys) {
for (var i = 0; i < keys.length; i++) {
var value = record && record[keys[i]];
if (value !== undefined && value !== null && value !== '') return value;
}
return null;
}
function showSummaryChips(record) {
var g1 = getNumberFromRecord(record, ['group1Score', 'group1_score']);
var g2 = getNumberFromRecord(record, ['group2Score', 'group2_score']);
var g3 = getNumberFromRecord(record, ['group3Score', 'group3_score']);
var total = getNumberFromRecord(record, ['totalScore', 'total_score']);
if (g1 === null && g2 === null && g3 === null && total === null) {
document.getElementById('summaryChips').style.display = 'none';
return;
}
document.getElementById('g1Chip').innerText = g1 === null ? '──' : g1 + ' 分';
document.getElementById('g2Chip').innerText = g2 === null ? '──' : g2 + ' 分';
document.getElementById('g3Chip').innerText = g3 === null ? '──' : g3 + ' 分';
document.getElementById('totalChip').innerText = total === null ? '──' : total + ' 分';
document.getElementById('summaryChips').style.display = 'grid';
}
function lookupSavedResult() {
const payload = {
studentId: document.getElementById('studentId').value.trim(),
birthday: document.getElementById('birthday').value.trim(),
password: document.getElementById('password').value
};
if (!payload.studentId) return setStatus('請輸入學號。', false);
if (!payload.birthday) return setStatus('請輸入生日。', false);
if (!payload.password) return setStatus('請輸入個人查詢密碼。', false);
if (typeof google === 'undefined' || !google.script || !google.script.run) {
return setStatus('目前不是在 Apps Script Web App 環境中,無法查詢試算表資料。', false);
}
document.getElementById('reportCard').style.display = 'none';
setStatus('查詢中,請稍候……', true);
google.script.run
.withSuccessHandler(function(res) {
if (!res || !res.success) {
document.getElementById('reportCard').style.display = 'none';
setStatus(res && res.message ? res.message : '查詢失敗。', false);
return;
}
const r = res.record || {};
setStatus('✅ 查詢成功,以下為最近一次儲存的試算結果。', true);
document.getElementById('recordBadge').innerText =
'儲存時間:' + escapeText(r.timestamp || '──') + '\n' +
'班級:' + escapeText(r.className || '──') + ' 座號:' + escapeText(r.seatNo || '──') + ' 姓名:' + escapeText(r.name || '──') + '\n' +
'試算階段:' + escapeText(r.gradeStage || '──');
showSummaryChips(r);
document.getElementById('reportText').innerText = r.reportText || '此筆資料沒有報告內容。';
document.getElementById('reportCard').style.display = 'block';
document.getElementById('reportCard').scrollIntoView({ behavior: 'smooth' });
})
.withFailureHandler(function(err) {
document.getElementById('reportCard').style.display = 'none';
setStatus('查詢失敗:' + (err && err.message ? err.message : err), false);
})
.lookupResult(payload);
}
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案
6. 部署
部署成 Web App 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論