diff --git a/sidepanel/editable-list-picker.js b/sidepanel/editable-list-picker.js new file mode 100644 index 0000000..fcbf61f --- /dev/null +++ b/sidepanel/editable-list-picker.js @@ -0,0 +1,228 @@ +(function attachSidepanelEditableListPicker(globalScope) { + const editableListPickers = []; + + function splitEditableListValues(value = '') { + return String(value || '') + .split(/[\r\n,,、]+/) + .map((name) => name.trim()) + .filter(Boolean); + } + + function normalizeEditableListValues(...sources) { + const values = []; + const seen = new Set(); + + const append = (value) => { + if (Array.isArray(value)) { + value.forEach(append); + return; + } + for (const item of splitEditableListValues(value)) { + const key = item.toLowerCase(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + values.push(item); + } + }; + + sources.forEach(append); + return values; + } + + function createEditableListPicker(config = {}) { + const { + root, + input, + trigger, + current, + menu, + emptyLabel = '请先添加', + fallbackItems = [], + minItems = 0, + deleteLabel = '删除', + itemLabel = '项目', + normalizeItems = normalizeEditableListValues, + normalizeValue = (value) => String(value || '').trim(), + getItemValue = (item) => String(item || '').trim(), + getItemLabel = (item) => getItemValue(item), + getItemDeleteLabel = (item) => getItemLabel(item), + onDelete = null, + onDeleteError = null, + } = config; + + const picker = { + root, + input, + trigger, + current, + menu, + items: [], + open: false, + }; + + const getFallbackItems = () => normalizeItems(fallbackItems); + const getNormalizedItemValue = (item) => normalizeValue(getItemValue(item)); + const findItemByValue = (value) => { + const normalized = normalizeValue(value); + return picker.items.find((item) => getNormalizedItemValue(item) === normalized) || null; + }; + const reportDeleteError = (error) => { + const fallbackMessage = `${deleteLabel}${itemLabel}失败。`; + if (typeof onDeleteError === 'function') { + onDeleteError(error, fallbackMessage); + return; + } + if (typeof globalScope.showToast === 'function') { + globalScope.showToast(error?.message || fallbackMessage, 'error'); + } + }; + + picker.setOpen = (open) => { + picker.open = Boolean(open) && !trigger?.disabled; + if (menu) { + menu.hidden = !picker.open; + } + if (trigger) { + trigger.setAttribute('aria-expanded', picker.open ? 'true' : 'false'); + trigger.classList?.toggle('is-open', picker.open); + } + }; + + picker.close = () => { + picker.setOpen(false); + }; + + picker.setVisible = (visible) => { + if (root) { + root.style.display = visible ? '' : 'none'; + } + if (!visible) { + picker.close(); + } + }; + + picker.setSelection = (value, options = {}) => { + const fallback = getNormalizedItemValue(picker.items[0]) || getNormalizedItemValue(getFallbackItems()[0]) || ''; + const selected = normalizeValue(value) || fallback; + const selectedItem = findItemByValue(selected); + if (input) { + input.value = selected; + if (options.emit && typeof input.dispatchEvent === 'function') { + input.dispatchEvent(new Event('change', { bubbles: true })); + } + } + if (current) { + current.textContent = selectedItem ? getItemLabel(selectedItem) : (selected || emptyLabel); + } + }; + + picker.render = (items = [], selectedValue = '') => { + const normalizedItems = normalizeItems(items); + picker.items = normalizedItems.length ? normalizedItems : getFallbackItems(); + const inputValue = normalizeValue(input?.value); + const selected = normalizeValue(selectedValue) + || (findItemByValue(inputValue) ? inputValue : '') + || getNormalizedItemValue(picker.items[0]) + || ''; + + if (trigger) { + trigger.disabled = picker.items.length === 0; + } + if (picker.items.length === 0) { + if (menu) { + menu.innerHTML = ''; + } + picker.setSelection('', { emit: false }); + picker.close(); + return; + } + + if ( + !menu + || typeof menu.appendChild !== 'function' + || typeof globalScope.document === 'undefined' + || typeof globalScope.document.createElement !== 'function' + ) { + picker.setSelection(selected, { emit: false }); + return; + } + + menu.innerHTML = ''; + picker.items.forEach((item) => { + const itemValue = getNormalizedItemValue(item); + const row = globalScope.document.createElement('div'); + row.className = 'editable-list-option-row'; + + const option = globalScope.document.createElement('button'); + option.type = 'button'; + option.className = 'editable-list-option'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', itemValue === selected ? 'true' : 'false'); + option.textContent = getItemLabel(item); + option.addEventListener('click', () => { + picker.setSelection(itemValue, { emit: true }); + picker.close(); + }); + + const deleteButton = globalScope.document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'editable-list-delete'; + deleteButton.textContent = deleteLabel; + deleteButton.title = `${deleteLabel}${itemLabel} ${getItemDeleteLabel(item)}`; + deleteButton.setAttribute('aria-label', `${deleteLabel}${itemLabel} ${getItemDeleteLabel(item)}`); + deleteButton.disabled = picker.items.length <= minItems; + deleteButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (typeof onDelete === 'function') { + Promise.resolve(onDelete(itemValue, item)).catch(reportDeleteError); + } + }); + + row.appendChild(option); + row.appendChild(deleteButton); + menu.appendChild(row); + }); + picker.setSelection(selected, { emit: false }); + }; + + trigger?.addEventListener('click', (event) => { + event.stopPropagation(); + editableListPickers.forEach((item) => { + if (item !== picker) { + item.close(); + } + }); + picker.setOpen(!picker.open); + }); + trigger?.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + picker.close(); + } + }); + menu?.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + editableListPickers.push(picker); + return picker; + } + + function closeEditableListPickers() { + editableListPickers.forEach((picker) => picker.close()); + } + + function isClickInsideEditableListPicker(target) { + return editableListPickers.some((picker) => Boolean(picker.root?.contains(target))); + } + + globalScope.SidepanelEditableListPicker = { + closeEditableListPickers, + createEditableListPicker, + isClickInsideEditableListPicker, + normalizeEditableListValues, + splitEditableListValues, + }; +})(window); diff --git a/sidepanel/paypal-manager.js b/sidepanel/paypal-manager.js index 97c1be0..f4de541 100644 --- a/sidepanel/paypal-manager.js +++ b/sidepanel/paypal-manager.js @@ -9,6 +9,7 @@ } = context; let actionInFlight = false; + let payPalAccountPicker = null; function getPayPalAccounts(currentState = state.getLatestState()) { return helpers.getPayPalAccounts(currentState); @@ -27,6 +28,58 @@ )).join(''); } + function getPayPalAccountValue(account = {}) { + return String(account?.id || '').trim(); + } + + function getPayPalAccountLabel(account = {}) { + return String(account?.email || '(未命名账号)'); + } + + function normalizePickerPayPalAccounts(accounts = []) { + return Array.isArray(accounts) + ? accounts.filter((account) => getPayPalAccountValue(account)) + : []; + } + + function getPayPalAccountPicker() { + if (payPalAccountPicker) { + return payPalAccountPicker; + } + + const pickerModule = helpers.editableListPicker || globalScope.SidepanelEditableListPicker; + const createEditableListPicker = pickerModule?.createEditableListPicker; + if ( + typeof createEditableListPicker !== 'function' + || !dom.payPalAccountPickerRoot + || !dom.btnPayPalAccountMenu + || !dom.payPalAccountCurrent + || !dom.payPalAccountMenu + ) { + return null; + } + + payPalAccountPicker = createEditableListPicker({ + root: dom.payPalAccountPickerRoot, + input: dom.selectPayPalAccount, + trigger: dom.btnPayPalAccountMenu, + current: dom.payPalAccountCurrent, + menu: dom.payPalAccountMenu, + emptyLabel: '请先添加 PayPal 账号', + itemLabel: '账号', + normalizeItems: normalizePickerPayPalAccounts, + normalizeValue: (value) => String(value || '').trim(), + getItemValue: getPayPalAccountValue, + getItemLabel: getPayPalAccountLabel, + getItemDeleteLabel: getPayPalAccountLabel, + onDelete: (accountId) => handleDeletePayPalAccount(accountId), + onDeleteError: (error, fallbackMessage) => { + helpers.showToast(error?.message || fallbackMessage, 'error'); + }, + }); + return payPalAccountPicker; + } + function applyPayPalAccountMutation(account) { if (!account?.id) return; const latestState = state.getLatestState(); @@ -43,10 +96,17 @@ const latestState = state.getLatestState(); const accounts = getPayPalAccounts(latestState); const currentId = getCurrentPayPalAccountId(latestState); + const selectedId = accounts.some((account) => account.id === currentId) ? currentId : ''; + const picker = getPayPalAccountPicker(); + + if (picker) { + picker.render(accounts, selectedId); + return; + } dom.selectPayPalAccount.innerHTML = buildSelectOptions(accounts); dom.selectPayPalAccount.disabled = accounts.length === 0; - dom.selectPayPalAccount.value = accounts.some((account) => account.id === currentId) ? currentId : ''; + dom.selectPayPalAccount.value = selectedId; } async function syncSelectedPayPalAccount(options = {}) { @@ -83,6 +143,62 @@ return response.account || null; } + async function handleDeletePayPalAccount(accountId) { + if (actionInFlight) return; + + const targetId = String(accountId || '').trim(); + if (!targetId) { + return; + } + + const latestState = state.getLatestState(); + const accounts = getPayPalAccounts(latestState); + const targetAccount = accounts.find((account) => account.id === targetId); + if (!targetAccount) { + return; + } + + actionInFlight = true; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = true; + } + + try { + const nextAccounts = accounts.filter((account) => account.id !== targetId); + const currentId = getCurrentPayPalAccountId(latestState); + const nextCurrentAccount = currentId === targetId + ? (nextAccounts[0] || null) + : (nextAccounts.find((account) => account.id === currentId) || null); + const payload = { + paypalAccounts: nextAccounts, + currentPayPalAccountId: nextCurrentAccount?.id || '', + paypalEmail: String(nextCurrentAccount?.email || '').trim(), + paypalPassword: String(nextCurrentAccount?.password || ''), + }; + + const response = await runtime.sendMessage({ + type: 'SAVE_SETTING', + source: 'sidepanel', + payload, + }); + if (response?.error) { + throw new Error(response.error); + } + + state.syncLatestState({ + ...payload, + currentPayPalAccountId: payload.currentPayPalAccountId || null, + }); + renderPayPalAccounts(); + helpers.showToast(`已删除 PayPal 账号:${targetAccount.email || targetId}`, 'success', 1600); + } finally { + actionInFlight = false; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = false; + } + } + } + async function openPayPalAccountDialog() { if (typeof helpers.openFormDialog !== 'function') { throw new Error('表单弹窗能力未加载,请刷新扩展后重试。'); @@ -180,6 +296,7 @@ return { bindPayPalEvents, + handleDeletePayPalAccount, renderPayPalAccounts, syncSelectedPayPalAccount, }; diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 45cb3c8..7473026 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -1971,13 +1971,13 @@ header { .data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); } [data-theme="dark"] .data-select { color-scheme: dark; } -.sub2api-group-picker { +.editable-list-picker { position: relative; flex: 1; min-width: 0; } -.sub2api-group-trigger { +.editable-list-trigger { width: 100%; display: flex; align-items: center; @@ -1987,7 +1987,7 @@ header { text-align: left; } -.sub2api-group-caret { +.editable-list-caret { width: 8px; height: 8px; border-right: 1.5px solid currentColor; @@ -1997,11 +1997,11 @@ header { opacity: 0.7; } -.sub2api-group-trigger.is-open .sub2api-group-caret { +.editable-list-trigger.is-open .editable-list-caret { transform: translateY(2px) rotate(225deg); } -.sub2api-group-menu { +.editable-list-menu { position: absolute; z-index: 70; top: calc(100% + 4px); @@ -2016,15 +2016,15 @@ header { box-shadow: var(--shadow-md); } -.sub2api-group-option-row { +.editable-list-option-row { display: flex; align-items: center; gap: 4px; min-height: 30px; } -.sub2api-group-option, -.sub2api-group-delete { +.editable-list-option, +.editable-list-delete { border: none; border-radius: var(--radius-sm); background: transparent; @@ -2033,7 +2033,7 @@ header { transition: background var(--transition), color var(--transition); } -.sub2api-group-option { +.editable-list-option { flex: 1; min-width: 0; padding: 6px 8px; @@ -2045,24 +2045,24 @@ header { white-space: nowrap; } -.sub2api-group-option[aria-selected="true"] { +.editable-list-option[aria-selected="true"] { color: var(--blue); background: var(--blue-soft); } -.sub2api-group-option:hover, -.sub2api-group-delete:hover:not(:disabled) { +.editable-list-option:hover, +.editable-list-delete:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-hover); } -.sub2api-group-delete { +.editable-list-delete { flex: 0 0 auto; padding: 5px 7px; font-size: 11px; } -.sub2api-group-delete:disabled { +.editable-list-delete:disabled { cursor: not-allowed; opacity: 0.42; } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index b509b79..dd2ebb5 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -199,13 +199,13 @@ 分组
-
- - +
@@ -267,9 +267,15 @@ @@ -459,7 +465,15 @@