<script lang="ts" setup>
|
import type { Component } from 'vue';
|
|
import type { DmsFullTextSearchItem } from '#/api/x/dms/search';
|
|
import { computed, ref } from 'vue';
|
import { useRouter } from 'vue-router';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { formatToDateTime } from '@jnpf/utils';
|
|
import {
|
EyeOutlined,
|
FilePdfOutlined,
|
FileTextOutlined,
|
FileWordOutlined,
|
FolderOutlined,
|
LeftOutlined,
|
RightOutlined,
|
SearchOutlined,
|
} from '@ant-design/icons-vue';
|
import { Empty } from 'ant-design-vue';
|
|
import { searchDmsFiles } from '#/api/x/dms/search';
|
|
import { dmsPreviewErrorMessage, openDmsFilePreview } from '../shared/dmsFilePreview';
|
import { onlineUtils } from "#/utils/jnpf";
|
|
defineOptions({ name: 'DmsFullTextSearch' });
|
|
const PAGE_SIZE = 20;
|
const { createMessage } = useMessage();
|
const router = useRouter();
|
const keyword = ref('');
|
const submittedKeyword = ref('');
|
const results = ref<DmsFullTextSearchItem[]>([]);
|
const nextCursor = ref<string>();
|
const cursorStack = ref<(string | undefined)[]>([undefined]);
|
const pageNumber = ref(1);
|
const loading = ref(false);
|
const searched = ref(false);
|
const failed = ref(false);
|
|
const canPrevious = computed(() => pageNumber.value > 1 && !loading.value);
|
const canNext = computed(() => !!nextCursor.value && !loading.value);
|
|
async function loadPage(cursor?: string) {
|
loading.value = true;
|
try {
|
const response = await searchDmsFiles({ cursor, keyword: submittedKeyword.value, pageSize: PAGE_SIZE });
|
results.value = response.data.list || [];
|
nextCursor.value = response.data.hasMore ? response.data.nextCursor : undefined;
|
searched.value = true;
|
failed.value = false;
|
return true;
|
} catch {
|
failed.value = true;
|
createMessage.error('全文检索失败');
|
return false;
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function search() {
|
const value = keyword.value.trim();
|
if (!value) {
|
reset();
|
return;
|
}
|
submittedKeyword.value = value;
|
cursorStack.value = [undefined];
|
pageNumber.value = 1;
|
await loadPage();
|
}
|
|
async function nextPage() {
|
const cursor = nextCursor.value;
|
if (!cursor || loading.value) return;
|
if (await loadPage(cursor)) {
|
cursorStack.value.push(cursor);
|
pageNumber.value += 1;
|
}
|
}
|
|
async function previousPage() {
|
if (!canPrevious.value) return;
|
const cursor = cursorStack.value[pageNumber.value - 2];
|
if (await loadPage(cursor)) {
|
cursorStack.value.pop();
|
pageNumber.value -= 1;
|
}
|
}
|
|
function reset() {
|
submittedKeyword.value = '';
|
results.value = [];
|
nextCursor.value = undefined;
|
cursorStack.value = [undefined];
|
pageNumber.value = 1;
|
searched.value = false;
|
failed.value = false;
|
}
|
|
function handleKeywordChange() {
|
if (!keyword.value.trim()) reset();
|
}
|
|
async function preview(item: DmsFullTextSearchItem) {
|
try {
|
onlineUtils.showLoading('文件预览加载中');
|
await openDmsFilePreview({
|
fileName: item.fileName || item.title,
|
mimeType: item.mimeType,
|
resourceId: item.id,
|
resourceType: 'FILE_VERSION',
|
watermarkBusinessTypes: ['quanwenJiansuo'],
|
});
|
} catch (error) {
|
createMessage.error(dmsPreviewErrorMessage(error));
|
} finally {
|
onlineUtils.hideLoading();
|
}
|
}
|
|
async function openFolderLocation(item: DmsFullTextSearchItem) {
|
if (!item.folderId || !item.folderPath) return;
|
const target = router.getRoutes().find((route) => route.meta.title === '现行版文件中心');
|
if (!target) {
|
createMessage.error('未找到现行版文件中心菜单');
|
return;
|
}
|
const menuQuery = target.meta.query && typeof target.meta.query === 'object' ? target.meta.query : {};
|
await router.push({
|
path: target.path,
|
query: {
|
...menuQuery,
|
dmsFolderId: item.folderId,
|
dmsFolderPath: item.folderPath,
|
pageKey: target.path,
|
},
|
});
|
}
|
|
function fileIcon(item: DmsFullTextSearchItem): Component {
|
const extension = (item.fileName.split('.').pop() || '').toLowerCase();
|
if (extension === 'pdf') return FilePdfOutlined;
|
if (extension === 'doc' || extension === 'docx') return FileWordOutlined;
|
return FileTextOutlined;
|
}
|
|
function fileTone(item: DmsFullTextSearchItem) {
|
const extension = (item.fileName.split('.').pop() || '').toLowerCase();
|
if (extension === 'pdf') return 'pdf';
|
if (extension === 'doc' || extension === 'docx') return 'word';
|
return 'text';
|
}
|
|
function displayDate(value?: string) {
|
return value ? formatToDateTime(value, 'YYYY-MM-DD') : '—';
|
}
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper full-text-search">
|
<div class="jnpf-content-wrapper-center search-page-center" :class="{ 'is-initial': !searched && !failed }">
|
<header class="jnpf-content-wrapper-search-box search-header">
|
<h1>全文检索</h1>
|
<div class="search-command">
|
<a-input
|
v-model:value="keyword"
|
allow-clear
|
:maxlength="200"
|
placeholder="请输入搜索关键词"
|
size="large"
|
@change="handleKeywordChange"
|
@press-enter="search">
|
<template #prefix><SearchOutlined /></template>
|
</a-input>
|
<a-button class="search-button" type="primary" size="large" :loading="loading" @click="search">
|
<template #icon><SearchOutlined /></template>
|
搜索
|
</a-button>
|
</div>
|
</header>
|
|
<div v-if="searched || failed" class="jnpf-content-wrapper-content search-results">
|
<div v-if="searched" class="search-summary">
|
<div v-if="!failed" class="result-status">
|
<span>“{{ submittedKeyword }}”</span>
|
<span>第 {{ pageNumber }} 页,当前 {{ results.length }} 条</span>
|
</div>
|
<div v-if="pageNumber > 1 || nextCursor" class="search-pagination">
|
<a-tooltip title="上一页">
|
<a-button class="pagination-button" aria-label="上一页" :disabled="!canPrevious" @click="previousPage"><LeftOutlined /></a-button>
|
</a-tooltip>
|
<span class="pagination-label">第 {{ pageNumber }} 页</span>
|
<a-tooltip title="下一页">
|
<a-button class="pagination-button" aria-label="下一页" :disabled="!canNext" @click="nextPage"><RightOutlined /></a-button>
|
</a-tooltip>
|
</div>
|
</div>
|
|
<main class="search-body" v-loading="loading">
|
<div v-if="results.length" class="result-list">
|
<article v-for="item in results" :key="item.id" class="result-row" @dblclick="preview(item)">
|
<div class="result-icon" :class="fileTone(item)">
|
<component :is="fileIcon(item)" />
|
</div>
|
|
<div class="result-content">
|
<button class="result-title" type="button" v-html="item.highlightedTitle || item.title" @click="preview(item)"></button>
|
|
<div class="result-meta">
|
<button
|
v-if="item.folderId && item.folderPath"
|
class="result-path result-path-link"
|
type="button"
|
:title="`打开目录:${item.folderPath}`"
|
@click.stop="openFolderLocation(item)"
|
@dblclick.stop>
|
<FolderOutlined />
|
<span>{{ item.folderPath }}</span>
|
</button>
|
<span v-else-if="item.folderPath" class="result-path" :title="item.folderPath">
|
<FolderOutlined />
|
<span>{{ item.folderPath }}</span>
|
</span>
|
<span>文件编号:{{ item.documentNo || '—' }}</span>
|
<span>版本:{{ item.versionLabel || '—' }}</span>
|
<span>日期:{{ displayDate(item.createdAt) }}</span>
|
</div>
|
|
<p v-if="item.highlightedSnippet" class="result-snippet" v-html="item.highlightedSnippet"></p>
|
<p v-else class="result-file-name">{{ item.fileName }}</p>
|
</div>
|
|
<a-button class="preview-button" type="link" @click="preview(item)">
|
<template #icon><EyeOutlined /></template>
|
预览
|
</a-button>
|
</article>
|
</div>
|
|
<Empty v-else-if="!loading" class="result-empty" :description="failed ? '全文检索暂时不可用' : '没有匹配的文件'" />
|
</main>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style lang="scss" scoped>
|
.full-text-search {
|
min-height: 0;
|
color: var(--text-color);
|
}
|
|
.search-page-center {
|
min-width: 0;
|
|
> .search-header {
|
padding: 22px 28px 18px;
|
border-bottom: 1px solid var(--border-color);
|
}
|
|
&.is-initial {
|
justify-content: center;
|
background: var(--component-background);
|
|
> .search-header {
|
align-self: center;
|
width: min(862px, calc(100% - 32px));
|
padding: 0;
|
margin-bottom: 8vh;
|
overflow: visible;
|
background: transparent;
|
border-bottom: 0;
|
|
h1 {
|
margin-bottom: 20px;
|
font-size: 24px;
|
text-align: center;
|
}
|
}
|
}
|
}
|
|
.search-header {
|
h1 {
|
margin: 0 0 14px;
|
font-size: 18px;
|
font-weight: 600;
|
line-height: 1.4;
|
}
|
}
|
|
.search-command {
|
display: grid;
|
grid-template-columns: minmax(240px, 760px) 92px;
|
gap: 10px;
|
align-items: center;
|
max-width: 862px;
|
}
|
|
.search-button {
|
width: 92px;
|
}
|
|
.search-results {
|
display: flex;
|
flex-direction: column;
|
min-width: 0;
|
min-height: 0;
|
background: var(--component-background);
|
}
|
|
.search-body {
|
flex: 1;
|
min-height: 0;
|
padding: 0 28px;
|
overflow: auto;
|
}
|
|
.search-summary {
|
display: flex;
|
flex-shrink: 0;
|
flex-wrap: wrap;
|
gap: 8px 24px;
|
align-items: center;
|
justify-content: space-between;
|
padding: 0 28px;
|
border-bottom: 1px solid var(--border-color);
|
}
|
|
.result-status {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 16px;
|
justify-content: space-between;
|
min-height: 46px;
|
padding: 13px 0 10px;
|
font-size: 13px;
|
color: var(--text-color-secondary);
|
overflow-wrap: anywhere;
|
}
|
|
.result-list {
|
width: 100%;
|
}
|
|
.result-row {
|
display: grid;
|
grid-template-columns: 38px minmax(0, 1fr) 74px;
|
gap: 12px;
|
align-items: start;
|
min-height: 112px;
|
padding: 18px 4px;
|
border-bottom: 1px solid var(--border-color);
|
|
&:hover {
|
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
|
}
|
}
|
|
.result-icon {
|
display: grid;
|
place-items: center;
|
width: 34px;
|
height: 38px;
|
font-size: 25px;
|
|
&.pdf {
|
color: #cf3f3f;
|
}
|
|
&.word {
|
color: #2f6eb5;
|
}
|
|
&.text {
|
color: #61707f;
|
}
|
}
|
|
.result-content {
|
min-width: 0;
|
}
|
|
.result-title {
|
max-width: 100%;
|
padding: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
font-size: 15px;
|
font-weight: 600;
|
line-height: 22px;
|
color: var(--primary-color);
|
text-align: left;
|
white-space: nowrap;
|
cursor: pointer;
|
background: transparent;
|
border: 0;
|
}
|
|
.result-meta {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 5px 18px;
|
margin-top: 7px;
|
font-size: 12px;
|
line-height: 20px;
|
color: var(--text-color-secondary);
|
}
|
|
.result-path {
|
display: inline-flex;
|
gap: 5px;
|
align-items: center;
|
max-width: min(100%, 520px);
|
|
> span {
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
}
|
|
.result-path-link {
|
padding: 0;
|
font: inherit;
|
color: var(--primary-color);
|
cursor: pointer;
|
background: transparent;
|
border: 0;
|
|
&:hover {
|
text-decoration: underline;
|
}
|
}
|
|
.result-snippet,
|
.result-file-name {
|
margin: 7px 0 0;
|
font-size: 13px;
|
line-height: 21px;
|
color: var(--text-color);
|
}
|
|
.result-file-name {
|
color: var(--text-color-secondary);
|
}
|
|
:deep(mark) {
|
padding: 0 1px;
|
color: inherit;
|
background: #ffe58f;
|
}
|
|
.preview-button {
|
align-self: center;
|
}
|
|
.result-empty {
|
margin-top: 120px;
|
}
|
|
.search-pagination {
|
display: flex;
|
flex-shrink: 0;
|
gap: 12px;
|
align-items: center;
|
justify-content: center;
|
min-height: 46px;
|
|
.pagination-label {
|
min-width: 72px;
|
color: var(--text-color-secondary);
|
text-align: center;
|
}
|
}
|
|
.pagination-button {
|
flex: 0 0 40px;
|
width: 40px;
|
min-width: 40px;
|
padding-right: 0;
|
padding-left: 0;
|
}
|
|
@media (max-width: 720px) {
|
.search-page-center > .search-header,
|
.search-summary,
|
.search-body {
|
padding-right: 16px;
|
padding-left: 16px;
|
}
|
|
.search-command {
|
grid-template-columns: minmax(0, 1fr) 84px;
|
}
|
|
.search-button {
|
width: 84px;
|
}
|
|
.result-row {
|
grid-template-columns: 30px minmax(0, 1fr);
|
gap: 10px;
|
}
|
|
.result-icon {
|
width: 28px;
|
}
|
|
.preview-button {
|
display: none;
|
}
|
|
.result-status {
|
flex-direction: column;
|
gap: 2px;
|
}
|
}
|
</style>
|