WEB 適用業務 *SFL 最上部見出しHTML (PYTHON.400/QHTMLSRC.SFLTOP)
最終更新:
2025-11-27
0001.00 <!DOCTYPE html>
0002.00 <html lang="ja">
0003.00 <head>
0004.00 <meta charset="utf-8">
0005.00 <meta http-equiv="Content-Language" content="ja">
0006.00 <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
0007.00 <meta http-equiv="Pragma" content="no-cache">
0008.00 <meta http-equiv="cache-control" content="no-cache">
0009.00 <meta http-equiv="Expires" content="-1">
0010.00 <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
0011.00 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
0012.00 <title>$TITLE</title>
0013.00 <style>
0014.00 html {
0015.00 font-family: "BIZ UPDGothic", Meiryo, sans-serif;
0016.00 accent-color: rgb(26, 115, 232);
0017.00 color: #555;
0018.00 width: 100%;
0019.00 height: 100%;
0020.00 margin: 0;
0021.00 padding: 0;
0022.00 border: 0;
0023.00 }
0024.00 body {
0025.00 box-sizing: border-box;
0026.00 width: 100%;
0027.00 height: 100%;
0028.00 margin: 0;
0029.00 border: 0;
0030.00 padding: 1em 4em;
0031.00 display: flex;
0032.00 flex-direction: column;
0033.00 }
0034.00 form {
0035.00 display: contents;
0036.00 }
0037.00 header {
0038.00 flex: 0;
0039.00 }
0040.00 main {
0041.00 flex: 1;
0042.00 min-height: 0;
0043.00 display: flex;
0044.00 flex-direction: column;
0045.00 align-items: flex-start;
0046.00 }
0047.00 footer {
0048.00 flex: 0;
0049.00 }
0050.00 .content {
0051.00 flex: 1;
0052.00 min-height: 0;
0053.00 max-width: calc(100%);
0054.00 overflow: auto;
0055.00 }
0056.00 input {
0057.00 font-family: inherit;
0058.00 font-size: inherit;
0059.00 color: inherit;
0060.00 }
0061.00 input:read-only {
0062.00 border-color: transparent;
0063.00 }
0064.00 h1 {
0065.00 font-size: 2.4em;
0066.00 font-weight: normal;
0067.00 color: #8888ff;
0068.00 }
0069.00 fieldset {
0070.00 border: 0;
0071.00 margin: 1em 0;
0072.00 }
0073.00 label {
0074.00 display: flex;
0075.00 align-items: baseline;
0076.00 gap: 0 0.7em;
0077.00 }
0078.00 .name {
0079.00 flex: none;
0080.00 width: 8em;
0081.00 }
0082.00 #record-table thead tr th {
0083.00 color: #333;
0084.00 font-weight: normal;
0085.00 text-align: left;
0086.00 border-bottom: 2px solid hsl(220, 90%, 56%);
0087.00 position: sticky;
0088.00 top: 0;
0089.00 background-color: white;
0090.00 }
0091.00 #record-table {
0092.00 border-collapse: separate;
0093.00 }
0094.00 #record-table th {
0095.00 padding-bottom: 0.75em;
0096.00 }
0097.00 .buttons {
0098.00 margin: 1.5em 0;
0099.00 }
0100.00 button > img {
0101.00 width: 1.2em;
0102.00 height: 1.2em;
0103.00 object-fit: contain;
0104.00 vertical-align: text-bottom;
0105.00 margin-right: 0.3em;
0106.00 }
0107.00 [data-dspmod="DSPMOD"] .checkbox {
0108.00 display: none;
0109.00 }
0110.00 .dspmod {
0111.00 position: absolute;
0112.00 top: 1em;
0113.00 right: 3em;
0114.00 font-size: 2.5em;
0115.00 color: #8888ff;
0116.00 }
0117.00 </style>
0118.00 </head>
0119.00 <body data-dspmod="{{dspmod}}">
0120.00 <form id="record-form" action="/LISTDEF" method="post">
0121.00 <header>
0122.00 <h1>&TITLE </h1>
0123.00 <div class="buttons">
0124.00 <button type="submit" formaction="/end"><img src="/icon/END.SVG"> 終了 </button>
0125.00 % if dspmod == "CHGPTN":
0126.00 <button type="submit" formaction="/update"><img src="/icon/UPDATE.SVG"> 更新 </button>
0127.00 <button type="submit" formaction="/delete"><img src="/icon/DELETE.SVG"> 削除 </button>
0128.00 % end
0129.00 <button type="button" class="default" onclick="location.href='/init';"><img src="/icon/BACK.SVG">
0130.00 </div>
0131.00 </header>
0132.00 <main>
0133.00 % if dspmod == 'DSPPTN':
0134.00 <div class="dspmod"> 表示 </div>
0135.00 % elif dspmod == 'CHGPTN':
0136.00 <div class="dspmod"> 変更 </div>
0137.00 % else:
0138.00 <div class="dspmod"> 入力 </div>
0139.00 % end
0140.00 <div class="content">
0141.00 <table id="record-table">
0142.00 <thead>
0143.00 <tr>
0144.00 <th class="checkbox">
0145.00 </th>
0146.00 <th>
0147.00 <span class="name">$COLHDG</span>
0148.00 </th>
0149.00 </tr>
0150.00 </thead>
0151.00 <tbody>
0152.00 </tbody>
0153.00 </table>
0154.00 </div>
0155.00 </main>
0156.00 <footer>
0157.00 </footer>
0158.00 </form>
0159.00 <form name="key">
0160.00 <input type="hidden" name="$KEYFLD" value="{{values[0].get('$KEYFLD', '')}}">
0161.00 </form>
0162.00 <template id="empty-record">
0163.00 </template>
0164.00 <script>
0165.00 "use strict";
0166.00
0167.00 /**
0168.00 * 要素の一番下までスクロールされたかどうか監視する。
0169.00 */
0170.00 class ScrollEndObserver {
0171.00 #callback;
0172.00 #threshold = 0;
0173.00 #targets = new Map();
0174.00 constructor(callback, options) {
0175.00 this.#callback = callback;
0176.00 const effective_options = {
0177.00 threshold: 0,
0178.00 ...options,
0179.00 };
0180.00 this.#threshold = effective_options.threshold;
0181.00 }
0182.00 /**
0183.00 * container 要素の監視を開始する。
0184.00 */
0185.00 observe(container) {
0186.00 if(!this.#targets.has(container)) {
0187.00 const onscroll = this.#onscroll.bind(this);
0188.00 container.addEventListener("scroll", onscroll, {
0189.00 passive: true,
0190.00 });
0191.00 this.#targets.set(container, {
0192.00 onscroll,
0193.00 scrollHeight: 0,
0194.00 clientHeight: 0,
0195.00 });
0196.00 }
0197.00 }
0198.00 /**
0199.00 * container 要素の監視を終了する。
0200.00 */
0201.00 unobserve(container) {
0202.00 const entry = this.#targets.get(container);
0203.00 if(entry) {
0204.00 container.removeEventListener("scroll", entry.onscroll);
0205.00 this.#targets.delete(container);
0206.00 }
0207.00 }
0208.00 /**
0209.00 * 全ての要素の監視を終了する。
0210.00 */
0211.00 disconnect() {
0212.00 this.#targets.forEach((value, key) => {
0213.00 this.unobserve(key);
0214.00 });
0215.00 }
0216.00 /**
0217.00 * onscroll イベントのリスナー
0218.00 */
0219.00 #onscroll(event) {
0220.00 const target = event.target;
0221.00 const entry = this.#targets.get(target);
0222.00
0223.00 // 監視対象でない場合は終了。
0224.00 if(!entry)
0225.00 return;
0226.00
0227.00 const scrollHeight = target.scrollHeight;
0228.00 const clientHeight = target.clientHeight;
0229.00
0230.00 // 要素の大きさが以前と変わっていないなら終了。
0231.00 // ( 不必要な callback が発生するのを避けるため。 )
0232.00 if(scrollHeight === entry.scrollHeight && clientHeight === entry.clientHeight)
0233.00 return;
0234.00
0235.00 // データの追加が必要なら callback する。
0236.00 if(this.needsMoreContent(target)) {
0237.00 entry.scrollHeight = scrollHeight;
0238.00 entry.clientHeight = clientHeight;
0239.00 this.#callback({
0240.00 target,
0241.00 scrollHeight,
0242.00 clientHeight,
0243.00 }, this);
0244.00 }
0245.00 }
0246.00 /**
0247.00 * 指定のコンテナにデータの追加が必要かどうか判定する。
0248.00 */
0249.00 needsMoreContent(target) {
0250.00 const scrollHeight = target.scrollHeight;
0251.00 const clientHeight = target.clientHeight;
0252.00 const scrollTop = target.scrollTop;
0253.00
0254.00 return scrollTop >= scrollHeight - clientHeight - (clientHeight * (1.0 - this.#threshold));
0255.00 }
0256.00 }
0257.00
0258.00 /**
0259.00 * この画面特有の処理
0260.00 */
0261.00 class App {
0262.00 #endOfData = false; // 最後のページまでデータを読み込んだ。
0263.00 #scrollEndObserver;
0264.00 #container = document.querySelector(".content");
0265.00 #tbody = document.querySelector("#record-table tbody");
0266.00 #recordForm = document.getElementById("record-form");
0267.00 #emptyRecord = document.getElementById("empty-record");
0268.00
0269.00 constructor() {
0270.00 this.#scrollEndObserver = new ScrollEndObserver((entry, observer) => {
0271.00 this.readPage();
0272.00 });
0273.00 }
0274.00
0275.00 /**
0276.00 * ページが読み込まれたときに最初に実行される処理。
0277.00 */
0278.00 async start() {
0279.00 // ユーザーが変更したレコードに印を付ける。
0280.00 this.#tbody.addEventListener("input", event => {
0281.00 const tr = event.target.closest("tr");
0282.00 if(tr)
0283.00 tr.setAttribute("data-modified", "");
0284.00 });
0285.00
0286.00 // ユーザーが変更したレコードのみを submit する。
0287.00 this.#recordForm.addEventListener("submit", event => {
0288.00 // 変更されていないレコードに一時的に disabled をつける。
0289.00 this.#tbody.querySelectorAll("tr:not([data-modified])").forEach(tr => {
0290.00 tr.querySelectorAll("input:enabled, select:enabled, textarea:enabled").forEach(element => {
0291.00 element.setAttribute("data-disabled-temporarily", "");
0292.00 element.disabled = true;
0293.00 });
0294.00 });
0295.00 // submit 後に元に戻す。
0296.00 setTimeout(() => {
0297.00 this.#tbody.querySelectorAll("[data-disabled-temporarily").forEach(element => {
0298.00 element.disabled = false;
0299.00 element.removeAttribute("data-disabled-temporarily");
0300.00 });
0301.00 }, 0);
0302.00 });
0303.00 // 最初から表示されている部分を読み取る。
0304.00 await this.readPage();
0305.00 // スクロールの監視を開始する。
0306.00 this.#scrollEndObserver.observe(this.#container);
0307.00
0308.00 // CHGPTN によって実行されたなら新規入力用の空行を表示する。
0309.00 if(document.body.matches("[data-dspmod='CHGPTN']")) {
0310.00 const text = await this.fetchText("/template", {
0311.00 method: "GET",
0312.00 cache: "no-cache",
0313.00 });
0314.00 this.#emptyRecord.innerHTML = text;
0315.00 this.appendEmptyRecord();
0316.00 // 最後の空行に入力されたら新しい空行を追加する。
0317.00 this.#tbody.addEventListener("change", event => {
0318.00 const targetTr = event.target.closest("tr");
0319.00 if(targetTr) {
0320.00 const dspmods = this.#tbody.querySelectorAll("input[name='_DSPMOD']:last-of-type");
0321.00 if(dspmods.length > 0) {
0322.00 const previous = dspmods[dspmods.length - 1];
0323.00 const tr = previous.closest("tr");
0324.00 if(targetTr === tr) {
0325.00 this.appendEmptyRecord();
0326.00 }
0327.00 }
0328.00 }
0329.00 });
0330.00 }
0331.00 }
0332.00 /**
0333.00 * 入力用の空行を一行追加する。
0334.00 */
0335.00 appendEmptyRecord() {
0336.00 const emptyRecord = this.#emptyRecord.content.cloneNode(true);
0337.00 const dspmods = this.#tbody.querySelectorAll("input[name='_DSPMOD']:last-of-type");
0338.00 if(dspmods.length > 0) {
0339.00 const previous = dspmods[dspmods.length - 1];
0340.00 const tr = previous.closest("tr");
0341.00 tr.after(emptyRecord);
0342.00 } else
0343.00 this.#tbody.prepend(emptyRecord);
0344.00 }
0345.00 /**
0346.00 * 非同期通信でテキストを受信する。
0347.00 */
0348.00 async fetchText(...args) {
0349.00 let response;
0350.00 try {
0351.00 response = await fetch(...args);
0352.00 } catch(error) {
0353.00 throw new Error(` 通信エラーが発生しました。 : nn${error}`);
0354.00 }
0355.00 if(!response.ok) {
0356.00 throw new Error(` サーバーがエラーを返しました : ${response.status} ${response.statusText}`);
0357.00 }
0358.00 try {
0359.00 return await response.text();
0360.00 } catch(error) {
0361.00 throw new Error(` サーバーからの応答を解釈出来ません : nn${error}`);
0362.00 }
0363.00 }
0364.00 /**
0365.00 * 指定のレコード番号から始まるデータをサーバーに問い合わせる。
0366.00 */
0367.00 async fetchNext(index) {
0368.00 const formData = new FormData(document.querySelector("form[name='key']"));
0369.00 formData.append("_RRN", index)
0370.00 return await this.fetchText("/record", {
0371.00 method: "POST",
0372.00 body: formData,
0373.00 cache: "no-store",
0374.00 });
0375.00 }
0376.00
0377.00 /**
0378.00 * サーバーから一画面分受け取って画面に表示する。
0379.00 */
0380.00 async readPage() {
0381.00 if(this.#endOfData)
0382.00 return;
0383.00
0384.00 const tbody = document.querySelector("tbody");
0385.00 do {
0386.00 const index = tbody.querySelectorAll("tr").length;
0387.00 let text;
0388.00 try {
0389.00 text = await this.fetchNext(index);
0390.00 } catch(error) {
0391.00 alert(error);
0392.00 break;
0393.00 }
0394.00 if(text.length === 0) {
0395.00 this.#endOfData = true;
0396.00 this.#scrollEndObserver.disconnect();
0397.00 break;
0398.00 }
0399.00 tbody.innerHTML += text;
0400.00 } while(this.#scrollEndObserver.needsMoreContent(this.#container));
0401.00 }
0402.00 }
0403.00
0404.00 const app = new App();
0405.00 app.start();
0406.00 </script>
0407.00 </body>
0408.00 </html>
[解説]
SFLTOP は一覧表形式の最上部として表示されるHTMLテンプケートです。
012.00 <title>$TITLE</title>
=> $TITLE にはPYTHON適用業務のテキストが埋め込まれます。
0146.00 <th> 0147.00 <span class="name">$COLHDG</span> 0148.00 </th>
=> $COLHDG には一覧表として表示するフィールドの欄見出しが並べて
置換えられます。
0159.00 <form name="key">
0160.00 <input type="hidden" name="$KEYFLD" value="{{values[0].get('$KEYFLD', '')}}">
0161.00 </form>
=>&KEYFLDには表示を開始するキー・フィールドの値が表示されます。