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 => {
+
+
+
+
+
+
+
+
{
class="flex justify-between flex-row items-start gap-4 py-4"
>
-