w-web-perm 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/css/app.6918ec45.css +1 -0
  3. package/dist/css/{chunk-vendors.2bcfce08.css → chunk-vendors.3c580e72.css} +1 -1
  4. package/dist/index.tmp +2 -2
  5. package/dist/js/app-legacy.5f15a58f.js +1 -0
  6. package/dist/js/app.5f15a58f.js +1 -0
  7. package/dist/js/{chunk-vendors-legacy.fc37f076.js → chunk-vendors-legacy.38f515f9.js} +16 -16
  8. package/dist/js/{chunk-vendors.fc37f076.js → chunk-vendors.38f515f9.js} +16 -16
  9. package/dist/w-web-perm.umd.js +2 -2
  10. package/dist/w-web-perm.umd.js.map +1 -1
  11. package/docs/WWebPerm.html +7 -7
  12. package/docs/global.html +2068 -111
  13. package/docs/index.html +2 -2
  14. package/docs/server_WWebPerm.mjs.html +63 -31
  15. package/docs/server_lockSave.mjs.html +115 -0
  16. package/docs/server_maskLog.mjs.html +176 -0
  17. package/docs/server_procCore.mjs.html +31 -5
  18. package/docs/server_wrapInjected.mjs.html +163 -0
  19. package/docs/src_fetchJson.mjs.html +31 -5
  20. package/docs/src_js_legendFit.mjs.html +225 -0
  21. package/docs/src_plugins_reuseDeletedIds.mjs.html +136 -0
  22. package/docs/src_plugins_submitGuard.mjs.html +163 -0
  23. package/package.json +13 -13
  24. package/public/index.html +2 -0
  25. package/server/WWebPerm.mjs +61 -29
  26. package/server/lockSave.mjs +43 -0
  27. package/server/maskLog.mjs +104 -0
  28. package/server/procCore.mjs +29 -3
  29. package/server/procLang.mjs +8 -0
  30. package/server/procStaInfor.mjs +32 -14
  31. package/server/wrapInjected.mjs +91 -0
  32. package/src/components/Common/CheckYes.vue +6 -0
  33. package/src/components/Common/CheckYesNo.vue +6 -0
  34. package/src/components/LayoutContentGrups.vue +30 -21
  35. package/src/components/LayoutContentPemis.vue +30 -21
  36. package/src/components/LayoutContentStaInfor.vue +74 -5
  37. package/src/components/LayoutContentTargets.vue +36 -22
  38. package/src/components/LayoutContentUsers.vue +31 -21
  39. package/src/components/RelationChips.vue +11 -31
  40. package/src/components/VeCgrups.vue +8 -1
  41. package/src/components/VeCpemis.vue +8 -1
  42. package/src/components/VeCrules.vue +8 -1
  43. package/src/components/VeGrupBlngUsers.vue +37 -31
  44. package/src/components/VePemiBlngGrups.vue +37 -31
  45. package/src/fetchJson.mjs +28 -2
  46. package/src/getPerm.mjs +9 -6
  47. package/src/getPermUserInfor.mjs +12 -9
  48. package/src/js/legendFit.mjs +153 -0
  49. package/src/plugins/mUI.mjs +20 -0
  50. package/src/plugins/reuseDeletedIds.mjs +64 -0
  51. package/src/plugins/submitGuard.mjs +91 -0
  52. package/srv.mjs +20 -1
  53. package/dist/css/app.506ce4d8.css +0 -1
  54. package/dist/js/app-legacy.8e615442.js +0 -1
  55. package/dist/js/app.8e615442.js +0 -1
  56. package/tableTags-web-perm.json +0 -1
