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
@@ -0,0 +1,104 @@
1
+ import isestr from 'wsemi/src/isestr.mjs'
2
+ import isarr from 'wsemi/src/isarr.mjs'
3
+
4
+
5
+ //maskLog: 權杖與網址寫入 console / srLog 前之遮罩(M 契約, spec/設計要點與取捨.md ADR-023)
6
+ //四 repo 一致(w-web-sso server/srLog.mjs 之 maskToken 與 src/maskUrl.mjs、w-web-api / w-web-task 之 maskTok), 以同一組測資守護(test/unit-maskLog.test.mjs)
7
+ //why: 原 console 站點原樣印出 token / req.query / authorization(Hapi 對重複之 query 參數給陣列, 型別檢查失敗之分支正是印出處), verifyConn 之 srLog 原樣記 referer
8
+
9
+
10
+ //safe: 露出字元之控制字元換成 '?', 避免請求端以 CR/LF 偽造 log 行
11
+ function safe(s) {
12
+ return Array.from(s, (c) => {
13
+ let x = c.charCodeAt(0)
14
+ return (x < 32 || x === 127) ? '?' : c
15
+ }).join('')
16
+ }
17
+
18
+
19
+ /**
20
+ * 權杖遮罩
21
+ * - 空值(undefined / null / '')回 ''
22
+ * - 陣列逐元素遮罩
23
+ * - 字串(長度 n)露出前後各 k=min(4, floor(n/8)) 字: k=0 只回 '(len=n)', 否則 '前k...後k(len=n)'; 36 字元之 session token 為前 4 後 4(與 w-web-sso 格式相同)
24
+ * - 其他型別只回 '(typeof)', 絕不輸出原值
25
+ *
26
+ * @param {*} token 輸入權杖
27
+ * @returns {String|Array} 回傳遮罩後字串, 輸入陣列時回傳陣列
28
+ */
29
+ function maskTok(token) {
30
+ if (isarr(token)) {
31
+ return token.map(maskTok)
32
+ }
33
+ if (token === undefined || token === null || token === '') {
34
+ return ''
35
+ }
36
+ if (!isestr(token)) {
37
+ return `(${typeof token})`
38
+ }
39
+ let n = token.length
40
+ let k = Math.min(4, Math.floor(n / 8))
41
+ if (k === 0) {
42
+ return `(len=${n})`
43
+ }
44
+ return `${safe(token.slice(0, k))}...${safe(token.slice(-k))}(len=${n})`
45
+ }
46
+
47
+
48
+ /**
49
+ * 查詢參數遮罩: 淺拷貝, 鍵名小寫為 token 者以 maskTok 處理, 其餘原樣(供印出 req.query)
50
+ * 非物件輸入依 maskTok 處理
51
+ *
52
+ * @param {*} q 輸入查詢參數物件(Hapi 之 req.query 為 null-prototype 物件)
53
+ * @returns {Object|String|Array} 回傳遮罩後之淺拷貝
54
+ */
55
+ function maskQuery(q) {
56
+ if (q === null || typeof q !== 'object' || isarr(q)) {
57
+ return maskTok(q)
58
+ }
59
+ let r = {}
60
+ for (let k of Object.keys(q)) {
61
+ r[k] = (k.toLowerCase() === 'token') ? maskTok(q[k]) : q[k]
62
+ }
63
+ return r
64
+ }
65
+
66
+
67
+ /**
68
+ * 網址遮罩: http(s) 只留 origin + pathname, 捨棄 query / fragment / userinfo(其內常含權杖, 如 ?token=)
69
+ * - 陣列逐元素處理; 空值回 ''; 其他非字串只回 '(typeof)'
70
+ * - 非 http(s) 之網址(如 data:、javascript:)路徑即全部內容, 只回 protocol(同 w-web-sso src/maskUrl.mjs)
71
+ * - 無法解析回 '(invalid-url)'
72
+ *
73
+ * @param {*} u 輸入網址
74
+ * @returns {String|Array} 回傳可寫入 log 之字串, 輸入陣列時回傳陣列
75
+ */
76
+ function maskUrl(u) {
77
+ if (isarr(u)) {
78
+ return u.map(maskUrl)
79
+ }
80
+ if (u === undefined || u === null || u === '') {
81
+ return ''
82
+ }
83
+ if (!isestr(u)) {
84
+ return `(${typeof u})`
85
+ }
86
+ let x = null
87
+ try {
88
+ x = new URL(u)
89
+ }
90
+ catch (err) {
91
+ return '(invalid-url)'
92
+ }
93
+ if (x.protocol !== 'http:' && x.protocol !== 'https:') {
94
+ return x.protocol
95
+ }
96
+ return `${x.origin}${x.pathname}`
97
+ }
98
+
99
+
100
+ export {
101
+ maskTok,
102
+ maskQuery,
103
+ maskUrl
104
+ }
@@ -49,7 +49,7 @@ function mergePickKeysOnly(ltdtDb, rowsIn, keyDetect, pickKeys) {
49
49
  }
