feat(frontend): add Shangwutong inbox presence actions

This commit is contained in:
2026-09-12 10:41:55 +08:00
parent 9b39a574bd
commit ca63efaa79
4 changed files with 312 additions and 1 deletions
@@ -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",
@@ -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": "当前在线状态",
@@ -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: '<div><slot /></div>' },
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'
);
});
});
@@ -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 => {
</BaseSettingsHeader>
</template>
<template #body>
<BulkSelectBar
v-if="isAdmin && selectedInboxIds.size"
v-model="selectedInboxIds"
:all-items="visibleShangwutongInboxes"
:select-all-label="
$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.SELECT_ALL')
"
:selected-count-label="
$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.SELECTED', {
count: selectedInboxIds.size,
})
"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
<Button
:label="
$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.SET_ONLINE')
"
size="sm"
variant="outline"
color="teal"
:disabled="
!selectedShangwutongInboxes.length ||
isUpdatingAnyPresence ||
allSelectedHavePresence('online')
"
:is-loading="isUpdatingAnyPresence"
@click="updateSelectedPresence('online')"
/>
<Button
:label="
$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.SET_OFFLINE')
"
size="sm"
variant="outline"
color="slate"
:disabled="
!selectedShangwutongInboxes.length ||
isUpdatingAnyPresence ||
allSelectedHavePresence('offline')
"
:is-loading="isUpdatingAnyPresence"
@click="updateSelectedPresence('offline')"
/>
</div>
</template>
</BulkSelectBar>
<span
v-if="!filteredInboxesList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
@@ -121,6 +277,17 @@ const openDelete = inbox => {
class="flex justify-between flex-row items-start gap-4 py-4"
>
<div class="flex items-center gap-4">
<Checkbox
v-if="isAdmin && isShangwutongInbox(inbox)"
:model-value="isInboxSelected(inbox.id)"
:disabled="isUpdatingAnyPresence"
:title="
$t(
'INBOX_MGMT.SHANGWUTONG_SETTINGS.QUICK_PRESENCE.SELECT_INBOX'
)
"
@change="toggleInboxSelection(inbox.id)"
/>
<div
v-if="inbox.avatar_url"
class="bg-n-alpha-3 rounded-xl size-10 ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
@@ -150,7 +317,36 @@ const openDelete = inbox => {
/>
</div>
</div>
<div class="flex gap-3 justify-end">
<div class="flex flex-wrap gap-3 justify-end">
<div
v-if="isAdmin && isShangwutongInbox(inbox)"
class="flex items-center gap-2"
>
<Button
:label="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.ONLINE')"
size="sm"
variant="outline"
color="teal"
:disabled="
isUpdatingAnyPresence ||
inboxDesiredPresence(inbox) === 'online'
"
:is-loading="isPresenceUpdating(inbox.id)"
@click.stop="updateInboxPresence([inbox], 'online')"
/>
<Button
:label="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PRESENCE.OFFLINE')"
size="sm"
variant="outline"
color="slate"
:disabled="
isUpdatingAnyPresence ||
inboxDesiredPresence(inbox) === 'offline'
"
:is-loading="isPresenceUpdating(inbox.id)"
@click.stop="updateInboxPresence([inbox], 'offline')"
/>
</div>
<router-link
:to="{
name: 'settings_inbox_show',