0. 作品介紹

作者 楊翔紘 三民國中

「輔導空間管理」是一套以 Google Apps Script 與 Google 試算表建置的校園空間預約系統。教師可快速查看空間使用情形、建立晤談預約,管理員則能管理教師、空間、學期及停止預約時段。系統具備衝突檢查、權限控管與學生資料保護,介面簡潔易用,適合校園輔導工作使用。


18 0 0
辦公
封面圖 封面

1. AI 提示詞

把這段指令貼給 AI,讓它幫你生成程式碼。

【請不要複製此提示詞,此為部署教學說明!】
【請不要複製此提示詞,此為部署教學說明!】
【請不要複製此提示詞,此為部署教學說明!】
【請不要複製此提示詞,此為部署教學說明!】
【請不要複製此提示詞,此為部署教學說明!】


# 輔導空間管理|Google Apps Script 精簡版

請先建立空白試算表,接著點選擴充功能-Apps script
只需要貼上兩個檔案:
1. `Code.gs`(將程式碼.gs重新命名為code.gs)
2. `index.html`(新增 HTML 檔案時名稱輸入 `index`)

## 安裝步驟
1. 建立新的 Google Apps Script 專案。
2. 將 `code.gs` 完整貼入後儲存。
3. 新增 HTML 檔案 `index`,貼入 `index.html`。
4. 在上方函式選單選擇 `setupSystem`,按「執行」並完成授權。
5. 執行結果會顯示自動建立的 Google 試算表網址。
6. 選擇「部署」→「新增部署作業」→「網頁應用程式」。
7. 執行身分選「我」;存取權依需求選校內網域或所有人。

## 初始密碼
- 學校共用密碼:School@115
- 管理員帳號:admin
- 管理員密碼:Admin@115

第一次登入後,請由管理員修改共用密碼及管理員密碼,再新增教師身分與教師 PIN。

## 精簡版保留功能
- 學校共用密碼及唯一管理員帳號
- 多位教師身分與 4~6 位數 PIN
- 本週空間總覽與學生資料匿名化
- 新增、修改、取消及簡易每週重複預約
- 管理員可查看、修改及取消全部預約;教師只能修改或取消自己的預約
- 空間衝突檢查與可用空間提示
- 統一的停止預約時段
- 學年度及學期起訖日期管理
- 固定早修、第 1~4 節、午休、第 5~8 節,不需要設定節次時間

2. 資料表規則

請照下面規則建立分頁與欄位。

第1分頁: 系統設定 A 欄: 設定鍵 B 欄: 設定值 C 欄: 更新時間 第2分頁: 教師身分 A 欄: ID B 欄: 姓名 C 欄: 職稱 D 欄: PIN鹽 E 欄: PIN雜湊 F 欄: 啟用 G 欄: 顏色 H 欄: 更新時間 第3分頁: 空間 A 欄: ID B 欄: 空間名稱 C 欄: 啟用 D 欄: 備註 E 欄: 更新時間 第4分頁: 晤談預約 A 欄: ID B 欄: 日期 C 欄: 週次 D 欄: 星期 E 欄: 節次 F 欄: 空間ID G 欄: 教師ID H 欄: 班級 I 欄: 座號 J 欄: 學生姓名 K 欄: 原課程 L 欄: 備註 M 欄: 系列ID N 欄: 建立時間 O 欄: 更新時間 第5分頁: 停止預約 A 欄: ID B 欄: 開始日期 C 欄: 結束日期 D 欄: 星期 E 欄: 節次 F 欄: 空間ID G 欄: 原因 H 欄: 啟用 I 欄: 更新時間

📄 解析結果(自動表格)

分頁:系統設定
A 欄 B 欄 C 欄
設定鍵 設定值 更新時間
分頁:教師身分
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄 G 欄 H 欄
ID 姓名 職稱 PIN鹽 PIN雜湊 啟用 顏色 更新時間
分頁:空間
A 欄 B 欄 C 欄 D 欄 E 欄
ID 空間名稱 啟用 備註 更新時間
分頁:晤談預約
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄 G 欄 H 欄 I 欄 J 欄 K 欄 L 欄 M 欄 N 欄 O 欄
ID 日期 週次 星期 節次 空間ID 教師ID 班級 座號 學生姓名 原課程 備註 系列ID 建立時間 更新時間
分頁:停止預約
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄 G 欄 H 欄 I 欄
ID 開始日期 結束日期 星期 節次 空間ID 原因 啟用 更新時間

3. 開啟 Apps Script

從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。

  1. 試算表上方選單:擴充功能 → Apps Script
  2. 刪掉預設的程式碼(或全部選取覆蓋)
  3. 保留/建立檔案:
    • Code.gs
    • index.html

4. 貼上後端(Code.gs)

把後端程式貼到 Apps Script 的 Code.gs。

Code.gs(後端)
/**
 * 輔導空間管理|Google Apps Script 精簡版
 * 第一次使用請在編輯器執行 setupSystem()。
 */
const CFG = Object.freeze({
  timezone:'Asia/Taipei', sessionHours:8, teacherUnlockHours:4,
  defaultSchoolPassword:'School@115', defaultAdminAccount:'admin', defaultAdminPassword:'Admin@115'
});
const TITLES=['輔導主任','資料組長','輔導組長','特教組長','專任輔導教師','輔導活動教師','特殊教育教師','專業輔導人員','一般教師','其他身分'];
const COLORS=['#315f91','#28776f','#765998','#a56232','#3f7895','#9b5265','#59743f','#75683d'];
const HEADERS={
 '系統設定':['設定鍵','設定值','更新時間'],
 '教師身分':['ID','姓名','職稱','PIN鹽','PIN雜湊','啟用','顏色','更新時間'],
 '空間':['ID','空間名稱','啟用','備註','更新時間'],
 '晤談預約':['ID','日期','週次','星期','節次','空間ID','教師ID','班級','座號','學生姓名','原課程','備註','系列ID','建立時間','更新時間'],
 '停止預約':['ID','開始日期','結束日期','星期','節次','空間ID','原因','啟用','更新時間'],
 '_工作階段':['權杖雜湊','類型','教師ID','教師解鎖到期','工作階段到期','建立時間']
};
let DB_CACHE=null,ROW_CACHE={},SETTINGS_CACHE=null;

function doGet(){ensureReady_();return HtmlService.createHtmlOutputFromFile('index').setTitle('輔導空間管理').addMetaTag('viewport','width=device-width, initial-scale=1')}
function setupSystem(){const ss=ensureSystem_();return {spreadsheetUrl:ss.getUrl(),schoolPassword:CFG.defaultSchoolPassword,adminAccount:CFG.defaultAdminAccount,adminPassword:CFG.defaultAdminPassword}}
function rpc(action,payload,token){
 try{
  ensureReady_();payload=payload||{};
  if(action==='bootstrap')return ok_({brand:publicSettings_(),serverTime:now_()});
  if(action==='schoolLogin')return ok_(schoolLogin_(payload.password));
  if(action==='adminLogin')return ok_(adminLogin_(payload.account,payload.password));
  const session=requireSession_(token);
  if(action==='logout')return ok_(logout_(token));
  if(action==='selectTeacher')return ok_(selectTeacher_(token,session,payload.teacherId,payload.weekStart));
  if(action==='unlockTeacher')return ok_(unlockTeacher_(token,session,payload.pin));
  if(action==='dashboard')return ok_(dashboard_(session,payload.weekStart));
  if(action==='createBooking')return ok_(createBooking_(session,payload));
  if(action==='updateBooking')return ok_(updateBooking_(session,payload));
  if(action==='cancelBooking')return ok_(cancelBooking_(session,payload.id));
  if(action==='adminData')return ok_(adminData_(session));
  if(action==='saveTeacher')return ok_(saveTeacher_(session,payload));
  if(action==='saveSpace')return ok_(saveSpace_(session,payload));
  if(action==='saveBlock')return ok_(saveBlock_(session,payload));
  if(action==='saveSettings')return ok_(saveSettings_(session,payload));
  throw fail_('找不到指定功能',404);
 }catch(error){console.error(error&&error.stack||error);return {ok:false,status:Number(error.status)||500,error:error.message||'系統發生錯誤'}}
}