50
50
 
51
51
 
52
- function proc(woItems, procOrm, { srLog, kmx }) {
52
+ function proc(woItems, procOrm, { srLog, kmx, lockSave }) {
53
53
 
54
54
 
55
55
  //updateTabItems
@@ -59,8 +59,21 @@ function proc(woItems, procOrm, { srLog, kmx }) {
59
59
  //pickKeysOnly, 僅採納既有列之指定欄位變更(見 mergePickKeysOnly), 未給則整表 diff 寫入
60
60
  let pickKeysOnly = get(opt, 'pickKeysOnly', null)
61
61
 
62
+ //idsNew: 前端標為新增(transient 欄位 _isNew === true, 清單頁 addItem / copyItem 標記)之列 id, 供下方比對既有列 (ADR-025);
63
+ //_isNew 不在 schema keys 內, 下方 ltdtmapping 取欄時即剝除不入庫. pickKeysOnly 模式不採納新增列(見 mergePickKeysOnly), 故不收集
64
+ let idsNew = []
65
+ if (!isearr(pickKeysOnly)) {
66
+ each(rows, (r) => {
67
+ if (get(r, '_isNew') === true) {
68
+ idsNew.push(get(r, 'id', ''))
69
+ }
70
+ })
71
+ }
72
+
73
+ //雙擊防護(後端): 同一操作者之同表寫入處理中再送出即拒絕 'saveInProgress'(不排隊, 見 lockSave.mjs, ADR-025);
74
+ //占位包在 kmx 之外層, 不同操作者之並行寫入仍由 kmx 序列化、各自成功(ADR-012)
62
75
  //序列化同表之並行整表批次寫入: 防 lost update (同 keyTable 序列化, 不同表並行)
63
- return await kmx('updateTabItems:' + keyTable, async () => {
76
+ return await lockSave(`updateTabItems:${keyTable}`, userId, () => kmx('updateTabItems:' + keyTable, async () => {
64
77
 
65
78
  //pickKeysOnly, 須於 mutex 內讀取資料庫為基底, 避免與並行寫入 race
66
79
  if (isearr(pickKeysOnly)) {
@@ -137,6 +150,19 @@ function proc(woItems, procOrm, { srLog, kmx }) {
137
150
 
138
151
  //ltdtDiffByKey
139
152
  let ltdtOld = await woItems[keyTable].select()
153
+
154
+ //新增列已存在即拒絕 (ADR-025): 同一包重送且第 2 次於第 1 次完成後才處理時(占位已釋放), 第 1 次已建立之列會被當「修改」(diff),
155
+ //以前端之佔位字覆寫伺服器於新增時填入之建立者 / 建立時間(procOrm save 只覆寫 userIdUpdate / timeUpdate); 故整包拒絕、不寫入
156
+ if (size(idsNew) > 0) {
157
+ let kpIdOld = {}
158
+ each(ltdtOld, (v) => {
159
+ kpIdOld[get(v, 'id', '')] = true
160
+ })
161
+ if (idsNew.some((id) => kpIdOld[id] === true)) {
162
+ return Promise.reject('saveNewRowExists')
163
+ }
164
+ }
165
+
140
166
  let ltdtNew = rows
141
167
  let r = ltdtDiffByKey(ltdtOld, ltdtNew, keyDetect)
142
168
  // console.log('ltdtDiffByKey r', r)
@@ -167,7 +193,7 @@ function proc(woItems, procOrm, { srLog, kmx }) {
167
193
 
168
194
  return ltdtNew
169
195
 
170
- })
196
+ }))
171
197
  }
172
198
 
173
199
 
@@ -146,6 +146,14 @@ let kpLang = {
146
146
  eng: `Duplicate field value in saved data`,
147
147
  cht: `儲存資料的欄位值重複`,
148
148
  },
149
+ saveInProgress: { //同一操作者之同表儲存處理中再送出 (ADR-025)
150
+ eng: `The previous save is still in progress. Please do not submit again.`,
151
+ cht: `上一次儲存仍在處理中,請勿重複送出`,
152
+ },
153
+ saveNewRowExists: { //標為新增之列已存在(同一包資料重送) (ADR-025)
154
+ eng: `The newly added data has already been saved. Please reload the page before making changes.`,
155
+ cht: `新增之資料已儲存過,請重新整理頁面後再操作`,
156
+ },
149
157
  saveTabItemsSuccess: {
150
158
  eng: `Saved successfully`,
151
159
  cht: `儲存成功`,
@@ -6,13 +6,23 @@ import staEvent from './staLogs/staEvent.callWorker.mjs'
6
6
  import staEventTable from './staLogs/staEventTable.callWorker.mjs'
7
7
 
8
8
 
9
+ //mock 之事件名單:
10
+ // true(預設 5 種):既有 e2e 標準圖所用;
11
+ // 'many'(13 種):本系統實際會記錄之事件名(取自本機 log),供圖例列數跨門檻之 e2e(1440 寬時一般式 2 列,勾全部加總並切中文為 3 列)
12
+ let MOCK_EVENTS = ['verifyConn', 'updateTargets-success', 'checkUser-error', 'api/getPerm-success', 'getWebInfor-success']
13
+ let MOCK_EVENTS_MANY = ['api/getPermUserInfor-success', 'api/getUserByToken-success', 'getStaEvent-success', 'getStaEventTable-success', 'getTokenUser-error', 'getWebInfor-success', 'updateGrups-success', 'updatePemis-success', 'updateTabItems-pickKeysOnly', 'updateTargets-success', 'updateUsers-success', 'verifyConn', 'verifyConn-error']
14
+ function getMockEvents(mock) {
15
+ return mock === 'many' ? MOCK_EVENTS_MANY : MOCK_EVENTS
16
+ }
17
+
18
+
9
19
  //mock 確定性資料集(供 e2e 統計圖穩定用):固定起點時間 + 固定 sin 計數,不依 now / log → 每次完全相同。
10
- //觸發:opt.mock=true(由 settings.json staEventMock 經 srv.mjs → WWebPerm 傳入)。非 mock 時走真實 staEvent。
11
- function genMockStaEvent(timeInterval = 'hr') {
20
+ //觸發:opt.mock 為 true 或 'many'(由 settings.json staEventMock 經 srv.mjs → WWebPerm 傳入)。非 mock 時走真實 staEvent。
21
+ function genMockStaEvent(timeInterval = 'hr', mock = true) {
12
22
  let fmt = timeInterval === 'day' ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH'
13
23
  let unit = timeInterval === 'hr' ? 'hour' : 'day'
14
24
  let nBuckets = timeInterval === 'hr' ? 48 : 7
15
- let events = ['verifyConn', 'updateTargets-success', 'checkUser-error', 'api/getPerm-success', 'getWebInfor-success']
25
+ let events = getMockEvents(mock)
16
26
  let base = ot('2025-01-01T00:00:00') //固定起點,不依 now
17
27
  let rs = []
18
28
  for (let i = 0; i < nBuckets; i++) {
@@ -30,15 +40,23 @@ function genMockStaEvent(timeInterval = 'hr') {
30
40
 
31
41
 
32
42
  //mock 確定性資料集(供 e2e 統計表穩定用):固定窗計數,不依 now / log → 每次完全相同。
33
- //5 個 event 與 genMockStaEvent 同名單;各事件滿足 last1Day>last8Hour>last4Hour>last1Hour,且各事件 last1Day 互不相同(排序明確、上多下少)。
34
- function genMockStaEventTable() {
35
- let rs = [
36
- { event: 'verifyConn', last1Day: 240, last8Hour: 90, last4Hour: 50, last1Hour: 15 },
37
- { event: 'updateTargets-success', last1Day: 180, last8Hour: 70, last4Hour: 38, last1Hour: 11 },
38
- { event: 'checkUser-error', last1Day: 120, last8Hour: 45, last4Hour: 24, last1Hour: 7 },
39
- { event: 'api/getPerm-success', last1Day: 90, last8Hour: 33, last4Hour: 18, last1Hour: 5 },
40
- { event: 'getWebInfor-success', last1Day: 60, last8Hour: 22, last4Hour: 12, last1Hour: 3 },
41
- ]
43
+ //事件名單與 genMockStaEvent 相同;各事件滿足 last1Day>last8Hour>last4Hour>last1Hour,且各事件 last1Day 互不相同(排序明確、上多下少)。
44
+ function genMockStaEventTable(mock = true) {
45
+ if (mock !== 'many') {
46
+ let rs = [
47
+ { event: 'verifyConn', last1Day: 240, last8Hour: 90, last4Hour: 50, last1Hour: 15 },
48
+ { event: 'updateTargets-success', last1Day: 180, last8Hour: 70, last4Hour: 38, last1Hour: 11 },
49
+ { event: 'checkUser-error', last1Day: 120, last8Hour: 45, last4Hour: 24, last1Hour: 7 },
50
+ { event: 'api/getPerm-success', last1Day: 90, last8Hour: 33, last4Hour: 18, last1Hour: 5 },
51
+ { event: 'getWebInfor-success', last1Day: 60, last8Hour: 22, last4Hour: 12, last1Hour: 3 },
52
+ ]
53
+ return rs
54
+ }
55
+ //'many': 依名單順序遞減, last1Day 自 400 起每事件少 20(互不相同)
56
+ let rs = MOCK_EVENTS_MANY.map((event, k) => {
57
+ let last1Day = 400 - k * 20
58
+ return { event, last1Day, last8Hour: Math.round(last1Day * 0.4), last4Hour: Math.round(last1Day * 0.2), last1Hour: Math.round(last1Day * 0.05) }
59
+ })
42
60
  return rs
43
61
  }
44
62
 
@@ -66,7 +84,7 @@ function proc(opt = {}) {
66
84
 
67
85
  //mock 模式回固定確定性資料集
68
86
  if (mock) {
69
- return genMockStaEvent(timeInterval)
87
+ return genMockStaEvent(timeInterval, mock)
70
88
  }
71
89
 
72
90
  //staEvent
@@ -98,7 +116,7 @@ function proc(opt = {}) {
98
116
 
99
117
  //mock 模式回固定確定性資料集
100
118
  if (mock) {
101
- return genMockStaEventTable()
119
+ return genMockStaEventTable(mock)
102
120
  }
103
121
 
104
122
  //staEventTable
@@ -0,0 +1,91 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+ import isarr from 'wsemi/src/isarr.mjs'
4
+ import isfun from 'wsemi/src/isfun.mjs'
5
+
6
+
7
+ //wrapInjected: 部署方注入函數(getUserByToken / verifyClientUser / verifyAppUser)之包裝(W 契約, spec/設計要點與取捨.md ADR-023)
8
+ //why: 注入函數拋錯或 reject 時原會被 await 原樣上拋, 其原文經 pm2resolve 回前端、經 verifyConn 之 msg 入 srLog、經 error 事件印出;
9
+ //部署方常以 w-web-sso 對外 helper(≤1.1.4)包裝 getUserByToken, 其 reject 字串含已代入系統介接權杖與使用者權杖之完整網址(修正前探測實證)。
10
+ //包裝後失敗視同否定結果(由呼叫端給 failValue), 走各呼叫點既有之查無 / 無權限分支回自家 key; 上游原文不回前端、不入 log。
11
+
12
+
13
+ //keysUpstream: 上游失敗可照記之 key 白名單 = w-web-sso 對外 helper 之 K 契約 14 個 key(w-web-sso ADR-068)
14
+ //與 sso 之同步點: sso 之 K 契約增減 key 時須同步本表(見 CLAUDE_rulebook.md「權杖不外洩」)
15
+ let keysUpstream = [
16
+ 'invalidUrl',
17
+ 'invalidTokenSelf',
18
+ 'invalidTokenTar',
19
+ 'invalidUserIdTar',
20
+ 'noTokenKeyValueInUrl',
21
+ 'noTokenKeyUserIdInUrl',
22
+ 'noTokenInUrl',
23
+ 'cannotGetUserByUrl',
24
+ 'cannotGetUsersByUrl',
25
+ 'cannotGetUserDataByUrl',
26
+ 'cannotGetUsersDataByUrl',
27
+ 'noUserDataByUrl',
28
+ 'noUsersDataByUrl',
29
+ 'noUserDataAfterConvert',
30
+ ]
31
+
32
+
33
+ //reName: Error.name 之名稱形狀(同 w-web-sso helper 診斷之 errName), 不符者不記(name 亦可被設為任意字串)
34
+ let reName = /^[A-Za-z]{1,40}$/
35
+
36
+
37
+ /**
38
+ * 描述上游失敗, 供寫入 log: 絕不含上游原文
39
+ * - reject 值(字串)或其 message 屬白名單 key → { upstream: key }
40
+ * - 其餘 → { upstreamType, upstreamName }: 型別(null / array / typeof)與名稱形狀之 Error.name(無則 '')
41
+ *
42
+ * @param {*} err 輸入注入函數拋出或 reject 之值
43
+ * @returns {Object} 回傳描述物件
44
+ */
45
+ function describeUpstream(err) {
46
+ let key = isestr(err) ? err : get(err, 'message')
47
+ if (isestr(key) && keysUpstream.includes(key)) {
48
+ return { upstream: key }
49
+ }
50
+ let upstreamType = (err === null) ? 'null' : (isarr(err) ? 'array' : typeof err)
51
+ let name = get(err, 'name')
52
+ let upstreamName = (isestr(name) && reName.test(name)) ? name : ''
53
+ return { upstreamType, upstreamName }
54
+ }
55
+
56
+
57
+ /**
58
+ * 包裝注入函數: 回傳(同步或 Promise)原樣回傳; 同步拋錯或 reject 回 failValue, 並以 describeUpstream(err) 呼叫 onFail
59
+ * 包裝後一律回 Promise(呼叫端既有之 ispm 判斷照常)
60
+ *
61
+ * @param {Function} fn 輸入注入函數
62
+ * @param {Object} [opt={}] 輸入設定物件
63
+ * @param {*} [opt.failValue=null] 輸入失敗時之回傳值(getUserByToken 為 null 等同查無, verify* 為 false 等同無權限)
64
+ * @param {Function} [opt.onFail=null] 輸入失敗時之紀錄函數, 只收到 describeUpstream 之結果(收不到上游原文); 其自身拋錯不影響回傳
65
+ * @returns {Function} 回傳包裝後之函數
66
+ */
67
+ function wrapInjected(fn, opt = {}) {
68
+ let failValue = get(opt, 'failValue', null)
69
+ let onFail = get(opt, 'onFail', null)
70
+ return async (...args) => {
71
+ try {
72
+ return await fn(...args) //await 於 try 內: 同步拋錯、reject 與 thenable 之 reject 皆攔截
73
+ }
74
+ catch (err) {
75
+ if (isfun(onFail)) {
76
+ try {
77
+ onFail(describeUpstream(err))
78
+ }
79
+ catch (e) {} //紀錄失敗不改變「失敗=否定結果」之語意
80
+ }
81
+ return failValue
82
+ }
83
+ }
84
+ }
85
+
86
+
87
+ export {
88
+ keysUpstream,
89
+ describeUpstream
90
+ }
91
+ export default wrapInjected
@@ -59,6 +59,12 @@ export default {
59
59
 
60
60
  let vo = this
61
61
 
62
+ //單例: 新訊息取代開啟中之舊訊息時, 舊訊息之等待者視同已按確定(pm.resolve 對已結束者無作用);
63
+ //否則其流程永久等待, 送出流程之 runSubmit 占位不釋放而該操作無法再觸發 (ADR-025)
64
+ if (vo.pm) {
65
+ vo.pm.resolve()
66
+ }
67
+
62
68
  //pm
63
69
  vo.pm = genPm()
64
70
 
@@ -54,6 +54,12 @@ export default {
54
54
 
55
55
  let vo = this
56
56
 
57
+ //單例: 新確認框取代開啟中之舊確認框時, 舊確認框之等待者視同選 No(reject('close'), 對已結束者無作用);
58
+ //否則其流程永久等待, 送出流程之 runSubmit 占位不釋放而該操作無法再觸發 (ADR-025)
59
+ if (vo.pm) {
60
+ vo.pm.reject('close')
61
+ }
62
+
57
63
  //pm
58
64
  vo.pm = genPm()
59
65
 
@@ -181,10 +181,12 @@
181
181
  :icon="mdiCloudUploadOutline"
182
182
  :backgroundColor="'rgba(255,0,50,0.7)'"
183
183
  :backgroundColorHover="'rgba(255,0,50,0.8)'"
184
+ :backgroundColorFocus="'rgba(255,0,50,0.8)'"
184
185
  :textColor="'#eee'"
185
186
  :textColorHover="'#fff'"
186
187
  :iconColor="'#eee'"
187
188
  :iconColorHover="'#fff'"
189
+ :iconColorFocus="'#fff'"
188
190
  :shadow="false"
189
191
  :promiseUnlock="true"
190
192
  @click="saveGrups"
@@ -865,6 +867,8 @@ export default {
865
867
  r.timeCreate = `{${vo.$t('grupAddIdNew')}}`
866
868
  r.userIdUpdate = `{${vo.$t('grupAddIdNew')}}`
867
869
  r.timeUpdate = `{${vo.$t('grupAddIdNew')}}`
870
+ //transient 欄位 _isNew: 標記未儲存之新列, 隨列送後端供「新增列已存在即拒絕」(saveNewRowExists), 後端檢查後剝除不入庫 (ADR-025)
871
+ r._isNew = true
868
872
  // console.log('r', r)
869
873
 
870
874
  //belongUsers, us
@@ -940,6 +944,8 @@ export default {
940
944
  r.timeCreate = `{${vo.$t('grupAddIdNew')}}`
941
945
  r.userIdUpdate = `{${vo.$t('grupAddIdNew')}}`
942
946
  r.timeUpdate = `{${vo.$t('grupAddIdNew')}}`
947
+ //transient 欄位 _isNew: 複製出之列亦為新列(見 addItem) (ADR-025)
948
+ r._isNew = true
943
949
  // console.log('r', r)
944
950
 
945
951
  //belongUsers, us
@@ -1182,26 +1188,23 @@ export default {
1182
1188
  },
1183
1189
 
1184
1190
  saveGrups: function(msg) {
1185
-
1191
+ //promiseUnlock 之鎖交由 doSaveGrups 之 runSubmit 於請求結束時釋放, 不於此解鎖: 請求期間儲存鈕之滑鼠與鍵盤 Enter 皆擋 (ADR-025;
1192
+ //原第一行即 msg.pm.resolve(), 鎖立即解除, 焦點留在儲存鈕時連按 Enter 可送出 2 次)
1186
1193
  let vo = this
1187
-
1188
- //第一行立刻釋放按鈕視覺鎖
1189
- msg.pm.resolve()
1190
-
1191
- //fire-and-forget, 不 await
1192
- vo.doSaveGrups()
1193
-
1194
+ vo.doSaveGrups({ pm: msg.pm })
1194
1195
  },
1195
1196
 
1196
- doSaveGrups: function() {
1197
+ doSaveGrups: function(opt = {}) {
1197
1198
  // console.log('method doSaveGrups')
1198
1199
 
1199
1200
  let vo = this
1200
1201
 
1201
- async function core() {
1202
+ async function core(unlockBtn) {
1202
1203
 
1203
- //1) 同步檢測 (在開 loading 之前)
1204
+ //1) 同步檢測 (在開 loading 之前); 早退之訊息框前以 unlockBtn() 釋放儲存鈕之 promiseUnlock 鎖: 按鈕鎖只涵蓋請求期間,
1205
+ //結果訊息框出現時按鈕已恢復(與打 API 之路徑一致); 流程狀態仍由 runSubmit 保持至訊息框關閉 (ADR-025)
1204
1206
  if (isestr(vo.isError)) {
1207
+ unlockBtn()
1205
1208
  await vo.$dg.showCheckYes(`${vo.isError}`)
1206
1209
  return
1207
1210
  }
@@ -1211,10 +1214,14 @@ export default {
1211
1214
 
1212
1215
  //check
1213
1216
  if (size(rows) === 0) {
1217
+ unlockBtn()
1214
1218
  await vo.$dg.showCheckYes(`${vo.$t('grupAddEmpty')}`)
1215
1219
  return
1216
1220
  }
1217
1221
 
1222
+ //新增列之 transient 欄位 _isNew(addItem / copyItem 標記)隨列送後端: 後端據以拒絕「標為新增但 id 已存在」之列
1223
+ //(同一包重送時不把剛建立之列當修改覆寫, 回 saveNewRowExists), 檢查後由 ltdtmapping 依 schema 取欄時剝除不入庫 (ADR-025)
1224
+
1218
1225
  //2) 確定打 API 才開 loading
1219
1226
  vo.$ui.updateLoading(true)
1220
1227
 
@@ -1237,18 +1244,20 @@ export default {
1237
1244
 
1238
1245
  }
1239
1246
 
1240
- //core
1241
- core()
1242
- .catch((err) => {
1243
- console.log('catch', err)
1244
- vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' })
1245
- })
1246
- .finally(() => {
1247
+ //runSubmit: 儲存流程(至結果訊息框關閉)進行中再觸發即略過; opt.pm 為儲存鈕之 promiseUnlock 鎖, 於請求結束(updateLoading(false))時釋放 (ADR-025)
1248
+ return vo.$ui.runSubmit('saveGrups', (unlockBtn) => {
1249
+ return core(unlockBtn)
1250
+ .catch((err) => {
1251
+ console.log('catch', err)
1252
+ vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' })
1253
+ })
1254
+ .finally(() => {
1247
1255
 
1248
- //hide loading
1249
- vo.$ui.updateLoading(false)
1256
+ //hide loading
1257
+ vo.$ui.updateLoading(false)
1250
1258
 
1251
- })
1259
+ })
1260
+ }, opt)
1252
1261
 
1253
1262
  },
1254
1263
 
@@ -181,10 +181,12 @@
181
181
  :icon="mdiCloudUploadOutline"
182
182
  :backgroundColor="'rgba(255,0,50,0.7)'"
183
183
  :backgroundColorHover="'rgba(255,0,50,0.8)'"
184
+ :backgroundColorFocus="'rgba(255,0,50,0.8)'"
184
185
  :textColor="'#eee'"
185
186
  :textColorHover="'#fff'"
186
187
  :iconColor="'#eee'"
187
188
  :iconColorHover="'#fff'"
189
+ :iconColorFocus="'#fff'"
188
190
  :shadow="false"
189
191
  :promiseUnlock="true"
190
192
  @click="savePemis"
@@ -866,6 +868,8 @@ export default {
866
868
  r.timeCreate = `{${vo.$t('pemiAddIdNew')}}`
867
869
  r.userIdUpdate = `{${vo.$t('pemiAddIdNew')}}`
868
870
  r.timeUpdate = `{${vo.$t('pemiAddIdNew')}}`
871
+ //transient 欄位 _isNew: 標記未儲存之新列, 隨列送後端供「新增列已存在即拒絕」(saveNewRowExists), 後端檢查後剝除不入庫 (ADR-025)
872
+ r._isNew = true
869
873
  // console.log('r', r)
870
874
 
871
875
  //belongGrups, gs
@@ -941,6 +945,8 @@ export default {
941
945
  r.timeCreate = `{${vo.$t('pemiAddIdNew')}}`
942
946
  r.userIdUpdate = `{${vo.$t('pemiAddIdNew')}}`
943
947
  r.timeUpdate = `{${vo.$t('pemiAddIdNew')}}`
948
+ //transient 欄位 _isNew: 複製出之列亦為新列(見 addItem) (ADR-025)
949
+ r._isNew = true
944
950
  // console.log('r', r)
945
951
 
946
952
  //belongGrups, gs
@@ -1184,26 +1190,23 @@ export default {
1184
1190
  },
1185
1191
 
1186
1192
  savePemis: function(msg) {
1187
-
1193
+ //promiseUnlock 之鎖交由 doSavePemis 之 runSubmit 於請求結束時釋放, 不於此解鎖: 請求期間儲存鈕之滑鼠與鍵盤 Enter 皆擋 (ADR-025;
1194
+ //原第一行即 msg.pm.resolve(), 鎖立即解除, 焦點留在儲存鈕時連按 Enter 可送出 2 次)
1188
1195
  let vo = this
1189
-
1190
- //第一行立刻釋放按鈕視覺鎖
1191
- msg.pm.resolve()
1192
-
1193
- //fire-and-forget, 不 await
1194
- vo.doSavePemis()
1195
-
1196
+ vo.doSavePemis({ pm: msg.pm })
1196
1197
  },
1197
1198
 
1198
- doSavePemis: function() {
1199
+ doSavePemis: function(opt = {}) {
1199
1200
  // console.log('method doSavePemis')
1200
1201
 
1201
1202
  let vo = this
1202
1203
 
1203
- async function core() {
1204
+ async function core(unlockBtn) {
1204
1205
 
1205
- //1) 同步檢測 (在開 loading 之前)
1206
+ //1) 同步檢測 (在開 loading 之前); 早退之訊息框前以 unlockBtn() 釋放儲存鈕之 promiseUnlock 鎖: 按鈕鎖只涵蓋請求期間,
1207
+ //結果訊息框出現時按鈕已恢復(與打 API 之路徑一致); 流程狀態仍由 runSubmit 保持至訊息框關閉 (ADR-025)
1206
1208
  if (isestr(vo.isError)) {
1209
+ unlockBtn()
1207
1210
  await vo.$dg.showCheckYes(`${vo.isError}`)
1208
1211
  return
1209
1212
  }
@@ -1213,10 +1216,14 @@ export default {
1213
1216
 
1214
1217
  //check
1215
1218
  if (size(rows) === 0) {
1219
+ unlockBtn()
1216
1220
  await vo.$dg.showCheckYes(`${vo.$t('pemiAddEmpty')}`)
1217
1221
  return
1218
1222
  }
1219
1223
 
1224
+ //新增列之 transient 欄位 _isNew(addItem / copyItem 標記)隨列送後端: 後端據以拒絕「標為新增但 id 已存在」之列
1225
+ //(同一包重送時不把剛建立之列當修改覆寫, 回 saveNewRowExists), 檢查後由 ltdtmapping 依 schema 取欄時剝除不入庫 (ADR-025)
1226
+
1220
1227
  //2) 確定打 API 才開 loading
1221
1228
  vo.$ui.updateLoading(true)
1222
1229
 
@@ -1239,18 +1246,20 @@ export default {
1239
1246
 
1240
1247
  }
1241
1248
 
1242
- //core
1243
- core()
1244
- .catch((err) => {
1245
- console.log('catch', err)
1246
- vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' })
1247
- })
1248
- .finally(() => {
1249
+ //runSubmit: 儲存流程(至結果訊息框關閉)進行中再觸發即略過; opt.pm 為儲存鈕之 promiseUnlock 鎖, 於請求結束(updateLoading(false))時釋放 (ADR-025)
1250
+ return vo.$ui.runSubmit('savePemis', (unlockBtn) => {
1251
+ return core(unlockBtn)
1252
+ .catch((err) => {
1253
+ console.log('catch', err)
1254
+ vo.$alert(vo.$t('anUnexpectedErrorOccurred'), { type: 'error' })
1255
+ })
1256
+ .finally(() => {
1249
1257
 
1250
- //hide loading
1251
- vo.$ui.updateLoading(false)
1258
+ //hide loading
1259
+ vo.$ui.updateLoading(false)
1252
1260
 
1253
- })
1261
+ })
1262
+ }, opt)
1254
1263
 
1255
1264
  },
1256
1265