删除communication自定义页面
This commit is contained in:
parent
d85b9539b6
commit
79903ac84c
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,274 +0,0 @@
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<n-input
|
||||
v-model:value="searchQueryModel"
|
||||
:placeholder="t('Search')"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: viewModeModel === '2col' }"
|
||||
@click="viewModeModel = '2col'"
|
||||
:title="t_ctx('List')"
|
||||
>
|
||||
<Icon icon="tabler:layout-list" :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: viewModeModel === '3col' }"
|
||||
@click="viewModeModel = '3col'"
|
||||
:title="t_ctx('Preview')"
|
||||
>
|
||||
<Icon icon="tabler:layout-columns" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="refresh-btn" @click="reloadHandler" :disabled="loadingModel">
|
||||
<i :class="loadingModel ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedKeysModel.length === 0"
|
||||
class="create-btn email-compose-btn"
|
||||
@click="openComposeEmail"
|
||||
:disabled="loadingModel"
|
||||
>
|
||||
<i class="fa fa-plus"></i>
|
||||
{{ t('New Email') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="delete-btn"
|
||||
@click="deleteSelectedHandler"
|
||||
:disabled="loadingModel"
|
||||
>
|
||||
<i class="fa fa-trash"></i>
|
||||
{{ t('Delete Selected') }} ({{ selectedKeysModel.length }})
|
||||
</button>
|
||||
|
||||
<!-- Email Compose Drawer -->
|
||||
<EmailComposeModal ref="composeRef" @sent="onEmailSent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { NInput } from 'naive-ui'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { t } from '@/shared/i18n'
|
||||
import EmailComposeModal from '@/shared/components/EmailComposeModal.vue'
|
||||
|
||||
interface Props {
|
||||
context?: {
|
||||
entity?: string
|
||||
searchQuery?: string
|
||||
viewMode?: '2col' | '3col'
|
||||
selectedKeys?: string[]
|
||||
loading?: boolean
|
||||
canEdit?: boolean
|
||||
reload?: () => void
|
||||
createRecordHandler?: () => void
|
||||
handleDeleteSelected?: () => void
|
||||
router?: any
|
||||
t?: any
|
||||
}
|
||||
// Also support direct props (same as DefaultListToolbar)
|
||||
entity?: string
|
||||
searchQuery?: string
|
||||
viewMode?: '2col' | '3col'
|
||||
selectedKeys?: string[]
|
||||
loading?: boolean
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:searchQuery', value: string): void
|
||||
(e: 'update:viewMode', value: '2col' | '3col'): void
|
||||
(e: 'reload'): void
|
||||
(e: 'create'): void
|
||||
(e: 'delete-selected'): void
|
||||
}>()
|
||||
|
||||
const composeRef = ref<InstanceType<typeof EmailComposeModal> | null>(null)
|
||||
|
||||
// 使用上下文的翻译函数(如果有),否则使用全局翻译
|
||||
const t_ctx = computed(() => props.context?.t ?? t)
|
||||
|
||||
// Resolve values from context or direct props
|
||||
const searchQueryModel = computed({
|
||||
get: () => props.context?.searchQuery ?? props.searchQuery ?? '',
|
||||
set: (value) => emit('update:searchQuery', value),
|
||||
})
|
||||
const viewModeModel = computed({
|
||||
get: () => props.context?.viewMode ?? props.viewMode ?? '3col',
|
||||
set: (value) => emit('update:viewMode', value),
|
||||
})
|
||||
const selectedKeysModel = computed(() => props.context?.selectedKeys ?? props.selectedKeys ?? [])
|
||||
const loadingModel = computed(() => props.context?.loading ?? props.loading ?? false)
|
||||
|
||||
function reloadHandler() {
|
||||
if (props.context?.reload) props.context.reload()
|
||||
else emit('reload')
|
||||
}
|
||||
|
||||
function deleteSelectedHandler() {
|
||||
if (props.context?.handleDeleteSelected) props.context.handleDeleteSelected()
|
||||
else emit('delete-selected')
|
||||
}
|
||||
|
||||
function openComposeEmail() {
|
||||
composeRef.value?.open()
|
||||
}
|
||||
|
||||
function onEmailSent(_data: any) {
|
||||
// Reload list to show the new communication
|
||||
reloadHandler()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 新建邮件按钮 - 与默认列表页创建按钮风格一致 */
|
||||
.email-compose-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #1fc76f;
|
||||
border-radius: 8px;
|
||||
background: #e6f8f0;
|
||||
color: #0d684b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.email-compose-btn:hover {
|
||||
background: #dcfce7;
|
||||
border-color: #1fc76f;
|
||||
color: #166534;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15);
|
||||
}
|
||||
|
||||
.email-compose-btn:active {
|
||||
background: #1fc76f;
|
||||
border-color: #1fc76f;
|
||||
color: white;
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2);
|
||||
}
|
||||
|
||||
.email-compose-btn:disabled {
|
||||
background: #f1f5f9;
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.email-compose-btn i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@ -1,833 +0,0 @@
|
||||
<template>
|
||||
<n-space align="center">
|
||||
<!-- Previous / Next -->
|
||||
<n-button
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="goToPrev"
|
||||
:disabled="loading || fetching || !hasPrev"
|
||||
:title="t('Previous')"
|
||||
class="header-action-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:chevron-left" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="goToNext"
|
||||
:disabled="loading || fetching || !hasNext"
|
||||
:title="t('Next')"
|
||||
class="header-action-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:chevron-right" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<!-- Refresh -->
|
||||
<n-button
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="$emit('refresh')"
|
||||
:disabled="loading || isNew"
|
||||
:title="t('Refresh')"
|
||||
class="header-action-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:refresh" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<!-- ===== Email-specific actions ===== -->
|
||||
|
||||
<!-- Reference doc link -->
|
||||
<n-button
|
||||
v-if="record?.reference_pagetype && record?.reference_name"
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="goToReference"
|
||||
:title="record.reference_name"
|
||||
class="header-action-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:file-text" /></n-icon>
|
||||
</template>
|
||||
{{ record.reference_name }}
|
||||
</n-button>
|
||||
|
||||
<!-- Delete -->
|
||||
<n-button
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="$emit('delete')"
|
||||
:disabled="loading || isNew"
|
||||
:title="t('Delete')"
|
||||
class="header-action-btn delete-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:trash" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<!-- Like -->
|
||||
<n-button
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="handleToggleLike"
|
||||
:disabled="loading || isNew"
|
||||
:loading="likeLoading"
|
||||
:title="isLiked ? t('Unlike') : t('Like')"
|
||||
class="header-action-btn like-action-btn"
|
||||
:class="{ 'is-liked': isLiked }"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon :icon="isLiked ? 'tabler:heart-filled' : 'tabler:heart'" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<!-- Back -->
|
||||
<n-button type="default" size="medium" @click="$emit('go-back')" :disabled="loading">
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:arrow-left" /></n-icon>
|
||||
</template>
|
||||
{{ t('Back') }}
|
||||
</n-button>
|
||||
|
||||
<!-- Reply -->
|
||||
<n-button
|
||||
v-if="canReply"
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="reply"
|
||||
:disabled="loading"
|
||||
class="reply-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:arrow-back-up" /></n-icon>
|
||||
</template>
|
||||
{{ t('Reply') }}
|
||||
</n-button>
|
||||
|
||||
<!-- Forward -->
|
||||
<n-button
|
||||
v-if="canReply"
|
||||
type="default"
|
||||
size="medium"
|
||||
@click="forward"
|
||||
:disabled="loading"
|
||||
class="header-action-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:arrow-forward-up" /></n-icon>
|
||||
</template>
|
||||
{{ t('Forward') }}
|
||||
</n-button>
|
||||
|
||||
<!-- More menu -->
|
||||
<n-dropdown
|
||||
:options="moreOptions"
|
||||
trigger="click"
|
||||
:render-icon="renderMenuIcon"
|
||||
@select="handleMoreSelect"
|
||||
>
|
||||
<n-button type="default" size="medium" :title="t('More')" class="header-action-btn">
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:dots" /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
|
||||
<!-- Email Compose Drawer -->
|
||||
<EmailComposeModal ref="composeRef" @sent="onEmailSent" />
|
||||
|
||||
<!-- Relink Dialog -->
|
||||
<n-modal v-model:show="showRelinkDialog" preset="dialog" :title="t('Relink Communication')" style="width: 480px;" :auto-focus="false">
|
||||
<n-form ref="relinkFormRef" :model="relinkForm" :rules="relinkRules" label-placement="top">
|
||||
<n-form-item :label="t('Reference Pagetype')" path="reference_pagetype" label-placement="top">
|
||||
<LinkField
|
||||
v-model="relinkForm.reference_pagetype"
|
||||
:options="'PageType'"
|
||||
:enable-create="false"
|
||||
:get-query="() => ({ filters: { istable: 0 } })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('Reference Name')" path="reference_name" label-placement="top">
|
||||
<LinkField
|
||||
v-model="relinkForm.reference_name"
|
||||
:options="relinkForm.reference_pagetype || ''"
|
||||
:enable-create="false"
|
||||
:disabled="!relinkForm.reference_pagetype"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button @click="showRelinkDialog = false">{{ t('Cancel') }}</n-button>
|
||||
<n-button type="primary" :loading="relinking" @click="confirmRelink">
|
||||
{{ t('Relink') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- Move Dialog -->
|
||||
<n-modal v-model:show="showMoveDialog" preset="dialog" :title="t('Move Email')" style="width: 400px;">
|
||||
<n-form-item :label="t('Email Account')" path="email_account">
|
||||
<n-select
|
||||
v-model:value="moveTargetAccount"
|
||||
:options="emailAccountOptions"
|
||||
filterable
|
||||
placeholder=""
|
||||
:loading="loadingAccounts"
|
||||
/>
|
||||
</n-form-item>
|
||||
<template #action>
|
||||
<n-button @click="showMoveDialog = false">{{ t('Cancel') }}</n-button>
|
||||
<n-button type="primary" :loading="moving" @click="confirmMove">
|
||||
{{ t('Move') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, h } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
NButton, NSpace, NIcon, NDropdown, NModal, NForm, NFormItem, NSelect,
|
||||
useMessage, type DropdownOption, type FormRules, type FormInst,
|
||||
} from 'naive-ui'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { t } from '@/shared/i18n'
|
||||
import { usePageTypeSlug, pageTypeToSlug } from '@/shared/utils/slug'
|
||||
import { getRecords, renameRecord, duplicateRecord, api } from '@/shared/api/common'
|
||||
import { toggleLike as toggleLikeApi } from '@/shared/api/timeline'
|
||||
import { useAuthStore } from '@/shared/stores/auth'
|
||||
import EmailComposeModal from '@/shared/components/EmailComposeModal.vue'
|
||||
import LinkField from '@/shared/components/LinkField.vue'
|
||||
|
||||
interface Props {
|
||||
entity: string
|
||||
id: string
|
||||
record?: any
|
||||
canEdit: boolean
|
||||
loading: boolean
|
||||
isSubmittable?: boolean
|
||||
pageStatus?: number
|
||||
sidebarPosition?: 'left' | 'right'
|
||||
pageMeta?: any
|
||||
_inPanel?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-sidebar-position'): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'go-back'): void
|
||||
(e: 'save'): void
|
||||
(e: 'submit'): void
|
||||
(e: 'cancel-doc'): void
|
||||
(e: 'rename', newName: string): void
|
||||
(e: 'duplicate', newName: string): void
|
||||
(e: 'prev', name: string): void
|
||||
(e: 'next', name: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sidebarPosition: 'left',
|
||||
record: () => ({}),
|
||||
isSubmittable: false,
|
||||
pageStatus: -1,
|
||||
pageMeta: () => ({}),
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ===== Email Compose =====
|
||||
const composeRef = ref<InstanceType<typeof EmailComposeModal> | null>(null)
|
||||
|
||||
// ===== Email context flags =====
|
||||
const isEmail = computed(() =>
|
||||
props.record?.communication_type === 'Communication' &&
|
||||
props.record?.communication_medium === 'Email'
|
||||
)
|
||||
const isReceived = computed(() => props.record?.sent_or_received === 'Received')
|
||||
const canReply = computed(() => isEmail.value && isReceived.value && !props.isNew)
|
||||
const isOpen = computed(() => props.record?.status === 'Open')
|
||||
const isLinked = computed(() => props.record?.status === 'Linked')
|
||||
const isSpam = computed(() => props.record?.email_status === 'Spam')
|
||||
const isTrash = computed(() => props.record?.email_status === 'Trash')
|
||||
const isSeen = computed(() => props.record?.seen)
|
||||
|
||||
const sender = computed(() => props.record?.sender || '')
|
||||
const senderFullName = computed(() => props.record?.sender_full_name || '')
|
||||
const subject = computed(() => props.record?.subject || '')
|
||||
const cc = computed(() => props.record?.cc || '')
|
||||
const recipients = computed(() => props.record?.recipients || '')
|
||||
const emailAccount = computed(() => props.record?.email_account || '')
|
||||
const originalComment = computed(() => props.record?.content || '')
|
||||
const attachments = computed(() => props.record?.attachments || [])
|
||||
|
||||
// ===== Like =====
|
||||
const likeLoading = ref(false)
|
||||
const currentUser = computed(() => authStore.user?.username || authStore.user?.name || '')
|
||||
const likedBy = computed<string[]>(() => {
|
||||
const raw = props.record?._liked_by
|
||||
if (!raw) return []
|
||||
if (typeof raw === 'string') { try { return JSON.parse(raw) } catch { return [] } }
|
||||
return Array.isArray(raw) ? raw : []
|
||||
})
|
||||
const isLiked = computed(() => likedBy.value.includes(currentUser.value))
|
||||
|
||||
async function handleToggleLike() {
|
||||
if (props.isNew || likeLoading.value) return
|
||||
likeLoading.value = true
|
||||
try {
|
||||
await toggleLikeApi(props.entity, props.id, isLiked.value ? 'No' : 'Yes')
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to toggle like'))
|
||||
} finally {
|
||||
likeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Navigation =====
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { pagetypeSlug } = usePageTypeSlug(route)
|
||||
const isNew = computed(() => props.id === 'new' || props.id.startsWith('new-'))
|
||||
|
||||
const listStateKey = computed(() => `list-state:${props.entity}`)
|
||||
const rowNames = ref<string[]>([])
|
||||
const fetching = ref(false)
|
||||
const loadedStart = ref(0)
|
||||
const hasMoreNext = ref(false)
|
||||
const hasMorePrev = ref(false)
|
||||
const pageSize = parseInt(localStorage.getItem('itemsPerPage') || '10')
|
||||
|
||||
const currentIndex = computed(() => {
|
||||
if (!props.id || rowNames.value.length === 0) return -1
|
||||
return rowNames.value.indexOf(props.id)
|
||||
})
|
||||
const hasPrev = computed(() => {
|
||||
if (isNew.value) return false
|
||||
return currentIndex.value > 0 || hasMorePrev.value
|
||||
})
|
||||
const hasNext = computed(() => {
|
||||
if (isNew.value || currentIndex.value < 0) return false
|
||||
return currentIndex.value < rowNames.value.length - 1 || hasMoreNext.value
|
||||
})
|
||||
|
||||
function readListState(): any | null {
|
||||
const saved = sessionStorage.getItem(listStateKey.value)
|
||||
if (!saved) return null
|
||||
try { return JSON.parse(saved) } catch { return null }
|
||||
}
|
||||
|
||||
function loadRowNames() {
|
||||
const state = readListState()
|
||||
if (!state?.rowNames?.length) return
|
||||
rowNames.value = state.rowNames
|
||||
loadedStart.value = ((state.page || 1) - 1) * pageSize
|
||||
hasMorePrev.value = loadedStart.value > 0
|
||||
hasMoreNext.value = state.rowNames.length >= pageSize
|
||||
}
|
||||
|
||||
function navigateTo(name: string) {
|
||||
router.push({
|
||||
name: 'PageTypeDetail',
|
||||
params: { entity: pagetypeSlug.value, id: name },
|
||||
state: { _listPage: history.state?._listPage || 1 }
|
||||
})
|
||||
}
|
||||
|
||||
function buildApiFilters(filters: Record<string, any>): any[] {
|
||||
if (!filters) return []
|
||||
return Object.entries(filters)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0))
|
||||
.map(([k, v]) => typeof v === 'string' ? [k, 'like', `%${v}%`] : Array.isArray(v) ? [k, 'in', v] : [k, '=', v])
|
||||
}
|
||||
|
||||
async function fetchPage(direction: 'next' | 'prev'): Promise<string[]> {
|
||||
const state = readListState()
|
||||
if (!state || state.searchQuery) return []
|
||||
|
||||
const orderBy = 'communication_date desc'
|
||||
const filters = buildApiFilters(state.filters)
|
||||
const ps = pageSize
|
||||
|
||||
let limitStart: number, limitLength: number
|
||||
if (direction === 'next') {
|
||||
limitStart = loadedStart.value + rowNames.value.length
|
||||
limitLength = ps
|
||||
} else {
|
||||
limitLength = Math.min(ps, loadedStart.value)
|
||||
limitStart = loadedStart.value - limitLength
|
||||
if (limitLength <= 0) return []
|
||||
}
|
||||
|
||||
const res = await getRecords(props.entity, filters, ['name'], orderBy, limitStart, limitLength)
|
||||
if (!res.success || !res.data?.length) {
|
||||
if (direction === 'next') hasMoreNext.value = false
|
||||
else hasMorePrev.value = false
|
||||
return []
|
||||
}
|
||||
|
||||
const newNames = res.data.map((r: any) => r.name)
|
||||
if (direction === 'next') {
|
||||
rowNames.value = [...rowNames.value, ...newNames]
|
||||
hasMoreNext.value = newNames.length >= ps
|
||||
} else {
|
||||
rowNames.value = [...newNames, ...rowNames.value]
|
||||
loadedStart.value -= newNames.length
|
||||
hasMorePrev.value = loadedStart.value > 0
|
||||
}
|
||||
|
||||
try {
|
||||
state.rowNames = rowNames.value
|
||||
sessionStorage.setItem(listStateKey.value, JSON.stringify(state))
|
||||
} catch {}
|
||||
|
||||
return newNames
|
||||
}
|
||||
|
||||
async function goToPrev() {
|
||||
if (fetching.value) return
|
||||
if (currentIndex.value > 0) {
|
||||
const targetName = rowNames.value[currentIndex.value - 1]
|
||||
emit('prev', targetName)
|
||||
if (!props._inPanel) navigateTo(targetName)
|
||||
} else if (hasMorePrev.value) {
|
||||
fetching.value = true
|
||||
try {
|
||||
const newNames = await fetchPage('prev')
|
||||
if (newNames.length) {
|
||||
const idx = rowNames.value.indexOf(props.id)
|
||||
if (idx > 0) {
|
||||
const target = rowNames.value[idx - 1]
|
||||
emit('prev', target)
|
||||
if (!props._inPanel) navigateTo(target)
|
||||
}
|
||||
}
|
||||
} finally { fetching.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
async function goToNext() {
|
||||
if (fetching.value) return
|
||||
if (currentIndex.value >= 0 && currentIndex.value < rowNames.value.length - 1) {
|
||||
const targetName = rowNames.value[currentIndex.value + 1]
|
||||
emit('next', targetName)
|
||||
if (!props._inPanel) navigateTo(targetName)
|
||||
} else if (hasMoreNext.value) {
|
||||
fetching.value = true
|
||||
try {
|
||||
const newNames = await fetchPage('next')
|
||||
if (newNames.length) {
|
||||
const idx = currentIndex.value + 1
|
||||
if (idx < rowNames.value.length) {
|
||||
const target = rowNames.value[idx]
|
||||
emit('next', target)
|
||||
if (!props._inPanel) navigateTo(target)
|
||||
}
|
||||
}
|
||||
} finally { fetching.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Reference link =====
|
||||
function goToReference() {
|
||||
if (!props.record?.reference_pagetype || !props.record?.reference_name) return
|
||||
router.push({
|
||||
name: 'PageTypeDetail',
|
||||
params: {
|
||||
entity: props.record.reference_pagetype.replace(/ /g, '-').toLowerCase(),
|
||||
id: props.record.reference_name,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Email actions =====
|
||||
function getMailArgs() {
|
||||
return {
|
||||
pg: props.record,
|
||||
lastEmail: props.record,
|
||||
sender: getSenderEmailId(),
|
||||
attachments: attachments.value,
|
||||
referencePagetype: props.record?.reference_pagetype,
|
||||
referenceName: props.record?.reference_name,
|
||||
doc: props.record,
|
||||
}
|
||||
}
|
||||
|
||||
function getSenderEmailId(): string {
|
||||
const bootAccounts = (window as any).jingrow?.boot?.email_accounts || []
|
||||
for (const account of bootAccounts) {
|
||||
if (account.email_account === emailAccount.value) {
|
||||
return account.email_id || ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function reply() {
|
||||
composeRef.value?.open({
|
||||
...getMailArgs(),
|
||||
isReply: true,
|
||||
replyAll: false,
|
||||
defaultSubject: subject.value.startsWith('Re:') ? subject.value : `Re: ${subject.value}`,
|
||||
defaultRecipients: sender.value,
|
||||
defaultContent: originalComment.value,
|
||||
})
|
||||
}
|
||||
|
||||
function replyAll() {
|
||||
const selfEmail = getSenderEmailId()
|
||||
const allRecipients = splitEmailList(recipients.value)
|
||||
const allCc = splitEmailList(cc.value)
|
||||
const otherRecipients = [...allRecipients, ...allCc].filter(e => e !== selfEmail)
|
||||
composeRef.value?.open({
|
||||
...getMailArgs(),
|
||||
isReply: true,
|
||||
replyAll: true,
|
||||
defaultSubject: subject.value.startsWith('Re:') ? subject.value : `Re: ${subject.value}`,
|
||||
defaultRecipients: sender.value,
|
||||
defaultCc: otherRecipients.join(', '),
|
||||
defaultContent: originalComment.value,
|
||||
})
|
||||
}
|
||||
|
||||
function forward() {
|
||||
composeRef.value?.open({
|
||||
...getMailArgs(),
|
||||
isForward: true,
|
||||
defaultSubject: subject.value.startsWith('Fw:') ? subject.value : `Fw: ${subject.value}`,
|
||||
defaultRecipients: '',
|
||||
defaultContent: originalComment.value,
|
||||
})
|
||||
}
|
||||
|
||||
function splitEmailList(str: string): string[] {
|
||||
if (!str) return []
|
||||
return str.split(/[;,]/).map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function onEmailSent() {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// ===== Mark as Read/Unread =====
|
||||
async function markAsReadUnread() {
|
||||
const action = isSeen.value ? 'Unread' : 'Read'
|
||||
try {
|
||||
await api.call('jingrow.email.inbox.create_email_flag_queue', {
|
||||
names: JSON.stringify([props.id]),
|
||||
action,
|
||||
flag: '\\SEEN',
|
||||
})
|
||||
message.success(t(action === 'Read' ? 'Marked as Read' : 'Marked as Unread'))
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to update'))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Mark as Closed/Open =====
|
||||
async function markAsClosedOpen() {
|
||||
const status = isOpen.value ? 'Closed' : 'Open'
|
||||
try {
|
||||
await api.call('jingrow.email.inbox.mark_as_closed_open', {
|
||||
communication: props.id,
|
||||
status,
|
||||
})
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to update'))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Mark as Spam =====
|
||||
async function markAsSpam() {
|
||||
try {
|
||||
await api.call('jingrow.email.inbox.mark_as_spam', {
|
||||
communication: props.id,
|
||||
sender: sender.value,
|
||||
})
|
||||
message.success(t('Email has been marked as spam'))
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to mark as spam'))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Move to Trash =====
|
||||
async function moveToTrash() {
|
||||
try {
|
||||
await api.call('jingrow.email.inbox.mark_as_trash', {
|
||||
communication: props.id,
|
||||
})
|
||||
message.success(t('Email has been moved to trash'))
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to move to trash'))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Add to Contact =====
|
||||
function addToContact() {
|
||||
const fullname = senderFullName.value || ''
|
||||
const names = fullname.split(' ')
|
||||
const firstName = names[0] || ''
|
||||
const lastName = names.length >= 2 ? names[names.length - 1] : ''
|
||||
const email = sender.value.replace(/<.*>/, '').trim() || sender.value
|
||||
const phone = props.record?.phone_no || ''
|
||||
|
||||
// Store default values in sessionStorage (same mechanism as Duplicate)
|
||||
const storageKey = 'duplicate:Contact'
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify({
|
||||
email_id: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
mobile_no: phone,
|
||||
}))
|
||||
} catch {}
|
||||
|
||||
// Navigate to new Contact page using Vue Router
|
||||
const slug = pageTypeToSlug('Contact')
|
||||
const randomStr = Math.random().toString(36).substring(2, 12)
|
||||
const tempName = `new-${slug}-${randomStr}`
|
||||
router.push({
|
||||
name: 'PageTypeDetail',
|
||||
params: { entity: slug, id: tempName }
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Relink =====
|
||||
const showRelinkDialog = ref(false)
|
||||
const relinkFormRef = ref<FormInst | null>(null)
|
||||
const relinking = ref(false)
|
||||
const relinkForm = ref({ reference_pagetype: '', reference_name: '' as string | null })
|
||||
|
||||
const relinkRules: FormRules = {
|
||||
reference_pagetype: [{ required: true, message: t('Required'), trigger: 'change' }],
|
||||
}
|
||||
|
||||
// Clear reference_name when pagetype changes
|
||||
watch(() => relinkForm.value.reference_pagetype, () => {
|
||||
relinkForm.value.reference_name = ''
|
||||
})
|
||||
|
||||
async function confirmRelink() {
|
||||
try {
|
||||
await relinkFormRef.value?.validate()
|
||||
} catch { return }
|
||||
|
||||
relinking.value = true
|
||||
try {
|
||||
await api.call('jingrow.email.relink', {
|
||||
name: props.id,
|
||||
reference_pagetype: relinkForm.value.reference_pagetype,
|
||||
reference_name: relinkForm.value.reference_name,
|
||||
})
|
||||
message.success(t('Communication relinked'))
|
||||
showRelinkDialog.value = false
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to relink'))
|
||||
} finally {
|
||||
relinking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Move =====
|
||||
const showMoveDialog = ref(false)
|
||||
const moveTargetAccount = ref<string | null>(null)
|
||||
const emailAccountOptions = ref<{ label: string; value: string }[]>([])
|
||||
const loadingAccounts = ref(false)
|
||||
const moving = ref(false)
|
||||
|
||||
async function openMoveDialog() {
|
||||
showMoveDialog.value = true
|
||||
moveTargetAccount.value = null
|
||||
if (emailAccountOptions.value.length === 0) {
|
||||
try {
|
||||
loadingAccounts.value = true
|
||||
const res = await api.call('jingrow.email.inbox.get_email_accounts')
|
||||
const data = (res?.email_accounts ?? []) as any[]
|
||||
emailAccountOptions.value = data
|
||||
.filter((d: any) => {
|
||||
const ea = d.email_account ?? ''
|
||||
return ea && ea !== emailAccount.value && ea !== 'All Accounts'
|
||||
})
|
||||
.map((d: any) => ({
|
||||
label: d.email_id,
|
||||
value: d.email_account,
|
||||
}))
|
||||
} catch { /* ignore */ } finally {
|
||||
loadingAccounts.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMove() {
|
||||
if (!moveTargetAccount.value) {
|
||||
message.warning(t('Select an email account'))
|
||||
return
|
||||
}
|
||||
moving.value = true
|
||||
try {
|
||||
await api.call('jingrow.email.inbox.move_email', {
|
||||
communication: props.id,
|
||||
email_account: moveTargetAccount.value,
|
||||
})
|
||||
message.success(t('Email moved'))
|
||||
showMoveDialog.value = false
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to move'))
|
||||
} finally {
|
||||
moving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Dropdown helpers =====
|
||||
function makeOption(key: string, label: string, icon: string | undefined, condition = true): DropdownOption | null {
|
||||
if (!condition || !icon) return null
|
||||
return { label, key, icon }
|
||||
}
|
||||
|
||||
function renderMenuIcon(option: DropdownOption) {
|
||||
const iconName = option.icon as string
|
||||
if (!iconName) return null
|
||||
return h(NIcon, null, { default: () => h(Icon, { icon: iconName }) })
|
||||
}
|
||||
|
||||
const moreOptions = computed<DropdownOption[]>(() => {
|
||||
const opts: DropdownOption[] = []
|
||||
|
||||
// Email action items (from former Actions dropdown)
|
||||
if (canReply.value) {
|
||||
const replyAll = makeOption('reply_all', t('Reply All'), 'tabler:arrow-back-up-double')
|
||||
if (replyAll) opts.push(replyAll)
|
||||
|
||||
const readLabel = isSeen.value ? t('Mark as Unread') : t('Mark as Read')
|
||||
const readIcon = isSeen.value ? 'tabler:mail-opened' : 'tabler:mail-check'
|
||||
const toggleRead = makeOption('toggle_read', readLabel, readIcon)
|
||||
if (toggleRead) opts.push(toggleRead)
|
||||
|
||||
const move = makeOption('move', t('Move'), 'tabler:arrows-transfer-down')
|
||||
if (move) opts.push(move)
|
||||
|
||||
if (!isSpam.value) {
|
||||
const spam = makeOption('spam', t('Mark as Spam'), 'tabler:alert-triangle')
|
||||
if (spam) opts.push(spam)
|
||||
}
|
||||
|
||||
if (!isTrash.value) {
|
||||
const trash = makeOption('trash', t('Move To Trash'), 'tabler:trash')
|
||||
if (trash) opts.push(trash)
|
||||
}
|
||||
|
||||
// Close / Reopen
|
||||
if (!isNew.value && !isLinked.value) {
|
||||
const closeLabel = isOpen.value ? t('Close') : t('Reopen')
|
||||
const closeIcon = isOpen.value ? 'tabler:x' : 'tabler:rotate-clockwise'
|
||||
const closeOpt = makeOption('toggle_close', closeLabel, closeIcon)
|
||||
if (closeOpt) opts.push(closeOpt)
|
||||
}
|
||||
|
||||
opts.push({ type: 'divider', key: 'd-email-actions' })
|
||||
}
|
||||
|
||||
// General more items
|
||||
if (!isNew.value && props.canEdit && !props.pageMeta?.allow_copy) {
|
||||
const dup = makeOption('duplicate', t('Duplicate'), 'tabler:copy')
|
||||
if (dup) opts.push(dup)
|
||||
}
|
||||
|
||||
const canRename = !isNew.value && props.canEdit && !!props.pageMeta?.allow_rename
|
||||
if (canRename) {
|
||||
const rename = makeOption('rename', t('Rename'), 'tabler:edit')
|
||||
if (rename) opts.push(rename)
|
||||
}
|
||||
|
||||
if (!isNew.value) {
|
||||
const newEmail = makeOption('new_email', t('New Email'), 'tabler:mail')
|
||||
if (newEmail) opts.push(newEmail)
|
||||
}
|
||||
|
||||
// Add to Contact
|
||||
if (canReply.value) {
|
||||
const contact = makeOption('contact', t('Contact'), 'tabler:user-plus')
|
||||
if (contact) opts.push(contact)
|
||||
}
|
||||
|
||||
// Relink
|
||||
if (!isNew.value) {
|
||||
const relink = makeOption('relink', t('Relink'), 'tabler:link')
|
||||
if (relink) opts.push(relink)
|
||||
}
|
||||
|
||||
return opts
|
||||
})
|
||||
|
||||
function handleMoreSelect(key: string) {
|
||||
switch (key) {
|
||||
case 'reply_all': replyAll(); break
|
||||
case 'toggle_read': markAsReadUnread(); break
|
||||
case 'move': openMoveDialog(); break
|
||||
case 'spam': markAsSpam(); break
|
||||
case 'trash': moveToTrash(); break
|
||||
case 'toggle_close': markAsClosedOpen(); break
|
||||
case 'contact': addToContact(); break
|
||||
case 'relink': showRelinkDialog.value = true; break
|
||||
case 'rename': emit('rename', '')
|
||||
break
|
||||
case 'duplicate': emit('duplicate', '')
|
||||
break
|
||||
case 'new_email':
|
||||
composeRef.value?.open({
|
||||
referencePagetype: props.entity,
|
||||
referenceName: props.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRowNames()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-action-btn.delete-btn:hover:not(:disabled) {
|
||||
background: #ef4444 !important;
|
||||
border-color: #ef4444 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.header-action-btn.like-action-btn.is-liked {
|
||||
background: #fef2f2 !important;
|
||||
}
|
||||
.header-action-btn.like-action-btn.is-liked :deep(.n-icon) {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Reply button - same style as Forward */
|
||||
|
||||
|
||||
/* Relink dialog - allow LinkField dropdown to overflow */
|
||||
:deep(.n-dialog__content) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user