function ensureSystem_(){
 const props=PropertiesService.getScriptProperties();let ss=null,id=props.getProperty('SPREADSHEET_ID');
 if(id){try{ss=SpreadsheetApp.openById(id)}catch(ignore){}}
 if(!ss){ss=SpreadsheetApp.getActiveSpreadsheet()||SpreadsheetApp.create('輔導空間管理資料庫');props.setProperty('SPREADSHEET_ID',ss.getId())}
 if(!props.getProperty('PEPPER'))props.setProperty('PEPPER',Utilities.getUuid()+Utilities.getUuid());
 ss.setSpreadsheetTimeZone(CFG.timezone);
 Object.keys(HEADERS).forEach(name=>ensureSheet_(ss,name,HEADERS[name]));
 const sessions=ss.getSheetByName('_工作階段');if(!sessions.isSheetHidden())sessions.hideSheet();
 if(!setting_('schoolName')){
  setSetting_('schoolName','新竹市預設國中');setSetting_('systemName','輔導空間管理');
  setSecret_('schoolPassword',CFG.defaultSchoolPassword);setSetting_('adminAccount',CFG.defaultAdminAccount);setSecret_('adminPassword',CFG.defaultAdminPassword);
  const current=academicDefault_();Object.keys(current).forEach(key=>setSetting_(key,current[key]));
 }
 return ss
}
function ensureReady_(){if(!PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'))ensureSystem_()}
function ensureSheet_(ss,name,headers){let sh=ss.getSheetByName(name);if(!sh)sh=ss.insertSheet(name);if(sh.getLastRow()===0)sh.getRange(1,1,1,headers.length).setValues([headers]);sh.setFrozenRows(1);sh.getRange(1,1,1,headers.length).setBackground(name[0]==='_'?'#667085':'#285b84').setFontColor('#fff').setFontWeight('bold');sh.autoResizeColumns(1,headers.length);return sh}
function db_(){return DB_CACHE||(DB_CACHE=SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID')))}
function sheet_(name){return db_().getSheetByName(name)}
function rows_(name){if(ROW_CACHE[name])return ROW_CACHE[name];const sh=sheet_(name),last=sh.getLastRow();return ROW_CACHE[name]=last<2?[]:sh.getRange(2,1,last-1,HEADERS[name].length).getValues()}
function invalidate_(name){delete ROW_CACHE[name];if(name==='系統設定')SETTINGS_CACHE=null}
function append_(name,row){sheet_(name).appendRow(row);invalidate_(name)}
function appendMany_(name,rows){if(!rows.length)return;const sh=sheet_(name);sh.getRange(sh.getLastRow()+1,1,rows.length,HEADERS[name].length).setValues(rows);invalidate_(name)}
function replaceRow_(name,index,row){sheet_(name).getRange(index+2,1,1,HEADERS[name].length).setValues([row]);invalidate_(name)}
function now_(){return new Date().toISOString()}
function uuid_(){return Utilities.getUuid()}
function ok_(data){return {ok:true,data:data}}
function fail_(message,status){const error=new Error(message);error.status=status||400;return error}
function bool_(v){return v===true||String(v).toUpperCase()==='TRUE'}
function isoDate_(date){return Utilities.formatDate(date,CFG.timezone,'yyyy-MM-dd')}
function normalizeDate_(value){if(value instanceof Date&&!isNaN(value.getTime()))return isoDate_(value);const original=String(value||'').trim(),text=original.replace(/[/.]/g,'-'),m=text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);if(!m){const parsed=new Date(original);if(!isNaN(parsed.getTime()))return isoDate_(parsed);throw fail_('日期格式不正確')}const date=new Date(Number(m[1]),Number(m[2])-1,Number(m[3]),12);if(date.getFullYear()!==Number(m[1])||date.getMonth()!==Number(m[2])-1||date.getDate()!==Number(m[3]))throw fail_('日期格式不正確');return isoDate_(date)}
function safeDate_(value){try{return normalizeDate_(value)}catch(ignore){return ''}}
function parseDate_(value){const normalized=normalizeDate_(value),m=normalized.split('-').map(Number);return new Date(m[0],m[1]-1,m[2],12)}
function digest_(value){return Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256,String(value),Utilities.Charset.UTF_8).map(v=>(v+256)%256).map(v=>('0'+v.toString(16)).slice(-2)).join('')}
function hash_(value,salt){return digest_(salt+'|'+value+'|'+PropertiesService.getScriptProperties().getProperty('PEPPER'))}
function equal_(a,b){a=String(a||'');b=String(b||'');if(a.length!==b.length)return false;let d=0;for(let i=0;i<a.length;i++)d|=a.charCodeAt(i)^b.charCodeAt(i);return d===0}

function settingsMap_(){if(SETTINGS_CACHE)return SETTINGS_CACHE;const map={};rows_('系統設定').forEach(row=>{const key=String(row[0]||'').trim();if(key)map[key]=row[1]});return SETTINGS_CACHE=map}
function setting_(key){return settingsMap_()[key]||''}
function setSetting_(key,value){const data=rows_('系統設定'),i=data.findIndex(row=>String(row[0])===key),row=[key,String(value),now_()];i<0?append_('系統設定',row):replaceRow_('系統設定',i,row)}
function setSecret_(key,value){const salt=uuid_();setSetting_(key,JSON.stringify({salt:salt,hash:hash_(value,salt)}))}
function secretMatches_(key,value){try{const saved=JSON.parse(setting_(key));return equal_(hash_(String(value||''),saved.salt),saved.hash)}catch(ignore){return false}}
function academicDefault_(){const d=new Date(),month=Number(Utilities.formatDate(d,CFG.timezone,'M')),year=Number(Utilities.formatDate(d,CFG.timezone,'yyyy')),academicYear=year-1911-(month<8?1:0),semester=month>=8||month<=1?1:2,startYear=academicYear+1911+(semester===2?1:0);return {academicYear:academicYear,semester:semester,startDate:semester===1?startYear+'-09-01':startYear+'-02-01',endDate:semester===1?(startYear+1)+'-01-31':startYear+'-06-30'}}
function publicSettings_(){const s=settingsMap_();return {schoolName:String(s.schoolName||'新竹市預設國中'),systemName:String(s.systemName||'輔導空間管理'),academicYear:Number(s.academicYear),semester:Number(s.semester),startDate:normalizeDate_(s.startDate),endDate:normalizeDate_(s.endDate)}}

function teachers_(){return rows_('教師身分').map((r,i)=>({row:i,id:String(r[0]||'').trim(),name:String(r[1]||'').trim(),title:String(r[2]||''),pinSalt:String(r[3]||''),pinHash:String(r[4]||''),active:bool_(r[5]),color:String(r[6]||'')||COLORS[i%COLORS.length],updatedAt:r[7]})).filter(t=>t.id&&t.name)}
function publicTeacher_(t){return {id:t.id,name:t.name,title:t.title,active:t.active,color:t.color}}
function spaces_(){return rows_('空間').map((r,i)=>({row:i,id:String(r[0]||'').trim(),name:String(r[1]||'').trim(),active:bool_(r[2]),note:String(r[3]||''),updatedAt:r[4]})).filter(s=>s.id&&s.name)}
function periods_(){return [{id:'early',label:'早修'},{id:'1',label:'第 1 節'},{id:'2',label:'第 2 節'},{id:'3',label:'第 3 節'},{id:'4',label:'第 4 節'},{id:'lunch',label:'午休'},{id:'5',label:'第 5 節'},{id:'6',label:'第 6 節'},{id:'7',label:'第 7 節'},{id:'8',label:'第 8 節'}]}
function bookings_(){return rows_('晤談預約').map((r,i)=>({row:i,id:String(r[0]||'').trim(),date:safeDate_(r[1]),week:Number(r[2]),day:Number(r[3]),period:String(r[4]||''),spaceId:String(r[5]||''),teacherId:String(r[6]||''),studentClass:String(r[7]||''),seat:String(r[8]||''),studentName:String(r[9]||''),subject:String(r[10]||''),note:String(r[11]||''),seriesId:String(r[12]||''),createdAt:r[13],updatedAt:r[14]})).filter(b=>b.id&&b.date)}
function blocks_(){return rows_('停止預約').map((r,i)=>({row:i,id:String(r[0]||'').trim(),startDate:safeDate_(r[1]),endDate:safeDate_(r[2]),day:String(r[3]||''),period:String(r[4]||''),spaceId:String(r[5]||''),reason:String(r[6]||''),active:bool_(r[7]),updatedAt:r[8]})).filter(b=>b.id&&b.startDate&&b.endDate)}

function newSession_(type,teacherId,verifiedUntil){const raw=uuid_()+uuid_(),expires=new Date(Date.now()+CFG.sessionHours*3600000).toISOString(),lock=LockService.getScriptLock();if(!lock.tryLock(20000))throw fail_('目前登入人數較多,請稍後再試',409);try{cleanupSessions_();append_('_工作階段',[digest_(raw),type,teacherId||'',verifiedUntil||'',expires,now_()]);return raw}finally{lock.releaseLock()}}
function schoolLogin_(password){if(!secretMatches_('schoolPassword',password))throw fail_('學校共用密碼不正確',401);const token=newSession_('school','');return {token:token,teachers:teachers_().filter(t=>t.active).map(publicTeacher_),brand:publicSettings_()}}
function adminLogin_(account,password){if(String(account||'')!==setting_('adminAccount')||!secretMatches_('adminPassword',password))throw fail_('管理員帳號或密碼不正確',401);const token=newSession_('admin',''),session=requireSession_(token);return {token:token,user:{type:'admin',name:'系統管理員'},brand:publicSettings_(),dashboard:dashboard_(session,isoDate_(new Date())),adminData:adminData_(session)}}
function cleanupSessions_(){const sh=sheet_('_工作階段'),data=rows_('_工作階段').filter(r=>r[0]&&new Date(r[4]).getTime()>Date.now());if(sh.getLastRow()>1)sh.getRange(2,1,sh.getLastRow()-1,6).clearContent();if(data.length)sh.getRange(2,1,data.length,6).setValues(data);invalidate_('_工作階段')}
function requireSession_(token){if(!token)throw fail_('請重新進入系統',401);const wanted=digest_(token),data=rows_('_工作階段'),i=data.findIndex(r=>String(r[0])===wanted&&new Date(r[4]).getTime()>Date.now());if(i<0)throw fail_('使用時間已到,請重新進入系統',401);const r=data[i];return {row:i,tokenHash:wanted,type:String(r[1]),teacherId:String(r[2]),verifiedUntil:String(r[3]),expires:String(r[4])}}
function rewriteSession_(s){replaceRow_('_工作階段',s.row,[s.tokenHash,s.type,s.teacherId,s.verifiedUntil,s.expires,now_()])}
function logout_(token){const lock=LockService.getScriptLock();if(!lock.tryLock(20000))throw fail_('目前系統忙碌,請稍後再試',409);try{const sh=sheet_('_工作階段'),data=rows_('_工作階段').filter(r=>r[0]&&String(r[0])!==digest_(token));if(sh.getLastRow()>1)sh.getRange(2,1,sh.getLastRow()-1,6).clearContent();if(data.length)sh.getRange(2,1,data.length,6).setValues(data);invalidate_('_工作階段');return {loggedOut:true}}finally{lock.releaseLock()}}
function selectTeacher_(token,session,id,weekStart){if(session.type!=='school')throw fail_('管理員不需要選擇教師');const t=teachers_().find(x=>x.id===String(id)&&x.active);if(!t)throw fail_('找不到教師身分');const selectedToken=newSession_('school',t.id),selectedSession=requireSession_(selectedToken);return {token:selectedToken,teacher:publicTeacher_(t),dashboard:dashboard_(selectedSession,weekStart||isoDate_(new Date()))}}
function unlockTeacher_(token,session,pin){const t=teachers_().find(x=>x.id===session.teacherId&&x.active);if(!t||!/^\d{4,6}$/.test(String(pin||''))||!equal_(hash_(String(pin),t.pinSalt),t.pinHash))throw fail_('教師 PIN 不正確',403);const verifiedUntil=new Date(Date.now()+CFG.teacherUnlockHours*3600000).toISOString(),verifiedToken=newSession_('school',t.id,verifiedUntil);return {token:verifiedToken,verified:true,verifiedUntil:verifiedUntil}}
function isVerified_(s){return s.type==='admin'||(s.teacherId&&new Date(s.verifiedUntil).getTime()>Date.now())}
function requireTeacher_(s){if(s.type!=='school'||!s.teacherId)throw fail_('請先選擇教師身分',403);const teacher=teachers_().find(t=>t.id===s.teacherId&&t.active);if(!teacher)throw fail_('此教師身分已停用,請重新進入系統',403);return teacher}
function requireVerified_(s){const t=requireTeacher_(s);if(!isVerified_(s))throw fail_('此操作需要先輸入教師 PIN',403);return t}
function requireAdmin_(s){if(s.type!=='admin')throw fail_('只有管理員可以執行此操作',403)}

function monday_(value){const d=parseDate_(value),day=d.getDay()||7;d.setDate(d.getDate()-day+1);return d}
function semesterFirstMonday_(){const settings=publicSettings_(),d=parseDate_(settings.startDate),day=d.getDay();if(day===0)d.setDate(d.getDate()+1);else if(day===6)d.setDate(d.getDate()+2);else d.setDate(d.getDate()-day+1);return d}
function semesterLastMonday_(){return monday_(publicSettings_().endDate)}
function normalizedWeekStart_(value){const requested=monday_(value),first=semesterFirstMonday_(),last=semesterLastMonday_();if(requested<first)return first;if(requested>last)return last;return requested}
function weekNumber_(dateValue){const start=semesterFirstMonday_(),d=normalizedWeekStart_(dateValue);return Math.floor((d-start)/604800000)+1}
function dayNumber_(dateValue){const day=parseDate_(dateValue).getDay();return day>=1&&day<=5?day:0}
function isBlocked_(date,period,spaceId){const day=dayNumber_(date);return blocks_().find(b=>b.active&&date>=b.startDate&&date<=b.endDate&&(!b.day||Number(b.day)===day)&&(!b.period||b.period===period)&&(!b.spaceId||b.spaceId===spaceId))}
function conflict_(date,period,spaceId,exceptId){if(isBlocked_(date,period,spaceId))return true;return bookings_().some(b=>b.id!==exceptId&&b.date===date&&b.period===period&&b.spaceId===spaceId)}
function alternatives_(date,period){return spaces_().filter(s=>s.active&&!conflict_(date,period,s.id,'')).map(s=>({id:s.id,name:s.name}))}
function dashboard_(session,weekStart){
 const start=normalizedWeekStart_(weekStart||isoDate_(new Date())),end=new Date(start);end.setDate(end.getDate()+4);const from=isoDate_(start),to=isoDate_(end),verified=isVerified_(session),allBookings=bookings_().filter(b=>b.date>=from&&b.date<=to),teacherId=session.teacherId;
 const visible=allBookings.map(b=>Object.assign({},b,{studentName:(session.type==='admin'||(verified&&b.teacherId===teacherId))?b.studentName:''}));
 return {brand:publicSettings_(),serverTime:now_(),sessionType:session.type,weekStart:from,week:weekNumber_(from),verified:verified,teacherId:teacherId,teachers:teachers_().map(publicTeacher_),spaces:spaces_(),periods:periods_(),bookings:visible,blocks:blocks_().filter(b=>b.active&&b.endDate>=from&&b.startDate<=to)}
}
function validateBookingInput_(p,allowInactiveSpaceId){const settings=publicSettings_(),date=normalizeDate_(p.date),space=spaces_().find(s=>s.id===String(p.spaceId)),studentName=String(p.studentName||'').trim(),subject=String(p.subject||'').trim(),note=String(p.note||'').trim();if(!/^\d{3}$/.test(String(p.studentClass||'')))throw fail_('班級請輸入三位數字,例如 801');if(!/^\d{1,2}$/.test(String(p.seat||'')))throw fail_('請輸入正確座號');if(!studentName||!subject)throw fail_('請填寫學生姓名與原課程');if(studentName.length>40||subject.length>40||note.length>500)throw fail_('學生姓名、原課程或備註內容過長');if(date<normalizeDate_(settings.startDate)||date>normalizeDate_(settings.endDate))throw fail_('預約日期必須在本學期起訖日期內');if(!space||(!space.active&&space.id!==String(allowInactiveSpaceId||'')))throw fail_('請選擇可用空間');if(!periods_().some(x=>x.id===String(p.period)))throw fail_('請選擇節次');if(!dayNumber_(date))throw fail_('只能預約星期一至星期五');return date}
function bookingRow_(id,p,teacherId,seriesId,created){const date=normalizeDate_(p.date);return [id,date,weekNumber_(date),dayNumber_(date),String(p.period),String(p.spaceId),teacherId,String(p.studentClass),String(p.seat),String(p.studentName).trim(),String(p.subject).trim(),String(p.note||'').trim(),seriesId||'',created||now_(),now_()]}
function createBooking_(session,p){
 const teacher=requireTeacher_(session);validateBookingInput_(p);const dates=[],start=parseDate_(p.date),repeatEnd=p.repeatEndDate?parseDate_(p.repeatEndDate):start;
 const semesterEnd=parseDate_(publicSettings_().endDate);if(repeatEnd<start)throw fail_('重複預約結束日期不可早於開始日期');if(repeatEnd>semesterEnd)throw fail_('重複預約結束日期不可超過本學期結束日期');const repeatCount=Math.floor((repeatEnd-start)/604800000)+1;if(repeatCount>30)throw fail_('單次最多建立 30 週的重複預約');for(let d=new Date(start);d<=repeatEnd;d.setDate(d.getDate()+7))dates.push(isoDate_(d));
 const seriesId=dates.length>1?uuid_():'',created=[],conflicts=[],newRows=[],period=String(p.period),spaceId=String(p.spaceId),lock=LockService.getScriptLock();if(!lock.tryLock(20000))throw fail_('目前有人正在儲存,請稍後再試',409);
 try{const existing=bookings_(),blocks=blocks_(),spaces=spaces_().filter(s=>s.active);
  const blocked=(date,targetSpace)=>{const day=dayNumber_(date);return blocks.some(b=>b.active&&date>=b.startDate&&date<=b.endDate&&(!b.day||Number(b.day)===day)&&(!b.period||b.period===period)&&(!b.spaceId||b.spaceId===targetSpace))};
  const occupied=(date,targetSpace)=>blocked(date,targetSpace)||existing.some(b=>b.date===date&&b.period===period&&b.spaceId===targetSpace)||newRows.some(r=>r[1]===date&&r[4]===period&&r[5]===targetSpace);
  dates.forEach(date=>{if(occupied(date,spaceId)){conflicts.push({date:date,alternatives:spaces.filter(s=>!occupied(date,s.id)).map(s=>({id:s.id,name:s.name}))});return}const id=uuid_();newRows.push(bookingRow_(id,Object.assign({},p,{date:date}),teacher.id,seriesId,''));created.push(id)});appendMany_('晤談預約',newRows)
 }finally{lock.releaseLock()}return {created:created.length,conflicts:conflicts}
}
function updateBooking_(session,p){const teacher=session.type==='admin'?null:requireVerified_(session),lock=LockService.getScriptLock();if(!lock.tryLock(20000))throw fail_('目前有人正在儲存,請稍後再試',409);try{const b=bookings_().find(x=>x.id===String(p.id));if(!b)throw fail_('找不到預約',404);if(session.type!=='admin'&&b.teacherId!==teacher.id)throw fail_('不能修改其他教師的預約',403);const date=normalizeDate_(p.date),period=String(p.period),spaceId=String(p.spaceId),sameSlot=date===b.date&&period===b.period&&spaceId===b.spaceId;validateBookingInput_(p,sameSlot?b.spaceId:'');if(!sameSlot&&conflict_(date,period,spaceId,b.id))throw fail_('此空間時段無法預約;可用空間:'+(alternatives_(date,period).map(x=>x.name).join('、')||'目前沒有'),409);replaceRow_('晤談預約',b.row,bookingRow_(b.id,p,b.teacherId,b.seriesId,b.createdAt));audit_(session.type==='admin'?'系統管理員':teacher.name,'修改預約','晤談預約',b.id);return {updated:true}}finally{lock.releaseLock()}}
function cancelBooking_(session,id){const teacher=session.type==='admin'?null:requireVerified_(session),lock=LockService.getScriptLock();if(!lock.tryLock(20000))throw fail_('目前有人正在儲存,請稍後再試',409);try{const b=bookings_().find(x=>x.id===String(id));if(!b)throw fail_('找不到預約',404);if(session.type!=='admin'&&b.teacherId!==teacher.id)throw fail_('不能取消其他教師的預約',403);deleteRow_('晤談預約',b.row);audit_(session.type==='admin'?'系統管理員':teacher.name,'取消預約','晤談預約',b.id);return {cancelled:true}}finally{lock.releaseLock()}}
function deleteRow_(name,index){sheet_(name).deleteRow(index+2);invalidate_(name)}

function audit_(){}
function adminData_(session){requireAdmin_(session);return {settings:publicSettings_(),adminAccount:setting_('adminAccount'),teachers:teachers_().map(publicTeacher_),spaces:spaces_(),blocks:blocks_()}}
function saveTeacher_(s,p){requireAdmin_(s);const name=String(p.name||'').trim(),title=String(p.title||''),pin=String(p.pin||''),id=String(p.id||''),color=String(p.color||'');if(!name||name.length>40||TITLES.indexOf(title)<0)throw fail_('請確認教師姓名與職稱');let all=teachers_(),old=all.find(t=>t.id===id),salt=old?old.pinSalt:uuid_(),hash=old?old.pinHash:'';if(id&&!old)throw fail_('找不到要修改的教師',404);if(all.some(t=>t.id!==id&&t.name.toLowerCase()===name.toLowerCase()))throw fail_('教師姓名不可重複');if(pin){if(!/^\d{4,6}$/.test(pin))throw fail_('教師 PIN 必須為 4 至 6 位數字');salt=uuid_();hash=hash_(pin,salt)}if(!old&&!hash)throw fail_('新增教師時必須設定 PIN');const row=[old?old.id:uuid_(),name,title,salt,hash,p.active!==false,/^#[0-9a-f]{6}$/i.test(color)?color:COLORS[all.length%COLORS.length],now_()];old?replaceRow_('教師身分',old.row,row):append_('教師身分',row);audit_('系統管理員',old?'修改':'新增','教師身分',name);return {saved:true}}
function saveSpace_(s,p){requireAdmin_(s);const name=String(p.name||'').trim(),id=String(p.id||''),note=String(p.note||'').trim(),all=spaces_(),old=all.find(x=>x.id===id);if(!name||name.length>60||note.length>300)throw fail_('請確認空間名稱與備註長度');if(id&&!old)throw fail_('找不到要修改的空間',404);if(all.some(x=>x.id!==id&&x.name.toLowerCase()===name.toLowerCase()))throw fail_('空間名稱不可重複');const row=[old?old.id:uuid_(),name,p.active!==false,note,now_()];old?replaceRow_('空間',old.row,row):append_('空間',row);audit_('系統管理員',old?'修改':'新增','空間',name);return {saved:true}}
function saveBlock_(s,p){requireAdmin_(s);const startDate=normalizeDate_(p.startDate),endDate=normalizeDate_(p.endDate),day=String(p.day||''),period=String(p.period||''),spaceId=String(p.spaceId||''),reason=String(p.reason||'').trim(),id=String(p.id||''),all=blocks_(),old=all.find(x=>x.id===id);if(id&&!old)throw fail_('找不到要修改的停止時段',404);if(endDate<startDate||!reason||reason.length>200)throw fail_('請確認停止日期與原因');if(day&&!/^[1-5]$/.test(day))throw fail_('停止預約的星期不正確');if(period&&!periods_().some(x=>x.id===period))throw fail_('停止預約的節次不正確');if(spaceId&&!spaces_().some(x=>x.id===spaceId))throw fail_('停止預約的空間不存在');const row=[old?old.id:uuid_(),startDate,endDate,day,period,spaceId,reason,p.active!==false,now_()];old?replaceRow_('停止預約',old.row,row):append_('停止預約',row);audit_('系統管理員',old?'修改':'新增','停止預約',reason);return {saved:true}}
function saveSettings_(s,p){requireAdmin_(s);const academicYear=Number(p.academicYear),semester=Number(p.semester),adminAccount=String(p.adminAccount||'').trim(),schoolName=String(p.schoolName||'').trim(),systemName=String(p.systemName||'').trim(),schoolPassword=String(p.schoolPassword||''),adminPassword=String(p.adminPassword||'');if(!schoolName||!systemName||schoolName.length>80||systemName.length>80)throw fail_('請確認學校名稱與系統名稱');if(!Number.isInteger(academicYear)||academicYear<1||academicYear>999)throw fail_('請輸入正確學年度');if(semester!==1&&semester!==2)throw fail_('學期只能選擇 1 或 2');if(!adminAccount||adminAccount.length>80)throw fail_('請確認管理員帳號');if(schoolPassword&&schoolPassword.length<6)throw fail_('學校共用密碼至少 6 個字元');if(adminPassword&&adminPassword.length<8)throw fail_('管理員密碼至少 8 個字元');const startDate=normalizeDate_(p.startDate),endDate=normalizeDate_(p.endDate);if(endDate<startDate)throw fail_('學期結束日期不可早於開始日期');setSetting_('schoolName',schoolName);setSetting_('systemName',systemName);setSetting_('academicYear',academicYear);setSetting_('semester',semester);setSetting_('startDate',startDate);setSetting_('endDate',endDate);if(schoolPassword)setSecret_('schoolPassword',schoolPassword);setSetting_('adminAccount',adminAccount);if(adminPassword)setSecret_('adminPassword',adminPassword);return {saved:true,dashboard:dashboard_(s,isoDate_(new Date()))}}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

把前端程式貼到 Apps Script 的 index.html。

index.html(前端)
<!doctype html>
<html lang="zh-Hant">
<head>
  <base target="_top">
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>輔導空間管理</title>
  <style>
    :root{--ink:#17243a;--muted:#667085;--line:#d9e1ea;--soft:#f4f7fa;--blue:#285b84;--blue2:#eaf2f9;--green:#28776f;--red:#b34848;--redbg:#fff0f0;--white:#fff;--shadow:0 8px 24px rgba(28,46,70,.08)}
    *{box-sizing:border-box}html{font-size:16px}body{margin:0;background:var(--soft);color:var(--ink);font-family:"Noto Sans TC","Microsoft JhengHei",sans-serif;line-height:1.55}button,input,select,textarea{font:inherit}button{cursor:pointer}button:disabled{opacity:.55;cursor:wait}.hidden{display:none!important}
    .login{min-height:100vh;display:grid;place-items:center;padding:24px}.login-card{width:min(520px,100%);background:#fff;border:1px solid var(--line);border-radius:20px;padding:36px;box-shadow:var(--shadow)}.login-brand small{display:block;color:var(--blue);font-weight:700;letter-spacing:.08em}.login-brand h1{font-size:1.8rem;margin:6px 0}.login-brand p{margin:0 0 28px;color:var(--muted)}.login-switch{display:flex;gap:8px;margin-bottom:20px}.login-switch button{flex:1}.login-stage{display:grid;gap:16px}.login-stage h2{font-size:1.2rem;margin:0}.login-note{background:var(--blue2);padding:12px 14px;border-radius:10px;color:#36506f}.error{min-height:24px;color:var(--red);margin:0}.text-button{border:0;background:none;color:var(--blue);padding:4px;font-weight:700}
    label{display:grid;gap:6px;font-weight:600;color:#344054}input,select,textarea{width:100%;min-height:44px;border:1px solid #cbd5df;border-radius:9px;padding:9px 11px;background:#fff;color:var(--ink)}input:focus,select:focus,textarea:focus{outline:3px solid rgba(40,91,132,.14);border-color:var(--blue)}textarea{resize:vertical}.primary,.secondary,.danger,.ghost{min-height:42px;border-radius:9px;padding:9px 15px;font-weight:700}.primary{border:1px solid var(--blue);background:var(--blue);color:#fff}.secondary{border:1px solid #aac0d3;background:var(--blue2);color:#254d70}.ghost{border:1px solid var(--line);background:#fff;color:var(--ink)}.danger{border:1px solid #e4b0b0;background:var(--redbg);color:#8c3030}.wide-button{width:100%}
    .app{min-height:100vh}.topbar{height:72px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;position:sticky;top:0;z-index:20}.brand strong{font-size:1.05rem}.brand span{display:block;color:var(--muted);font-size:.9rem}.top-meta{display:flex;gap:18px;align-items:center}.clock{font-variant-numeric:tabular-nums;color:#475467}.user-chip{background:var(--blue2);border-radius:999px;padding:7px 12px;font-weight:700}.layout{display:grid;grid-template-columns:220px minmax(0,1fr);min-height:calc(100vh - 72px)}.sidebar{background:#fff;border-right:1px solid var(--line);padding:18px 12px}.nav{display:grid;gap:6px;position:sticky;top:90px}.nav button{border:0;background:transparent;text-align:left;padding:11px 13px;border-radius:9px;color:#475467;font-weight:700}.nav button.active{background:var(--blue2);color:var(--blue)}.nav .admin-nav{margin-top:14px;border-top:1px solid var(--line);padding-top:16px}.main{padding:24px;min-width:0}.view{display:none}.view.active{display:block}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:18px}.page-head h1{font-size:1.55rem;margin:0}.page-head p{color:var(--muted);margin:4px 0 0}.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
    .card{background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 2px 10px rgba(28,46,70,.04)}.toolbar{padding:14px 16px;display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:12px}.week-label{font-size:1.08rem;font-weight:800}.schedule-wrap{overflow-x:auto;background:#fff;border:1px solid var(--line);border-radius:14px}.schedule{width:100%;min-width:960px;border-collapse:separate;border-spacing:0;table-layout:fixed}.schedule th,.schedule td{border-right:1px solid var(--line);border-bottom:1px solid var(--line);vertical-align:top}.schedule th{background:#eef3f7;padding:10px}.schedule th:first-child{width:120px}.schedule td:first-child{background:#f7f9fb;position:sticky;left:0;z-index:2;text-align:center;vertical-align:middle;padding:10px}.schedule td{height:94px;padding:6px}.booking-card{border:1px solid #c8d7e5;border-left:4px solid var(--blue);border-radius:8px;padding:7px 8px;margin-bottom:5px;background:#fff;text-align:left;width:100%;min-height:0}.booking-card strong,.booking-card span{display:block}.booking-card span{font-size:.86rem;color:#526174}.block-card{background:var(--redbg);border:1px solid #e8b9b9;border-left:4px solid var(--red);border-radius:8px;padding:7px;color:#7d2e2e;font-size:.86rem}.empty-hint{padding:48px 20px;text-align:center;color:var(--muted)}
    .grid{display:grid;gap:14px}.grid.two{grid-template-columns:repeat(2,minmax(0,1fr))}.grid.three{grid-template-columns:repeat(3,minmax(0,1fr))}.panel{padding:20px}.panel h2{font-size:1.15rem;margin:0 0 14px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.form-grid .span2{grid-column:span 2}.form-grid .span3{grid-column:1/-1}.check{display:flex;align-items:center;gap:8px}.check input{width:auto;min-height:0}.list{display:grid;gap:10px}.list-item{display:flex;gap:14px;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.list-item:last-child{border-bottom:0}.list-main{min-width:0}.list-main strong{display:block}.list-main span{color:var(--muted);font-size:.9rem}.nowrap{white-space:nowrap}.unlock{padding:12px 14px;background:#fff7df;border:1px solid #ead79b;border-radius:10px;color:#70591e;margin-bottom:14px}.admin-section{margin-bottom:16px}.admin-section summary{cursor:pointer;padding:16px 18px;font-size:1.05rem;font-weight:800}.admin-section>div{padding:0 18px 18px}.compact-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;align-items:end;margin-bottom:14px}.compact-form button{min-width:96px}.mini-list{border:1px solid var(--line);border-radius:10px;overflow:hidden}.toast{position:fixed;right:22px;bottom:22px;max-width:380px;background:#1f2937;color:#fff;padding:12px 16px;border-radius:10px;box-shadow:var(--shadow);opacity:0;transform:translateY(10px);pointer-events:none;transition:.2s;z-index:50}.toast.show{opacity:1;transform:none}.modal-backdrop{position:fixed;inset:0;background:rgba(23,36,58,.45);display:none;place-items:center;padding:18px;z-index:40}.modal-backdrop.open{display:grid}.modal{width:min(620px,100%);max-height:90vh;overflow:auto;background:#fff;border-radius:16px;padding:22px}.modal-head{display:flex;justify-content:space-between;gap:12px;align-items:start}.modal-head h2{margin:0}.close{border:0;background:#eef2f5;border-radius:50%;width:38px;height:38px;font-size:1.2rem}.detail-lines{display:grid;gap:9px;margin:18px 0}.detail-lines div{display:grid;grid-template-columns:100px 1fr;gap:10px}.detail-lines dt{color:var(--muted)}.detail-lines dd{margin:0;font-weight:600}
    @media(max-width:900px){.layout{grid-template-columns:1fr}.sidebar{border-right:0;border-bottom:1px solid var(--line);padding:8px;overflow-x:auto}.nav{display:flex;position:static;min-width:max-content}.nav button{white-space:nowrap}.nav .admin-nav{margin:0 0 0 8px;border:0;border-left:1px solid var(--line);padding:11px 13px}.main{padding:16px}.form-grid,.grid.two,.grid.three{grid-template-columns:1fr}.form-grid .span2,.form-grid .span3{grid-column:auto}.clock{display:none}.topbar{padding:0 14px}.page-head{align-items:start;flex-direction:column}}
    @media(max-width:560px){html{font-size:15px}.login-card{padding:24px 18px}.top-meta{gap:8px}.brand span{display:none}.user-chip{max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toolbar{align-items:flex-start;flex-direction:column}.list-item{align-items:flex-start;flex-direction:column}.list-item .actions{width:100%}.list-item .actions button{flex:1}.main{padding:12px}.panel{padding:16px}}
  </style>
</head>
<body>
  <section class="login" id="loginScreen">
    <div class="login-card">
      <div class="login-brand"><small id="loginSchool">新竹市預設國中</small><h1 id="loginSystem">輔導空間管理</h1><p>輸入校內共用密碼即可使用。</p></div>
      <div class="login-switch"><button class="secondary" id="schoolMode" type="button">教師使用</button><button class="ghost" id="adminMode" type="button">管理員</button></div>
      <form class="login-stage" id="schoolLoginForm"><label>學校共用密碼<input id="schoolPassword" type="password" required autocomplete="current-password"></label><p class="error" id="schoolError"></p><button class="primary wide-button" type="submit">繼續</button></form>
      <form class="login-stage hidden" id="teacherSelectForm"><h2>選擇教師身分</h2><label>教師姓名<select id="teacherSelect" required></select></label><p class="login-note">查看完整學生姓名、修改或取消預約時,系統才會要求教師 PIN。</p><p class="error" id="teacherError"></p><button class="primary wide-button" type="submit">進入系統</button><button class="text-button" id="backSchoolLogin" type="button">返回</button></form>
      <form class="login-stage hidden" id="adminLoginForm"><label>管理員帳號<input id="adminAccount" required autocomplete="username"></label><label>管理員密碼<input id="adminPassword" type="password" required autocomplete="current-password"></label><p class="error" id="adminError"></p><button class="primary wide-button" type="submit">管理員登入</button></form>
    </div>
  </section>

  <div class="app hidden" id="app">
    <header class="topbar"><div class="brand"><strong id="brandSchool">新竹市預設國中</strong><span id="brandSystem">輔導空間管理</span></div><div class="top-meta"><span class="clock" id="clock"></span><span class="user-chip" id="userName"></span><button class="ghost" id="logoutBtn">離開</button></div></header>
    <div class="layout">
      <aside class="sidebar"><nav class="nav"><button class="active" data-view="overview">空間總覽</button><button data-view="mine">我的晤談</button><button data-view="booking">新增晤談</button><button class="admin-nav hidden" data-view="admin">系統管理</button></nav></aside>
      <main class="main">
        <section class="view active" id="view-overview"><div class="page-head"><div><h1>空間總覽</h1><p>共用畫面不顯示學生姓名。</p></div><button class="primary" data-go-booking>+ 新增晤談</button></div><div class="card toolbar"><div class="actions"><button class="ghost" id="prevWeek">← 上週</button><button class="secondary" id="thisWeek">本週</button><button class="ghost" id="nextWeek">下週 →</button></div><span class="week-label" id="weekLabel"></span></div><div class="schedule-wrap"><table class="schedule" id="schedule"></table></div></section>
        <section class="view" id="view-mine"><div class="page-head"><div><h1>我的晤談</h1><p>依週次查看自己的安排。</p></div><div class="actions"><button class="secondary" id="unlockBtn">輸入 PIN 查看完整資料</button><button class="primary" data-go-booking>+ 新增晤談</button></div></div><div class="unlock" id="unlockStatus">學生姓名目前受到保護;輸入教師 PIN 後可查看、修改或取消自己的預約。</div><div class="card list" id="myList"></div></section>
        <section class="view" id="view-booking"><div class="page-head"><div><h1 id="bookingHeading">新增晤談</h1><p>原課程採手動輸入,儲存前會檢查空間與停止預約時段。</p></div></div><form class="card panel" id="bookingForm"><input type="hidden" id="bookingId"><div class="form-grid"><label>班級<input id="studentClass" required inputmode="numeric" maxlength="3" pattern="[7-9][0-9]{2}" placeholder="例如 801"></label><label>座號<input id="studentSeat" required inputmode="numeric" maxlength="2"></label><label>學生姓名<input id="studentName" required></label><label>原課程<input id="studentSubject" required placeholder="例如:數學"></label><label>日期<input id="bookingDate" type="date" required></label><label>節次<select id="bookingPeriod" required></select></label><label>空間<select id="bookingSpace" required></select></label><label id="repeatLabel">重複到<input id="repeatEndDate" type="date"><small>留空表示只預約一次</small></label><label class="span2">備註<textarea id="bookingNote" rows="2"></textarea></label><div class="span3 actions"><button class="primary" type="submit">儲存晤談</button><button class="ghost hidden" id="cancelEdit" type="button">取消編輯</button></div></div></form></section>
        <section class="view" id="view-admin"><div class="page-head"><div><h1>系統管理</h1><p>管理校名、密碼、教師、空間、學期及停止預約。</p></div></div>
          <details class="card admin-section" open><summary>基本設定</summary><div><form class="compact-form" id="settingsForm"><label>學校名稱<input id="setSchool" required></label><label>系統名稱<input id="setSystem" required></label><label>學年度<input id="setYear" type="number" required></label><label>學期<select id="setSemester"><option value="1">第一學期</option><option value="2">第二學期</option></select></label><label>開始日期<input id="setStart" type="date" required></label><label>結束日期<input id="setEnd" type="date" required></label><label>新共用密碼<input id="setSchoolPassword" type="password" placeholder="不修改請留空"></label><label>管理員帳號<input id="setAdminAccount" required></label><label>新管理員密碼<input id="setAdminPassword" type="password" placeholder="不修改請留空"></label><button class="primary" type="submit">儲存設定</button></form></div></details>
          <details class="card admin-section"><summary>教師身分</summary><div><form class="compact-form" id="teacherForm"><input type="hidden" id="teacherId"><label>教師姓名<input id="teacherName" required></label><label>職稱<select id="teacherTitle"></select></label><label>PIN<input id="teacherPin" inputmode="numeric" maxlength="6" placeholder="新增時必填"></label><label>顏色<input id="teacherColor" type="color" value="#315f91"></label><label>狀態<select id="teacherActive"><option value="true">啟用</option><option value="false">停用</option></select></label><button class="primary" type="submit">儲存教師</button><button class="ghost" type="button" data-clear="teacher">清除</button></form><div class="mini-list" id="teacherAdminList"></div></div></details>
          <details class="card admin-section"><summary>空間管理</summary><div><form class="compact-form" id="spaceForm"><input type="hidden" id="spaceId"><label>空間名稱<input id="spaceName" required></label><label>備註<input id="spaceNote"></label><label>狀態<select id="spaceActive"><option value="true">啟用</option><option value="false">停用</option></select></label><button class="primary" type="submit">儲存空間</button><button class="ghost" type="button" data-clear="space">清除</button></form><div class="mini-list" id="spaceAdminList"></div></div></details>
          <details class="card admin-section"><summary>停止預約時段</summary><div><form class="compact-form" id="blockForm"><input type="hidden" id="blockId"><label>開始日期<input id="blockStart" type="date" required></label><label>結束日期<input id="blockEnd" type="date" required></label><label>星期<select id="blockDay"><option value="">全部</option><option value="1">星期一</option><option value="2">星期二</option><option value="3">星期三</option><option value="4">星期四</option><option value="5">星期五</option></select></label><label>節次<select id="blockPeriod"></select></label><label>空間<select id="blockSpace"></select></label><label>原因<input id="blockReason" required></label><label>狀態<select id="blockActive"><option value="true">啟用</option><option value="false">停用</option></select></label><button class="primary" type="submit">儲存停止時段</button><button class="ghost" type="button" data-clear="block">清除</button></form><div class="mini-list" id="blockAdminList"></div></div></details>
        </section>
      </main>
    </div>
  </div>
  <div class="modal-backdrop" id="detailModal"><div class="modal"><div class="modal-head"><h2>晤談詳情</h2><button class="close" data-close>×</button></div><dl class="detail-lines" id="detailLines"></dl><div class="actions" id="detailActions"></div></div></div>
  <div class="toast" id="toast"></div>

  <script>
    const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
    const titles=['輔導主任','資料組長','輔導組長','特教組長','專任輔導教師','輔導活動教師','特殊教育教師','專業輔導人員','一般教師','其他身分'];
    const tokenKey='counselingSimpleToken';let token=sessionStorage.getItem(tokenKey)||'',data=null,adminData=null,currentView='overview',serverOffset=0,clockTimer=0;
    const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
    const call=(action,payload={})=>new Promise((resolve,reject)=>google.script.run.withSuccessHandler(result=>{if(!result?.ok){const e=new Error(result?.error||'系統執行失敗');e.status=result?.status;reject(e)}else resolve(result.data)}).withFailureHandler(e=>reject(new Error(e?.message||'Apps Script 執行失敗'))).rpc(action,payload,token));
    function toast(message){const t=$('#toast');t.textContent=message;t.classList.add('show');clearTimeout(t._timer);t._timer=setTimeout(()=>t.classList.remove('show'),2800)}
    function busy(form,on){form.querySelectorAll('button').forEach(b=>b.disabled=on)}
    function dateIso(d){return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
    function parseDate(v){const [y,m,d]=v.split('-').map(Number);return new Date(y,m-1,d,12)}
    function addDays(v,n){const d=parseDate(v);d.setDate(d.getDate()+n);return dateIso(d)}
    function monday(v){const d=parseDate(v),day=d.getDay()||7;d.setDate(d.getDate()-day+1);return dateIso(d)}
    function fmtDate(v){return new Intl.DateTimeFormat('zh-TW',{month:'numeric',day:'numeric',weekday:'short'}).format(parseDate(v))}
    function setBrand(brand){$('#loginSchool').textContent=$('#brandSchool').textContent=brand.schoolName;$('#loginSystem').textContent=$('#brandSystem').textContent=brand.systemName;document.title=`${brand.schoolName}|${brand.systemName}`}
    function startClock(serverTime){const parsed=Date.parse(serverTime);if(Number.isFinite(parsed))serverOffset=parsed-Date.now();clearInterval(clockTimer);const render=()=>$('#clock').textContent=new Intl.DateTimeFormat('zh-TW',{timeZone:'Asia/Taipei',year:'numeric',month:'2-digit',day:'2-digit',weekday:'short',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}).format(new Date(Date.now()+serverOffset));render();clockTimer=setInterval(render,1000)}
    function showMode(mode){$('#schoolLoginForm').classList.toggle('hidden',mode!=='school');$('#teacherSelectForm').classList.add('hidden');$('#adminLoginForm').classList.toggle('hidden',mode!=='admin');$('#schoolMode').className=mode==='school'?'secondary':'ghost';$('#adminMode').className=mode==='admin'?'secondary':'ghost'}
    $('#schoolMode').onclick=()=>showMode('school');$('#adminMode').onclick=()=>showMode('admin');$('#backSchoolLogin').onclick=()=>showMode('school');
    $('#schoolLoginForm').onsubmit=async e=>{e.preventDefault();busy(e.target,true);$('#schoolError').textContent='';try{const result=await call('schoolLogin',{password:$('#schoolPassword').value});token=result.token;sessionStorage.setItem(tokenKey,token);setBrand(result.brand);showTeacherPicker(result.teachers)}catch(err){$('#schoolError').textContent=err.message}finally{busy(e.target,false)}};
    function showTeacherPicker(teachers){$('#schoolLoginForm').classList.add('hidden');$('#teacherSelectForm').classList.remove('hidden');$('#teacherSelect').innerHTML=teachers.length?teachers.map(t=>`<option value="${esc(t.id)}">${esc(t.name)}|${esc(t.title)}</option>`).join(''):'<option value="">尚無教師,請先由管理員新增</option>'}
    $('#teacherSelectForm').onsubmit=async e=>{e.preventDefault();busy(e.target,true);$('#teacherError').textContent='';try{if(!$('#teacherSelect').value)throw new Error('目前尚未建立教師身分');const result=await call('selectTeacher',{teacherId:$('#teacherSelect').value});token=result.token;sessionStorage.setItem(tokenKey,token);$('#userName').textContent=result.teacher.name;await enterApp(false,result.dashboard)}catch(err){$('#teacherError').textContent=err.message}finally{busy(e.target,false)}};
    $('#adminLoginForm').onsubmit=async e=>{e.preventDefault();busy(e.target,true);$('#adminError').textContent='';try{const result=await call('adminLogin',{account:$('#adminAccount').value,password:$('#adminPassword').value});token=result.token;sessionStorage.setItem(tokenKey,token);setBrand(result.brand);$('#userName').textContent='系統管理員';adminData=result.adminData;await enterApp(true,result.dashboard)}catch(err){$('#adminError').textContent=err.message}finally{busy(e.target,false)}};
    async function enterApp(forceAdmin=false,prefetched=null){data=prefetched||await call('dashboard');setBrand(data.brand);startClock(data.serverTime);const isAdmin=forceAdmin||data.sessionType==='admin';if(!isAdmin&&!data.teacherId){showTeacherPicker(data.teachers.filter(t=>t.active));return}if(!isAdmin)$('#userName').textContent=data.teachers.find(t=>t.id===data.teacherId)?.name||'教師';$('.admin-nav').classList.toggle('hidden',!isAdmin);$$('[data-view="booking"],[data-go-booking]').forEach(x=>x.classList.toggle('hidden',isAdmin));$('#loginScreen').classList.add('hidden');$('#app').classList.remove('hidden');renderAll();if(isAdmin){adminData=adminData||await call('adminData');renderAdmin()}}
    async function refresh(){const week=data?.weekStart||monday(dateIso(new Date()));data=await call('dashboard',{weekStart:week});setBrand(data.brand);renderAll()}
    $('#logoutBtn').onclick=async()=>{try{await call('logout')}catch(ignore){}sessionStorage.removeItem(tokenKey);token='';data=null;adminData=null;$('#app').classList.add('hidden');$('#loginScreen').classList.remove('hidden');$('#schoolPassword').value='';showMode('school')};
    $$('.nav button').forEach(button=>button.onclick=async()=>{showView(button.dataset.view);if(button.dataset.view==='admin'){adminData=await call('adminData');renderAdmin()}});
    function showView(name){currentView=name;$$('.nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===name));$$('.view').forEach(v=>v.classList.toggle('active',v.id==='view-'+name));if(name==='booking'&&!$('#bookingId').value)prepareNewBooking()}
    $$('[data-go-booking]').forEach(b=>b.onclick=()=>{resetBooking();showView('booking')});
    function renderAll(){renderOverview();renderMine();renderBookingOptions();$('#unlockStatus').textContent=data.verified?'已通過教師 PIN,可查看完整姓名並修改自己的預約。':'學生姓名目前受到保護;輸入教師 PIN 後可查看、修改或取消自己的預約。';$('#unlockBtn').textContent=data.verified?'PIN 已解鎖':'輸入 PIN 查看完整資料'}
    function teacher(id){return data.teachers.find(t=>t.id===id)}function space(id){return data.spaces.find(s=>s.id===id)}function period(id){return data.periods.find(p=>p.id===id)}
    function renderOverview(){const dates=[0,1,2,3,4].map(i=>addDays(data.weekStart,i));$('#weekLabel').textContent=`${data.brand.academicYear} 學年度第 ${data.brand.semester} 學期|第 ${data.week} 週|${dates[0].slice(5).replace('-','/')}-${dates[4].slice(5).replace('-','/')}`;let html='<thead><tr><th>節次</th>'+dates.map(d=>`<th>${fmtDate(d)}</th>`).join('')+'</tr></thead><tbody>';data.periods.forEach(p=>{html+=`<tr><td><strong>${esc(p.label)}</strong></td>`;dates.forEach((date,idx)=>{const day=idx+1,bookings=data.bookings.filter(b=>b.date===date&&b.period===p.id),blocks=data.blocks.filter(b=>date>=b.startDate&&date<=b.endDate&&(!b.day||Number(b.day)===day)&&(!b.period||b.period===p.id));html+='<td>';blocks.forEach(b=>html+=`<div class="block-card"><strong>停止預約</strong><br>${esc(b.reason)}${b.spaceId?'|'+esc(space(b.spaceId)?.name||''):''}</div>`);bookings.forEach(b=>html+=bookingCard(b));html+='</td>'});html+='</tr>'});$('#schedule').innerHTML=html+'</tbody>';$$('[data-booking]').forEach(b=>b.onclick=()=>openDetail(b.dataset.booking))}
    function bookingCard(b){const t=teacher(b.teacherId),s=space(b.spaceId),student=b.studentName?`${b.studentClass} ${b.seat}號 ${b.studentName}`:`${b.studentClass} ${b.seat}號`;return `<button class="booking-card" style="border-left-color:${esc(t?.color||'#315f91')}" data-booking="${esc(b.id)}"><strong>${esc(s?.name||'未設定空間')}</strong><span>${esc(student)}[${esc(b.subject)}]</span><span>${esc(t?.name||'')}</span></button>`}
    $('#prevWeek').onclick=()=>changeWeek(-7);$('#nextWeek').onclick=()=>changeWeek(7);$('#thisWeek').onclick=()=>changeWeek(0);async function changeWeek(days){if(days===0){data=await call('dashboard');setBrand(data.brand);renderAll();return}data.weekStart=addDays(data.weekStart,days);await refresh()}
    function renderMine(){if(data.sessionType==='admin'){$('#myList').innerHTML='<div class="empty-hint">管理員請從空間總覽查看全部預約。</div>';return}const mine=data.bookings.filter(b=>b.teacherId===data.teacherId).sort((a,b)=>a.date.localeCompare(b.date)||data.periods.findIndex(p=>p.id===a.period)-data.periods.findIndex(p=>p.id===b.period));$('#myList').innerHTML=mine.length?mine.map(b=>`<div class="list-item"><div class="list-main"><strong>${esc(b.studentClass)} ${esc(b.seat)}號${b.studentName?' '+esc(b.studentName):''}</strong><span class="nowrap">${esc(fmtDate(b.date))} · ${esc(period(b.period)?.label||b.period)}</span><span>${esc(space(b.spaceId)?.name||'')}|原課程:${esc(b.subject)}</span></div><button class="secondary" data-booking="${esc(b.id)}">查看</button></div>`).join(''):'<div class="empty-hint">本週沒有晤談安排。</div>';$$('#myList [data-booking]').forEach(b=>b.onclick=()=>openDetail(b.dataset.booking))}
    $('#unlockBtn').onclick=async()=>{if(data.verified)return;const pin=prompt('請輸入你的 4 至 6 位數教師 PIN');if(!pin)return;try{const result=await call('unlockTeacher',{pin});token=result.token;sessionStorage.setItem(tokenKey,token);await refresh();toast('教師 PIN 已解鎖')}catch(e){toast(e.message)}};
    function openDetail(id){const b=data.bookings.find(x=>x.id===id);if(!b)return;const lines=[['學生',`${b.studentClass} ${b.seat}號${b.studentName?' '+b.studentName:''}`],['日期',fmtDate(b.date)],['節次',period(b.period)?.label||b.period],['空間',space(b.spaceId)?.name||''],['原課程',b.subject],['晤談教師',teacher(b.teacherId)?.name||''],['備註',b.note||'—']];$('#detailLines').innerHTML=lines.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');const manageable=data.sessionType==='admin'||(b.teacherId===data.teacherId&&data.verified);$('#detailActions').innerHTML=manageable?`<button class="primary" id="editDetail">編輯</button><button class="danger" id="cancelDetail">取消預約</button>`:'';if(manageable){$('#editDetail').onclick=()=>editBooking(b);$('#cancelDetail').onclick=()=>cancelBooking(b.id)}$('#detailModal').classList.add('open')}
    $('[data-close]').onclick=()=>$('#detailModal').classList.remove('open');$('#detailModal').onclick=e=>{if(e.target===$('#detailModal'))$('#detailModal').classList.remove('open')};
    function renderBookingOptions(currentSpaceId=''){const bookingSpaces=data.spaces.filter(s=>s.active||s.id===currentSpaceId);$('#bookingPeriod').innerHTML=data.periods.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}</option>`).join('');$('#bookingSpace').innerHTML=bookingSpaces.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}${s.active?'':'(已停用,僅可保留原預約)'}</option>`).join('');$('#blockPeriod').innerHTML='<option value="">全部節次</option>'+$('#bookingPeriod').innerHTML;$('#blockSpace').innerHTML='<option value="">全部空間</option>'+data.spaces.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}${s.active?'':'(已停用)'}</option>`).join('')}
    function prepareNewBooking(){renderBookingOptions();if(!$('#bookingDate').value)$('#bookingDate').value=dateIso(new Date());$('#bookingHeading').textContent='新增晤談';$('#repeatLabel').classList.remove('hidden')}
    function resetBooking(){$('#bookingForm').reset();$('#bookingId').value='';$('#cancelEdit').classList.add('hidden');prepareNewBooking()}
    function editBooking(b){$('#detailModal').classList.remove('open');renderBookingOptions(b.spaceId);$('#bookingId').value=b.id;$('#studentClass').value=b.studentClass;$('#studentSeat').value=b.seat;$('#studentName').value=b.studentName;$('#studentSubject').value=b.subject;$('#bookingDate').value=b.date;$('#bookingPeriod').value=b.period;$('#bookingSpace').value=b.spaceId;$('#bookingNote').value=b.note;$('#bookingHeading').textContent='編輯晤談';$('#repeatLabel').classList.add('hidden');$('#cancelEdit').classList.remove('hidden');showView('booking')}
    $('#cancelEdit').onclick=()=>{resetBooking();if(data?.sessionType==='admin')showView('overview')};
    $('#bookingForm').onsubmit=async e=>{e.preventDefault();busy(e.target,true);const payload={id:$('#bookingId').value,studentClass:$('#studentClass').value,seat:$('#studentSeat').value,studentName:$('#studentName').value,subject:$('#studentSubject').value,date:$('#bookingDate').value,period:$('#bookingPeriod').value,spaceId:$('#bookingSpace').value,note:$('#bookingNote').value,repeatEndDate:$('#bookingId').value?'':$('#repeatEndDate').value};try{if(payload.id){await call('updateBooking',payload);toast('晤談已更新')}else{const result=await call('createBooking',payload);toast(`已建立 ${result.created} 筆預約${result.conflicts.length?`,略過 ${result.conflicts.length} 個衝突日期`:''}`)}const bookedWeek=monday(payload.date);resetBooking();data.weekStart=bookedWeek;await refresh();showView('overview')}catch(err){toast(err.message)}finally{busy(e.target,false)}};
    async function cancelBooking(id){if(!confirm('確定取消這筆晤談預約嗎?'))return;try{await call('cancelBooking',{id});$('#detailModal').classList.remove('open');await refresh();toast('預約已取消')}catch(e){toast(e.message)}}
    function itemRow(title,sub,id,type){return `<div class="list-item"><div class="list-main"><strong>${esc(title)}</strong><span>${esc(sub)}</span></div><button class="ghost" data-edit-${type}="${esc(id)}">編輯</button></div>`}
    function renderAdmin(){if(!adminData)return;const s=adminData.settings;$('#setSchool').value=s.schoolName;$('#setSystem').value=s.systemName;$('#setYear').value=s.academicYear;$('#setSemester').value=s.semester;$('#setStart').value=s.startDate;$('#setEnd').value=s.endDate;$('#setAdminAccount').value=adminData.adminAccount;$('#teacherTitle').innerHTML=titles.map(t=>`<option>${esc(t)}</option>`).join('');$('#teacherAdminList').innerHTML=adminData.teachers.map(t=>itemRow(t.name,`${t.title}|${t.active?'啟用':'停用'}`,t.id,'teacher')).join('')||'<div class="empty-hint">尚未建立教師身分。</div>';$('#spaceAdminList').innerHTML=adminData.spaces.map(x=>itemRow(x.name,x.active?'啟用':'停用',x.id,'space')).join('')||'<div class="empty-hint">尚未建立空間。</div>';$('#blockAdminList').innerHTML=adminData.blocks.map(x=>itemRow(x.reason,`${x.startDate}-${x.endDate}|${x.active?'啟用':'停用'}`,x.id,'block')).join('')||'<div class="empty-hint">尚未設定停止預約時段。</div>';bindAdminEdits()}
    async function reloadAdmin(){adminData=await call('adminData');data.brand=adminData.settings;data.teachers=adminData.teachers;data.spaces=adminData.spaces;data.blocks=adminData.blocks.filter(x=>x.active);renderAll();renderAdmin()}
    $('#settingsForm').onsubmit=e=>adminSubmit(e,'saveSettings',{schoolName:$('#setSchool').value,systemName:$('#setSystem').value,academicYear:$('#setYear').value,semester:$('#setSemester').value,startDate:$('#setStart').value,endDate:$('#setEnd').value,schoolPassword:$('#setSchoolPassword').value,adminAccount:$('#setAdminAccount').value,adminPassword:$('#setAdminPassword').value});
    $('#teacherForm').onsubmit=e=>adminSubmit(e,'saveTeacher',{id:$('#teacherId').value,name:$('#teacherName').value,title:$('#teacherTitle').value,pin:$('#teacherPin').value,color:$('#teacherColor').value,active:$('#teacherActive').value==='true'},'teacher');
    $('#spaceForm').onsubmit=e=>adminSubmit(e,'saveSpace',{id:$('#spaceId').value,name:$('#spaceName').value,note:$('#spaceNote').value,active:$('#spaceActive').value==='true'},'space');
    $('#blockForm').onsubmit=e=>adminSubmit(e,'saveBlock',{id:$('#blockId').value,startDate:$('#blockStart').value,endDate:$('#blockEnd').value,day:$('#blockDay').value,period:$('#blockPeriod').value,spaceId:$('#blockSpace').value,reason:$('#blockReason').value,active:$('#blockActive').value==='true'},'block');
    async function adminSubmit(e,action,payload,clear){e.preventDefault();busy(e.target,true);try{const result=await call(action,payload);if(result.dashboard)data=result.dashboard;if(clear)clearAdminForm(clear);await reloadAdmin();toast('設定已儲存')}catch(err){toast(err.message)}finally{busy(e.target,false)}}
    function bindAdminEdits(){$$('[data-edit-teacher]').forEach(b=>b.onclick=()=>fillTeacher(b.dataset.editTeacher));$$('[data-edit-space]').forEach(b=>b.onclick=()=>fillSpace(b.dataset.editSpace));$$('[data-edit-block]').forEach(b=>b.onclick=()=>fillBlock(b.dataset.editBlock))}
    function fillTeacher(id){const x=adminData.teachers.find(t=>t.id===id);$('#teacherId').value=x.id;$('#teacherName').value=x.name;$('#teacherTitle').value=x.title;$('#teacherColor').value=x.color;$('#teacherActive').value=String(x.active);$('#teacherPin').value=''}function fillSpace(id){const x=adminData.spaces.find(s=>s.id===id);$('#spaceId').value=x.id;$('#spaceName').value=x.name;$('#spaceNote').value=x.note;$('#spaceActive').value=String(x.active)}function fillBlock(id){const x=adminData.blocks.find(b=>b.id===id);$('#blockId').value=x.id;$('#blockStart').value=x.startDate;$('#blockEnd').value=x.endDate;$('#blockDay').value=x.day;$('#blockPeriod').value=x.period;$('#blockSpace').value=x.spaceId;$('#blockReason').value=x.reason;$('#blockActive').value=String(x.active)}
    function clearAdminForm(type){const form=$('#'+type+'Form');form.reset();const hidden=form.querySelector('input[type="hidden"]');if(hidden)hidden.value=''}$$('[data-clear]').forEach(b=>b.onclick=()=>clearAdminForm(b.dataset.clear));
    async function init(){try{const boot=await call('bootstrap');setBrand(boot.brand);startClock(boot.serverTime);if(token){try{await enterApp();return}catch(ignore){sessionStorage.removeItem(tokenKey);token=''}}showMode('school')}catch(e){$('#schoolError').textContent='系統初始化失敗:'+e.message}}
    init();
  </script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

部署成 Web App 後,就會拿到可分享網址。

  1. Apps Script 右上:部署 → 新部署
  2. 類型選:Web app
  3. 執行身分:你
  4. 存取權限:任何人
  5. 完成後複製網址

💬 評論區

老師們的交流與提問

載入評論中...