@@ -986,27 +986,24 @@ export default {
986
986
 
987
987
  clickSave: function(msg) {
988
988
  //console.log('methods clickSave', msg)
989
-
989
+ //WDialog 內建儲存鈕之 promiseUnlock 鎖交由 doSave 之 runSubmit 於請求結束時釋放, 不於此解鎖: 請求期間儲存鈕之滑鼠與鍵盤 Enter 皆擋 (ADR-025;
990
+ //原第一行即 msg.pm.resolve(), 鎖立即解除, 焦點留在儲存鈕時連按 Enter 可送出 2 次)
990
991
  let vo = this
991
-
992
- //save按鈕第一行立刻釋放視覺鎖
993
- msg.pm.resolve()
994
-
995
- //fire-and-forget, 不 await
996
- vo.doSave()
997
-
992
+ vo.doSave({ pm: msg.pm })
998
993
  },
999
994
 
1000
- doSave: function() {
995
+ doSave: function(opt = {}) {
1001
996
 
1002
997
  let vo = this
1003
998
 
1004
- async function core() {
999
+ async function core(unlockBtn) {
1005
1000
 
1006
- //1) 事先檢測 (所有同步檢查在開 loading 之前, 空清單不應先閃 loading)
1001
+ //1) 事先檢測 (所有同步檢查在開 loading 之前, 空清單不應先閃 loading); 訊息框前以 unlockBtn() 釋放儲存鈕之 promiseUnlock 鎖:
1002
+ //按鈕鎖只涵蓋請求期間, 結果訊息框出現時按鈕已恢復(與打 API 之路徑一致) (ADR-025)
1007
1003
  let rows = get(vo, 'opt.rows', [])
1008
1004
  // console.log('rows', rows)
1009
1005
  if (size(rows) === 0) {
1006
+ unlockBtn()
1010
1007
  await vo.$dg.showCheckYes(`${vo.$t('grupSaveGrupsEmpty')}`)
1011
1008
  return
1012
1009
  }
@@ -1075,32 +1072,35 @@ export default {
1075
1072
 
1076
1073
  }
1077
1074
 
1078
- //core
1079
- core()
1080
- .then((rows) => {
1075
+ //runSubmit: 對話框儲存流程(至結果訊息框關閉, 成功時含關閉對話框)進行中再觸發即略過; opt.pm 為對話框儲存鈕之 promiseUnlock 鎖,
1076
+ //於請求結束(updateLoading(false))時釋放. 對話框之 promise(vo.pm)與開關(bShow)不變: 成功才 resolve 並關閉, 失敗保持開啟可重按儲存 (ADR-025)
1077
+ return vo.$ui.runSubmit('savePemiBlngGrups', (unlockBtn) => {
1078
+ return core(unlockBtn)
1079
+ .then((rows) => {
1081
1080
 
1082
- //存檔失敗: 不 resolve、不關窗(規格「若寫入失敗…對話框不關閉,可修正後重按儲存」; 舊版於此無條件關窗, 2026-09-17 審計查出)
1083
- if (rows === false) {
1084
- return
1085
- }
1081
+ //存檔失敗: 不 resolve、不關窗(規格「若寫入失敗…對話框不關閉,可修正後重按儲存」; 舊版於此無條件關窗, 2026-09-17 審計查出)
1082
+ if (rows === false) {
1083
+ return
1084
+ }
1086
1085
 
1087
- //resolve
1088
- vo.pm.resolve(rows)
1086
+ //resolve
1087
+ vo.pm.resolve(rows)
1089
1088
 
1090
- //hide
1091
- vo.bShow = false
1089
+ //hide
1090
+ vo.bShow = false
1092
1091
 
1093
- })
1094
- .catch((err) => {
1095
- console.log('doSave', err)
1096
- vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' }) //§5.1: 非預期例外須告知, 不靜默吞 (失敗時 .then 不執行→視窗不關)
1097
- })
1098
- .finally(() => {
1092
+ })
1093
+ .catch((err) => {
1094
+ console.log('doSave', err)
1095
+ vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' }) //§5.1: 非預期例外須告知, 不靜默吞 (失敗時 .then 不執行→視窗不關)
1096
+ })
1097
+ .finally(() => {
1099
1098
 
1100
- //hide loading
1101
- vo.$ui.updateLoading(false)
1099
+ //hide loading
1100
+ vo.$ui.updateLoading(false)
1102
1101
 
1103
- })
1102
+ })
1103
+ }, opt)
1104
1104
 
1105
1105
  },
1106
1106
 
@@ -1122,6 +1122,12 @@ export default {
1122
1122
 
1123
1123
  let vo = this
1124
1124
 
1125
+ //單例: 對話框開啟中再次開啟(如焦點留在開啟鈕時按 Enter)時, 舊階段之等待者視同關閉(reject('close window'), 同 clickClose;
1126
+ //對已結束者無作用), 否則其 promise 永久懸置 (ADR-025; 同 CheckYes / CheckYesNo)
1127
+ if (vo.pm) {
1128
+ vo.pm.reject('close window')
1129
+ }
1130
+
1125
1131
  //pm
1126
1132
  vo.pm = genPm()
1127
1133
 
package/src/fetchJson.mjs CHANGED
@@ -1,3 +1,28 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+
4
+
5
+ let reName = /^[A-Za-z]{1,40}$/
6
+ let reCode = /^[A-Z][A-Z0-9_]{1,40}$/
7
+
8
+
9
+ //errDesc: 例外只取名稱(與代碼形狀之 cause.code / code), 不取 message(spec/設計要點與取捨.md ADR-023)
10
+ //why: fetch 例外之 message 可能含完整網址('Failed to parse URL from <url>'、'Request cannot be constructed from a URL that includes credentials: <url>'),
11
+ //而網址常帶權杖(?token=); JSON 解析錯誤之 message 夾帶回應本體片段. reject 物件會經 perm.conn / provideTabs 交給呼叫端與其 log
12
+ function errDesc(err) {
13
+ let name = get(err, 'name')
14
+ let s = (isestr(name) && reName.test(name)) ? name : 'Error'
15
+ let code = get(err, 'cause.code')
16
+ if (!(isestr(code) && reCode.test(code))) {
17
+ code = get(err, 'code')
18
+ }
19
+ if (isestr(code) && reCode.test(code)) {
20
+ s += ` ${code}`
21
+ }
22
+ return s
23
+ }
24
+
25
+
1
26
  /**
2
27
  * 以瀏覽器與 node(18+) 內建之 fetch 取得 JSON 回應, 取代 axios(2026-09-07 起本套件不再依賴 axios)
3
28
  *
@@ -5,6 +30,7 @@
5
30
  * - 網路錯誤或非 2xx: axios 會 reject, fetch 只在網路錯誤 reject, 故此處對 !res.ok 亦 reject, 統一為 { kind: 'request', status, message }
6
31
  * - 回應非 JSON: axios 回原字串(呼叫端判 state 失敗), 此處 res.json() 失敗 reject { kind: 'parse', status, message }
7
32
  * - 成功: resolve 解析後之 JSON(對應 axios 之 res.data)
33
+ * reject 之 message 只含例外名稱與代碼(如 'TypeError ECONNREFUSED'、'SyntaxError')或 'HTTP <status>', 不含網址與回應本體(網址常帶權杖)
8
34
  * 注意: node 端 fetch(undici) 不讀 HTTP_PROXY / HTTPS_PROXY 環境變數(axios 會), 需走 proxy 之部署須自行以 opt.dispatcher 注入 undici Agent
9
35
  *
10
36
  * @param {String} url 輸入請求網址字串
@@ -19,7 +45,7 @@ async function fetchJson(url, opt = {}) {
19
45
  res = await fetch(url, opt)
20
46
  }
21
47
  catch (err) {
22
- return Promise.reject({ kind: 'request', status: 0, message: String((err && err.message) || err) })
48
+ return Promise.reject({ kind: 'request', status: 0, message: errDesc(err) })
23
49
  }
24
50
 
25
51
  //check status
@@ -32,7 +58,7 @@ async function fetchJson(url, opt = {}) {
32
58
  return await res.json()
33
59
  }
34
60
  catch (err) {
35
- return Promise.reject({ kind: 'parse', status: res.status, message: String((err && err.message) || err) })
61
+ return Promise.reject({ kind: 'parse', status: res.status, message: errDesc(err) })
36
62
  }
37
63
 
38
64
  }
package/src/getPerm.mjs CHANGED
@@ -2,7 +2,6 @@ import get from 'lodash-es/get.js'
2
2
  import isestr from 'wsemi/src/isestr.mjs'
3
3
  import iseobj from 'wsemi/src/iseobj.mjs'
4
4
  import isfun from 'wsemi/src/isfun.mjs'
5
- import ispm from 'wsemi/src/ispm.mjs'
6
5
  import fetchJson from './fetchJson.mjs'
7
6
 
8
7
 
@@ -25,7 +24,9 @@ async function getPerm(url, tokenTar, opt = {}) {
25
24
  if (url.indexOf('token={token}') < 0) {
26
25
  return Promise.reject('noTokenInUrl')
27
26
  }
28
- url = url.replaceAll('{token}', tokenTar)
27
+ //代入: 值經 encodeURIComponent 並以 split / join 取代(ADR-023); 不用 replaceAll——其取代字串會解讀 $` $& $' 等樣式(可把佔位符前之網址前綴複製進查詢值),
28
+ //不編碼時 + 被伺服端解成空白、& 與 = 形成額外參數. 對 UUID、英數與 -_.~ 為恆等轉換
29
+ url = url.split('{token}').join(encodeURIComponent(tokenTar))
29
30
  // console.log('getPerm: url', url)
30
31
 
31
32
  //get, 內建 fetch(不依賴 axios); 網路錯誤或非 2xx → cannotGetUserByUrl, 回應非 JSON → cannotGetUserDataByUrl(對齊 axios 時期語意)
@@ -65,10 +66,12 @@ async function getPerm(url, tokenTar, opt = {}) {
65
66
  //check
66
67
  if (isfun(funConvertPerm)) {
67
68
 
68
- //funConvertPerm
69
- ur = funConvertPerm(ur)
70
- if (ispm(ur)) {
71
- ur = await ur
69
+ //funConvertPerm, 同步拋錯或 reject 一律 reject 'noUserDataAfterConvert'(部署方之錯誤原文不上拋, ADR-023)
70
+ try {
71
+ ur = await funConvertPerm(ur) //await 於 try 內: 同步拋錯、reject 與 thenable 之 reject 皆攔截; 非 Promise 之回傳原樣取得
72
+ }
73
+ catch (err) {
74
+ return Promise.reject('noUserDataAfterConvert')
72
75
  }
73
76
  // console.log('getPerm ur(funConvertPerm)', ur)
74
77
 
@@ -2,7 +2,6 @@ import get from 'lodash-es/get.js'
2
2
  import isestr from 'wsemi/src/isestr.mjs'
3
3
  import iseobj from 'wsemi/src/iseobj.mjs'
4
4
  import isfun from 'wsemi/src/isfun.mjs'
5
- import ispm from 'wsemi/src/ispm.mjs'
6
5
  import fetchJson from './fetchJson.mjs'
7
6
 
8
7
 
@@ -24,12 +23,14 @@ async function getPermUserInfor(url, tokenSelf, userIdTar, opt = {}) {
24
23
  //funConvertPerm
25
24
  let funConvertPerm = get(opt, 'funConvertPerm')
26
25
 
27
- //url
28
- if (url.indexOf('token={sysToken}') < 0 && url.indexOf('userId={userId}') < 0) {
26
+ //url, 兩個佔位符缺一即拒(原為 &&: 只有兩者皆缺才拒, 缺一時仍送出請求; 對齊 w-web-sso ADR-056 修正紀錄)
27
+ if (url.indexOf('token={sysToken}') < 0 || url.indexOf('userId={userId}') < 0) {
29
28
  return Promise.reject('noTokenUserIdInUrl')
30
29
  }
31
- url = url.replaceAll('{sysToken}', tokenSelf) //系統介接用permToken
32
- url = url.replaceAll('{userId}', userIdTar)
30
+ //代入: 值經 encodeURIComponent 並以 split / join 取代(ADR-023); 不用 replaceAll——其取代字串會解讀 $` $& $' 等樣式(userIdTar 含 $` 時會把已代入之介接權杖複製進 userId),
31
+ //不編碼時 + 被伺服端解成空白、& 與 = 形成額外參數, 值內之 {userId} 字樣亦會被二次代入. 對 UUID、英數與 -_.~ 為恆等轉換
32
+ url = url.split('{sysToken}').join(encodeURIComponent(tokenSelf)) //系統介接用permToken
33
+ url = url.split('{userId}').join(encodeURIComponent(userIdTar))
33
34
  // console.log('getPermUserInfor: url', url)
34
35
 
35
36
  //get, 內建 fetch(不依賴 axios); 網路錯誤或非 2xx → cannotGetUserByUrl, 回應非 JSON → cannotGetUserDataByUrl(對齊 axios 時期語意)
@@ -69,10 +70,12 @@ async function getPermUserInfor(url, tokenSelf, userIdTar, opt = {}) {
69
70
  //check
70
71
  if (isfun(funConvertPerm)) {
71
72
 
72
- //funConvertPerm
73
- ur = funConvertPerm(ur)
74
- if (ispm(ur)) {
75
- ur = await ur
73
+ //funConvertPerm, 同步拋錯或 reject 一律 reject 'noUserDataAfterConvert'(部署方之錯誤原文不上拋, ADR-023)
74
+ try {
75
+ ur = await funConvertPerm(ur) //await 於 try 內: 同步拋錯、reject 與 thenable 之 reject 皆攔截; 非 Promise 之回傳原樣取得
76
+ }
77
+ catch (err) {
78
+ return Promise.reject('noUserDataAfterConvert')
76
79
  }
77
80
  // console.log('getPermUserInfor ur(funConvertPerm)', ur)
78
81
 
@@ -0,0 +1,153 @@
1
+ //圖例排版: 供事件發生頻率圖(LayoutContentStaInfor)使用. 一般式(plain, 可換行)圖例放得下時維持現狀, 放不下(會壓到最上方刻度與繪圖區)時改捲動式(scroll, 單列+翻頁)
2
+ //判定以「離屏量測實例」計算一般式圖例於指定寬度之實際排版, 為寬度與名稱集合之純函數:
3
+ // 不讀可見圖之顯示列表(echarts resize 不 flush zrender, resize 後讀到的是上一個寬度之圖例, echarts.js:964-1012);
4
+ // 不在可見圖上試排(類型改變即重建圖例模型, 捲動式頁次會歸零, 帶回頁次則每次滑動 800ms)
5
+ //本檔無外部相依, 日後可原樣移入 w-echarts-vue(src/js/)
6
+
7
+
8
+ //GRID_TOP: 繪圖區頂(px), 同 echarts 6.1.0 預設(GridModel.js:84 top:65), 於 option 明寫以防預設變動
9
+ let GRID_TOP = 65
10
+
11
+ //LEGEND_MAX_BOTTOM: 一般式圖例底緣上限(px). 最上方 y 軸刻度文字以繪圖區頂為中線(字高 12 → 半高 6), 再留白 2 → 65-6-2=57
12
+ // 實測(echarts 6.1.0, 列距 21): 1 列底 18、2 列底 39(安全)、3 列底 60(擦到刻度)、4 列底 81(壓進繪圖區)
13
+ let LEGEND_MAX_BOTTOM = GRID_TOP - 6 - 2
14
+
15
+
16
+ /**
17
+ * 產生圖例設定(單一來源), genOpt 與執行期切換類型共用
18
+ *
19
+ * 切換類型(plain↔scroll)時 echarts 以新傳入之選項重建圖例模型(Global.js:322-344), 未帶之設定一律回到預設, 故每次都須帶齊
20
+ *
21
+ * @param {String} [type='plain'] 輸入圖例類型字串, 'plain'或'scroll', 預設'plain'
22
+ * @returns {Object} 回傳echarts之legend設定物件
23
+ */
24
+ function genLegend(type = 'plain') {
25
+ let legend = {
26
+ show: true,
27
+ top: 0, //須明給: echarts 6 起 legend 預設由 top:0 改為 bottom, 不給會落到底部壓住 x 軸時間標籤; 切換類型時亦須帶(類型改變即重建圖例模型)
28
+ type,
29
+ }
30
+ if (type === 'scroll') {
31
+ legend.tooltip = { show: true } //捲動式僅出現於窄版, 單一項目可能寬於可視窗而被裁, 滑過顯示全名(靜止畫面不變)
32
+ }
33
+ return legend
34
+ }
35
+
36
+
37
+ /**
38
+ * 依量測結果決定圖例類型
39
+ *
40
+ * @param {Object|null} m 輸入量測結果物件{rows,bottom}, 量測失敗為null
41
+ * @returns {String} 回傳'plain'或'scroll', 量測失敗回'scroll'(單列, 不會重疊)
42
+ */
43
+ function decideLegendType(m) {
44
+ if (!m || typeof m.bottom !== 'number') {
45
+ return 'scroll'
46
+ }
47
+ return m.bottom <= LEGEND_MAX_BOTTOM ? 'plain' : 'scroll'
48
+ }
49
+
50
+
51
+ /**
52
+ * 建立離屏量測器: 以未掛入DOM之echarts實例, 計算一般式圖例於指定寬度之排版
53
+ *
54
+ * 量測實例僅含圖例、隱藏之座標軸與同名空系列, 故顯示列表中「文字等於系列名」之元素即圖例項目(不會與軸刻度文字撞名)
55
+ *
56
+ * @param {Object} echarts 輸入echarts命名空間物件, 須與圖表同一份(例如w-echarts-vue之iniEcharts()), 字型與版面規則方能一致
57
+ * @returns {Object} 回傳物件, 含measure(names,width,height)與dispose(); measure回傳{rows,bottom}(bottom為圖例底緣距圖表頂之px), 量不到回null
58
+ */
59
+ function createLegendMeasurer(echarts) {
60
+ let el = null
61
+ let chart = null
62
+
63
+ let measure = (names, width, height = 300) => {
64
+
65
+ //check
66
+ if (!Array.isArray(names) || names.length === 0) {
67
+ return null
68
+ }
69
+ if (!(width > 0) || !(height > 0)) {
70
+ return null
71
+ }
72
+
73
+ //uniq
74
+ let uniq = []
75
+ for (let name of names) {
76
+ if (uniq.indexOf(name) < 0) {
77
+ uniq.push(name)
78
+ }
79
+ }
80
+
81
+ try {
82
+
83
+ //chart, 首次建立後重用
84
+ if (!chart) {
85
+ el = document.createElement('div')
86
+ chart = echarts.init(el, null, { width, height })
87
+ }
88
+ else {
89
+ chart.resize({ width, height })
90
+ }
91
+
92
+ //setOption, 同步排版(notMerge整體置換)
93
+ chart.setOption({
94
+ animation: false,
95
+ legend: genLegend('plain'),
96
+ xAxis: { type: 'category', show: false, data: [] },
97
+ yAxis: { type: 'value', show: false },
98
+ series: uniq.map((name) => {
99
+ return { name, type: 'line', data: [] }
100
+ }),
101
+ }, { notMerge: true })
102
+
103
+ //圖例項目: 顯示列表中文字等於系列名者, 以其頂緣去重計列數、取最大底緣
104
+ let tops = []
105
+ let bottom = 0
106
+ let n = 0
107
+ for (let e of chart.getZr().storage.getDisplayList(true)) {
108
+ let t = e.style && e.style.text
109
+ if (!t || uniq.indexOf(t) < 0 || typeof e.transformCoordToGlobal !== 'function') {
110
+ continue
111
+ }
112
+ let r = e.getBoundingRect()
113
+ let top = e.transformCoordToGlobal(r.x, r.y)[1]
114
+ let btm = e.transformCoordToGlobal(r.x, r.y + r.height)[1]
115
+ let tr = Math.round(top)
116
+ if (tops.indexOf(tr) < 0) {
117
+ tops.push(tr)
118
+ }
119
+ bottom = Math.max(bottom, btm)
120
+ n++
121
+ }
122
+
123
+ //check, 找到之圖例文字數須等於系列名數, 否則視為量測失敗
124
+ if (n !== uniq.length) {
125
+ return null
126
+ }
127
+
128
+ return { rows: tops.length, bottom }
129
+ }
130
+ catch (err) {
131
+ return null
132
+ }
133
+ }
134
+
135
+ let dispose = () => {
136
+ if (chart) {
137
+ chart.dispose()
138
+ }
139
+ chart = null
140
+ el = null
141
+ }
142
+
143
+ return { measure, dispose }
144
+ }
145
+
146
+
147
+ export {
148
+ GRID_TOP,
149
+ LEGEND_MAX_BOTTOM,
150
+ genLegend,
151
+ decideLegendType,
152
+ createLegendMeasurer
153
+ }
@@ -7,11 +7,16 @@ import isestr from 'wsemi/src/isestr.mjs'
7
7
  import isfun from 'wsemi/src/isfun.mjs'
8
8
  import waitFun from 'wsemi/src/waitFun.mjs'
9
9
  // import WUiDwload from 'w-ui-dwload/src/WUiDwload.mjs'
10
+ import submitGuard from './submitGuard.mjs'
10
11
 
11
12
 
12
13
  let vo = Vue.prototype
13
14
 
14
15
 
16
+ //sg, 送出類操作之前端雙擊防護 (見 submitGuard.mjs, ADR-025)
17
+ let sg = submitGuard()
18
+
19
+
15
20
  //連線建立前的 fallback 專用, 僅含連線狀態字串, 其餘語系皆由後端提供
16
21
  let kpFallback = {
17
22
  csIng: {
@@ -49,6 +54,20 @@ function updateConnState(connState) {
49
54
 
50
55
  function updateLoading(loading) {
51
56
  vo.$store.commit(vo.$store.types.UpdateLoading, loading)
57
+
58
+ //全頁 loading 關閉(請求結束)時釋放送出中按鈕之 promiseUnlock 鎖, 結果訊息框出現時按鈕已恢復; 流程狀態仍在, 訊息框開啟期間之重入仍擋 (ADR-025)
59
+ if (!loading) {
60
+ sg.releaseBtnLocks()
61
+ }
62
+
63
+ }
64
+
65
+
66
+ //runSubmit: 有副作用之送出流程一律經此執行, 同 key 進行中再觸發即略過(按鈕之滑鼠與鍵盤 Enter 同一狀態);
67
+ //opt.pm 傳入按鈕之 promiseUnlock 鎖(msg.pm), 由本機制於請求結束時釋放, handler 不自行 resolve;
68
+ //fn 收到 unlock(未打 API 即開訊息框之早退路徑於開框前呼叫, 只釋放本次按鈕鎖)與 opt.hold 見 submitGuard.mjs (ADR-025)
69
+ function runSubmit(key, fn, opt = {}) {
70
+ return sg.run(key, fn, opt)
52
71
  }
53
72
 
54
73
 
@@ -263,6 +282,7 @@ let mUI = {
263
282
 
264
283
  updateConnState,
265
284
  updateLoading,
285
+ runSubmit,
266
286
  // updateViewState,
267
287
  updateUserToken,
268
288
  updateUserSelf,
@@ -0,0 +1,64 @@
1
+ import get from 'lodash-es/get.js'
2
+ import map from 'lodash-es/map.js'
3
+ import each from 'lodash-es/each.js'
4
+
5
+
6
+ /**
7
+ * 新增列沿用資料庫既有 id 時之正規化 (標的清單專用, 其 id 為可就地改寫之標的路徑; ADR-025)
8
+ *
9
+ * 同一次儲存中刪除(或把 id 改掉)某既有列, 又新增一列並把 id 改成該既有列之 id, 淨效果為「修改該列」:
10
+ * 該新增列改為一般列送出(_isNew 設 false), 建立者 / 建立時間沿用資料庫之值(修改者 / 修改時間由後端寫入).
11
+ * 否則後端之「新增列已存在即拒絕」(saveNewRowExists, 擋同一包重送) 會把它當重送擋下, 且重新整理後重做仍被擋;
12
+ * 亦避免以前端佔位字({待自動給予})覆寫建立者 / 建立時間.
13
+ * 只處理 id 於 rows 內唯一者; 重複 id 不處理, 由後端 ckKey 回 saveRowFieldDuplicate.
14
+ * 使用者 / 群組 / 權限清單之 id 為系統產生且不可編輯, 新增列不會與既有列同 id, 不需此處理.
15
+ *
16
+ * @param {Array} rows 輸入欲送出之列陣列(清單之 opt.rows), 不修改
17
+ * @param {Array} rowsDb 輸入資料庫之列陣列(前端 store 之同表資料)
18
+ * @returns {Array} 回傳正規化後之新陣列
19
+ */
20
+ function reuseDeletedIds(rows, rowsDb) {
21
+
22
+ //kpDb, kpCount: 以 Map 存放, 不以 lodash get 取值(標的 id 為路徑, 可能含「.」而被當成巢狀路徑)
23
+ let kpDb = new Map()
24
+ each(rowsDb, (r) => {
25
+ kpDb.set(get(r, 'id', ''), r)
26
+ })
27
+ let kpCount = new Map()
28
+ each(rows, (r) => {
29
+ let id = get(r, 'id', '')
30
+ kpCount.set(id, (kpCount.get(id) || 0) + 1)
31
+ })
32
+
33
+ let rs = map(rows, (r) => {
34
+ let id = get(r, 'id', '')
35
+
36
+ //非新增列
37
+ if (get(r, '_isNew') !== true) {
38
+ return r
39
+ }
40
+
41
+ //資料庫無此 id: 真正之新增
42
+ if (!kpDb.has(id)) {
43
+ return r
44
+ }
45
+ let rDb = kpDb.get(id)
46
+
47
+ //rows 內重複 id: 不處理
48
+ if (kpCount.get(id) !== 1) {
49
+ return r
50
+ }
51
+
52
+ return {
53
+ ...r,
54
+ _isNew: false,
55
+ userId: get(rDb, 'userId', ''),
56
+ timeCreate: get(rDb, 'timeCreate', ''),
57
+ }
58
+ })
59
+
60
+ return rs
61
+ }
62
+
63
+
64
+ export default reuseDeletedIds
@@ -0,0 +1,91 @@
1
+ import get from 'lodash-es/get.js'
2
+ import each from 'lodash-es/each.js'
3
+ import isfun from 'wsemi/src/isfun.mjs'
4
+ import haskey from 'wsemi/src/haskey.mjs'
5
+
6
+
7
+ /**
8
+ * 送出類操作之前端雙擊防護核心 (與 Vue / store 無關, 供單元測試; 由 mUI 之 runSubmit 與 updateLoading 使用, ADR-025)
9
+ *
10
+ * - run(key, fn, opt): 同 key 之送出流程進行中(自觸發起至流程結束, 含結果訊息框開啟期間)再觸發即略過.
11
+ * 按鈕之滑鼠與鍵盤 Enter、輸入框之 Enter 等入口共用同一狀態.
12
+ * opt.pm 為 WButtonChip / WButtonCircle 之 promiseUnlock 鎖(click 事件之 msg.pm), 於 releaseBtnLocks()(全頁 loading 關閉, 即請求結束)
13
+ * 或流程結束時釋放; 被略過之觸發亦立即釋放其 pm, 否則該按鈕永久鎖住.
14
+ * fn 收到 unlock 函數: 流程需先開確認框者於開框前呼叫, 按鈕鎖只涵蓋請求期間(確認框背後之按鈕不顯示載入圖示), 重入仍由流程狀態擋.
15
+ * opt.hold 為函數, 輸入流程結果, 回傳 true 時保持占位與按鈕鎖(如登入成功轉址, 頁面即將離開).
16
+ * - releaseBtnLocks(): 釋放進行中流程所登記之按鈕鎖, 流程狀態不變(結果訊息框開啟期間之重入仍擋).
17
+ * - isRunning(key): 查詢 key 是否進行中.
18
+ *
19
+ * 為何需要: 全頁 loading(WDialog)不搶焦點, 只擋滑鼠, 鍵盤事件仍送達背後之按鈕與輸入框;
20
+ * 按鈕鎖若於 handler 第一行即解除, 鍵盤連按照樣送出(2026-09-29 實測).
21
+ *
22
+ * @returns {Object} 回傳 { run, releaseBtnLocks, isRunning }
23
+ */
24
+ function submitGuard() {
25
+
26
+ //kpRunning, 進行中流程: key → { unlock }
27
+ let kpRunning = {}
28
+
29
+ let run = (key, fn, opt = {}) => {
30
+
31
+ //unlock, 釋放本次觸發之按鈕鎖; pm.resolve 重複呼叫無作用
32
+ let pm = get(opt, 'pm', null)
33
+ let unlock = () => {
34
+ if (pm && isfun(pm.resolve)) {
35
+ pm.resolve()
36
+ }
37
+ }
38
+
39
+ //同 key 進行中: 略過, 並立即釋放本次觸發之按鈕鎖
40
+ if (haskey(kpRunning, key)) {
41
+ unlock()
42
+ return Promise.resolve(null)
43
+ }
44
+
45
+ //占位
46
+ kpRunning[key] = { unlock }
47
+
48
+ //hold
49
+ let hold = get(opt, 'hold', null)
50
+
51
+ //end
52
+ let end = () => {
53
+ unlock()
54
+ delete kpRunning[key]
55
+ }
56
+
57
+ return Promise.resolve()
58
+ .then(() => {
59
+ return fn(unlock)
60
+ })
61
+ .then((r) => {
62
+ if (isfun(hold) && hold(r) === true) {
63
+ return r //保持占位與按鈕鎖
64
+ }
65
+ end()
66
+ return r
67
+ }, (err) => {
68
+ end()
69
+ return Promise.reject(err)
70
+ })
71
+ }
72
+
73
+ let releaseBtnLocks = () => {
74
+ each(kpRunning, (v) => {
75
+ v.unlock()
76
+ })
77
+ }
78
+
79
+ let isRunning = (key) => {
80
+ return haskey(kpRunning, key)
81
+ }
82
+
83
+ return {
84
+ run,
85
+ releaseBtnLocks,
86
+ isRunning,
87
+ }
88
+ }
89
+
90
+
91
+ export default submitGuard
package/srv.mjs CHANGED
@@ -55,7 +55,20 @@ let getUserByToken = async (token) => {
55
55
  isAdmin: 'n',
56
56
  }
57
57
  }
58
- console.log('invalid token', token)
58
+ if (process.env.NODE_ENV !== 'production') { //測試用權杖(非 production 才有), 供 test/api-tokenSafety 驗證注入函數失敗時不外洩(W 契約, spec/設計要點與取捨.md ADR-023)
59
+ if (token === '{token-for-reject}') { //模擬部署方以舊版 w-web-sso helper(≤1.1.4)注入: 失敗時 reject 之字串含已代入系統介接權杖(合成秘密)與使用者權杖之完整網址
60
+ return Promise.reject(`can not get user data by url[http://127.0.0.1:11007/api/getSsoUserInfor?token=SYNTH-SYS-SECRET-FOR-TEST&key=token&value=${token}]`)
61
+ }
62
+ if (token === '{token-for-verify-throw}') { //回有效使用者, 但 verifyClientUser / verifyAppUser 對其拋錯(Error.message 為合成秘密), 模擬部署方驗證函數拋錯
63
+ return {
64
+ id: 'id-for-verify-throw',
65
+ name: 'verify-throw',
66
+ email: 'verify-throw@example.com',
67
+ isAdmin: 'y',
68
+ }
69
+ }
70
+ }
71
+ console.log('invalid token') //不印權杖值(ADR-023)
59
72
  return {}
60
73
  }
61
74
 
@@ -63,6 +76,9 @@ let verifyClientUser = (user, from) => {
63
76
  // console.log('verifyClientUser/user', user)
64
77
  // console.log('於生產環境時得加入限制瀏覽器使用者身份機制')
65
78
  // return false //測試無法登入
79
+ if (process.env.NODE_ENV !== 'production' && user.id === 'id-for-verify-throw') { //測試用: 見 getUserByToken 之 '{token-for-verify-throw}'
80
+ throw new Error('SYNTH-VERIFY-SECRET-FOR-TEST')
81
+ }
66
82
  return user.isAdmin === 'y' //測試僅系統管理者使用
67
83
  }
68
84
 
@@ -70,6 +86,9 @@ let verifyAppUser = (user, from) => {
70
86
  // console.log('verifyAppUser/user', user)
71
87
  // console.log('於生產環境時得加入限制應用程式使用者身份機制')
72
88
  // return false //測試無法登入
89
+ if (process.env.NODE_ENV !== 'production' && user.id === 'id-for-verify-throw') { //測試用: 見 getUserByToken 之 '{token-for-verify-throw}'
90
+ throw new Error('SYNTH-VERIFY-SECRET-FOR-TEST')
91
+ }
73
92
  return user.isAdmin === 'y' //測試僅系統管理者使用
74
93
  }
75
94
 
@@ -1 +0,0 @@
1
- .bg-gray-100[data-v-b5db7122]{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50[data-v-b5db7122]{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-white[data-v-b5db7122]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.border[data-v-b5db7122]{border-width:1px}.border-collapse[data-v-b5db7122]{border-collapse:collapse}.border-solid[data-v-b5db7122]{border-style:solid}.border-gray-200[data-v-b5db7122]{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300[data-v-b5db7122]{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.flex[data-v-b5db7122]{display:-webkit-box;display:-ms-flexbox;display:flex}.focus\:ring-blue-500[data-v-b5db7122]:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.font-medium[data-v-b5db7122]{font-weight:500}.font-semibold[data-v-b5db7122]{font-weight:600}.h-4[data-v-b5db7122]{height:1rem}.hover\:bg-gray-50[data-v-b5db7122]:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.items-center[data-v-b5db7122]{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.justify-end[data-v-b5db7122]{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.mb-8[data-v-b5db7122]{margin-bottom:2rem}.ml-2[data-v-b5db7122]{margin-left:.5rem}.mr-2[data-v-b5db7122]{margin-right:.5rem}.mt-2[data-v-b5db7122]{margin-top:.5rem}.overflow-x-auto[data-v-b5db7122]{overflow-x:auto}.p-5[data-v-b5db7122]{padding:1.25rem}.pb-2[data-v-b5db7122]{padding-bottom:.5rem}.px-2[data-v-b5db7122]{padding-left:.5rem;padding-right:.5rem}.px-6[data-v-b5db7122]{padding-left:1.5rem;padding-right:1.5rem}.py-1[data-v-b5db7122]{padding-top:.25rem;padding-bottom:.25rem}.py-3[data-v-b5db7122]{padding-top:.75rem;padding-bottom:.75rem}.py-4[data-v-b5db7122]{padding-top:1rem;padding-bottom:1rem}.relative[data-v-b5db7122]{position:relative}.rounded[data-v-b5db7122]{border-radius:.25rem}.rounded-lg[data-v-b5db7122]{border-radius:.5rem}.shadow-md[data-v-b5db7122]{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);-webkit-box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.space-x-4[data-v-b5db7122]>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-8[data-v-b5db7122]>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.text-blue-600[data-v-b5db7122]{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-gray-500[data-v-b5db7122]{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-700[data-v-b5db7122]{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-900[data-v-b5db7122]{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-left[data-v-b5db7122]{text-align:left}.text-lg[data-v-b5db7122]{font-size:1.125rem;line-height:1.75rem}.text-purple-600[data-v-b5db7122]{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-sm[data-v-b5db7122]{font-size:.875rem;line-height:1.25rem}.text-xs[data-v-b5db7122]{font-size:.75rem;line-height:1rem}.transition-colors[data-v-b5db7122]{-webkit-transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1);-webkit-transition-duration:.15s;transition-duration:.15s}.w-4[data-v-b5db7122]{width:1rem}.w-full[data-v-b5db7122]{width:100%}.staCtrl[data-v-b5db7122]{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;gap:12px;padding:8px 0}.staCtrlRight[data-v-b5db7122],.staCtrl[data-v-b5db7122]{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.staCtrlRight[data-v-b5db7122]{-webkit-box-align:center;-ms-flex-align:center;align-items:center;gap:16px}body,html{font-family:微軟正黑體,Microsoft JhengHei,MicrosoftJhengHeiRegular,Avenir,Helvetica,Arial,sans-serif;overflow-y:hidden}a,button,div,input,p,pre,span,textarea{font-family:inherit}.fade-enter-active{-webkit-animation:go 1s;animation:go 1s}.fade-leave-active{-webkit-animation:back 1s;animation:back 1s}@-webkit-keyframes go{0%{opacity:0}to{opacity:1}}@keyframes go{0%{opacity:0}to{opacity:1}}@-webkit-keyframes back{0%{opacity:1}to{opacity:0}}@keyframes back{0%{opacity:1}to{opacity:0}}