diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 291e7585..682c8b1b 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -1190,6 +1190,15 @@ "AWAY": "Away", "OFFLINE": "Offline" }, + "QUICK_PRESENCE": { + "SELECT_ALL": "Select all Shangwutong inboxes", + "SELECT_INBOX": "Select this Shangwutong inbox", + "SELECTED": "Selected {count}", + "SET_ONLINE": "Set selected online", + "SET_OFFLINE": "Set selected offline", + "SUCCESS": "Set {count} Shangwutong inbox(es) to {presence}", + "ERROR": "Updated {success} Shangwutong inbox(es); {failed} failed. Please try again." + }, "STATUS_FIELDS": { "CONNECTION_STATUS": "Connection status", "ACTUAL_PRESENCE": "Current presence", diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json index def44e88..4d837671 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -1190,6 +1190,15 @@ "AWAY": "离开", "OFFLINE": "离线" }, + "QUICK_PRESENCE": { + "SELECT_ALL": "全选商务通收件箱", + "SELECT_INBOX": "选择此商务通收件箱", + "SELECTED": "已选择 {count} 个", + "SET_ONLINE": "批量上线", + "SET_OFFLINE": "批量下线", + "SUCCESS": "已将 {count} 个商务通收件箱切换为{presence}", + "ERROR": "已更新 {success} 个商务通收件箱,{failed} 个失败,请重试" + }, "STATUS_FIELDS": { "CONNECTION_STATUS": "连接状态", "ACTUAL_PRESENCE": "当前在线状态", diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.spec.js new file mode 100644 index 00000000..87cd224b --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.spec.js @@ -0,0 +1,97 @@ +import { shallowMount } from '@vue/test-utils'; +import Index from './Index.vue'; + +const mocked = vi.hoisted(() => ({ + alert: vi.fn(), + dispatch: vi.fn(), + inboxes: [], +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +vi.mock('dashboard/composables', () => ({ + useAlert: (...args) => mocked.alert(...args), +})); + +vi.mock('dashboard/composables/useAdmin', async () => { + const { computed } = await vi.importActual('vue'); + return { + useAdmin: () => ({ isAdmin: computed(() => true) }), + }; +}); + +vi.mock('dashboard/composables/store', async () => { + const { computed } = await vi.importActual('vue'); + return { + useMapGetter: () => computed(() => mocked.inboxes), + useStoreGetters: () => ({ + 'inboxes/getUIFlags': computed(() => ({ isFetching: false })), + }), + useStore: () => ({ dispatch: mocked.dispatch }), + }; +}); + +const mountComponent = () => + shallowMount(Index, { + global: { + mocks: { $t: key => key }, + stubs: { + SettingsLayout: { template: '
' }, + BaseSettingsHeader: true, + ChannelName: true, + ChannelIcon: true, + Avatar: true, + Button: true, + Checkbox: true, + BulkSelectBar: true, + RouterLink: true, + 'woot-confirm-delete-modal': true, + }, + }, + }); + +describe('inbox settings list', () => { + beforeEach(() => { + mocked.alert.mockReset(); + mocked.dispatch.mockReset().mockResolvedValue({}); + mocked.inboxes = [ + { + id: 1, + name: '商务通在线', + channel_type: 'Channel::Shangwutong', + desired_presence: 'online', + }, + { + id: 2, + name: '商务通离线', + channel_type: 'Channel::Shangwutong', + desired_presence: 'offline', + }, + { + id: 3, + name: 'API', + channel_type: 'Channel::Api', + }, + ]; + }); + + it('updates selected Shangwutong inboxes in one batch and skips unchanged ones', async () => { + const wrapper = mountComponent(); + + wrapper.vm.toggleInboxSelection(1); + wrapper.vm.toggleInboxSelection(2); + await wrapper.vm.updateSelectedPresence('offline'); + + expect(mocked.dispatch).toHaveBeenCalledTimes(1); + expect(mocked.dispatch).toHaveBeenCalledWith('inboxes/updateInbox', { + id: 1, + formData: false, + channel: { desired_presence: 'offline' }, + }); + expect(mocked.alert).toHaveBeenCalledWith( + 'INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.SUCCESS' + ); + }); +}); diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue index 884c198c..aba9736a 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue @@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables'; import { picoSearch } from '@scmmishra/pico-search'; import Avatar from 'next/avatar/Avatar.vue'; import { useAdmin } from 'dashboard/composables/useAdmin'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; import SettingsLayout from '../SettingsLayout.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import { @@ -15,6 +16,8 @@ import { import ChannelName from './components/ChannelName.vue'; import ChannelIcon from 'next/icon/ChannelIcon.vue'; import Button from 'dashboard/components-next/button/Button.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; +import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue'; const getters = useStoreGetters(); const store = useStore(); @@ -37,6 +40,108 @@ const filteredInboxesList = computed(() => { return picoSearch(inboxesList.value, query, ['name', 'channel_type']); }); +const selectedInboxIds = ref(new Set()); +const updatingPresenceIds = ref(new Set()); + +const isShangwutongInbox = inbox => + inbox.channel_type === INBOX_TYPES.SHANGWUTONG; +const inboxDesiredPresence = inbox => inbox.desired_presence || 'online'; +const getPresenceLabel = presence => + presence === 'online' + ? t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.ONLINE') + : t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.OFFLINE'); + +const shangwutongInboxes = computed(() => + (inboxesList.value || []).filter(isShangwutongInbox) +); +const visibleShangwutongInboxes = computed(() => + (filteredInboxesList.value || []).filter(isShangwutongInbox) +); +const selectedShangwutongInboxes = computed(() => + shangwutongInboxes.value.filter(inbox => selectedInboxIds.value.has(inbox.id)) +); +const isUpdatingAnyPresence = computed( + () => updatingPresenceIds.value.size > 0 +); + +const isInboxSelected = inboxId => selectedInboxIds.value.has(inboxId); +const isPresenceUpdating = inboxId => updatingPresenceIds.value.has(inboxId); +const allSelectedHavePresence = presence => + selectedShangwutongInboxes.value.length > 0 && + selectedShangwutongInboxes.value.every( + inbox => inboxDesiredPresence(inbox) === presence + ); + +const toggleInboxSelection = inboxId => { + const nextSelection = new Set(selectedInboxIds.value); + if (nextSelection.has(inboxId)) { + nextSelection.delete(inboxId); + } else { + nextSelection.add(inboxId); + } + selectedInboxIds.value = nextSelection; +}; + +const updateInboxPresence = async (targetInboxes, presence) => { + const inboxesToUpdate = targetInboxes.filter( + inbox => + isShangwutongInbox(inbox) && inboxDesiredPresence(inbox) !== presence + ); + if (!inboxesToUpdate.length) return; + + const nextUpdatingIds = new Set(updatingPresenceIds.value); + inboxesToUpdate.forEach(inbox => nextUpdatingIds.add(inbox.id)); + updatingPresenceIds.value = nextUpdatingIds; + + const results = await Promise.allSettled( + inboxesToUpdate.map(inbox => + Promise.resolve().then(() => + store.dispatch('inboxes/updateInbox', { + id: inbox.id, + formData: false, + channel: { desired_presence: presence }, + }) + ) + ) + ); + + const completedIds = new Set( + inboxesToUpdate + .filter((_, index) => results[index].status === 'fulfilled') + .map(inbox => inbox.id) + ); + selectedInboxIds.value = new Set( + [...selectedInboxIds.value].filter(id => !completedIds.has(id)) + ); + + const remainingUpdatingIds = new Set(updatingPresenceIds.value); + inboxesToUpdate.forEach(inbox => remainingUpdatingIds.delete(inbox.id)); + updatingPresenceIds.value = remainingUpdatingIds; + + const failedCount = results.filter( + result => result.status === 'rejected' + ).length; + if (failedCount) { + useAlert( + t('INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.ERROR', { + success: inboxesToUpdate.length - failedCount, + failed: failedCount, + }) + ); + return; + } + + useAlert( + t('INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.SUCCESS', { + count: inboxesToUpdate.length, + presence: getPresenceLabel(presence), + }) + ); +}; + +const updateSelectedPresence = presence => + updateInboxPresence(selectedShangwutongInboxes.value, presence); + const uiFlags = computed(() => getters['inboxes/getUIFlags'].value); const deleteConfirmText = computed( @@ -60,6 +165,9 @@ const confirmPlaceHolderText = computed( const deleteInbox = async ({ id }) => { try { await store.dispatch('inboxes/delete', id); + const nextSelection = new Set(selectedInboxIds.value); + nextSelection.delete(id); + selectedInboxIds.value = nextSelection; useAlert(t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE')); } catch (error) { useAlert(t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE')); @@ -108,6 +216,54 @@ const openDelete = inbox => {