ow
This commit is contained in:
1 parent
7f5bf3c68d
commit
816209fc35
37 files changed
+3236
-4515
No files matched your search
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div class="help-doc">
|
||||
<p class="help-intro">
|
||||
「我的云盘」用于浏览和管理云盘中的文件与文件夹:
|
||||
</p>
|
||||
|
||||
<section class="help-section">
|
||||
<h4 class="help-title">一、顶部工具栏</h4>
|
||||
<ul class="help-list help-list--icon">
|
||||
<li>
|
||||
<span class="help-icon"><HomeOutlined /></span>
|
||||
<span><b>返回顶层</b>:一键回到根目录(我的云盘);已在根目录时按钮为灰色不可点。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><ArrowLeftOutlined /></span>
|
||||
<span><b>返回上层</b>:回到当前目录的上一级;已在根目录时不可点。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><ReloadOutlined /></span>
|
||||
<span><b>刷新</b>:重新加载当前目录的内容(加载过程中图标会转圈)。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon help-icon--wide"><LaptopOutlined /><FolderOutlined /></span>
|
||||
<span>
|
||||
<b>面包屑导航</b>:显示当前所在的目录层级 ——
|
||||
第一级是「我的云盘」<span class="help-inline"><LaptopOutlined /></span>,
|
||||
之后每一级是子文件夹 <span class="help-inline"><FolderOutlined /></span>;
|
||||
点击其中任意一级可快速跳转到该层。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><SearchOutlined /></span>
|
||||
<span>
|
||||
<b>搜索框</b>:按「文件名称」在当前目录内模糊查询,输入后按回车或点击输入框里的搜索图标生效;
|
||||
清空关键字后再搜索一次即可恢复当前目录的完整列表。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon help-icon--wide">
|
||||
<cust-icon-font size="15" type="icon-sousuo" />
|
||||
<cust-icon-font size="15" type="icon-gongcheng" />
|
||||
</span>
|
||||
<span>
|
||||
<b>搜索 / 目录名切换</b>(手机等小屏):
|
||||
点 <span class="help-inline"><cust-icon-font size="15" type="icon-sousuo" /></span> 显示搜索框,
|
||||
点 <span class="help-inline"><cust-icon-font size="15" type="icon-gongcheng" /></span>
|
||||
返回「目录名 + 文件列表」显示。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><cust-icon-font size="15" type="icon-help" /></span>
|
||||
<span><b>使用帮助</b>:打开本说明。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h4 class="help-title">二、操作按钮(位于工具栏下方)</h4>
|
||||
<ul class="help-list help-list--icon">
|
||||
<li>
|
||||
<span class="help-icon"><FolderAddOutlined /></span>
|
||||
<span><b>新建文件夹</b>:在当前目录下创建文件夹(需「新建」权限)。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon help-icon--primary"><UploadOutlined /></span>
|
||||
<span>
|
||||
<b>上传文件</b>:点击后选择文件,按分片方式上传,大文件也能上传;
|
||||
上传过程中会显示进度,成功后列表自动刷新(需「新建」权限)。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><DownloadOutlined /></span>
|
||||
<span><b>下载</b>:仅选中<b>单个文件</b>时出现;文件夹不支持下载(需「修改」权限)。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><ShareAltOutlined /></span>
|
||||
<span><b>共享</b>:仅选中单个文件时出现,可把该文件共享到指定的家庭群组 / 文件夹。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><EditOutlined /></span>
|
||||
<span><b>重命名</b>:仅选中<b>单个</b>文件或文件夹时出现(需「修改」权限)。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><RotateRightOutlined /></span>
|
||||
<span><b>移动</b>:仅选中<b>单个</b>文件或文件夹时出现,可把它移到其它目录(需「修改」权限)。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon help-icon--danger"><DeleteOutlined /></span>
|
||||
<span>
|
||||
<b>删除</b>:勾选任意条目后出现,点击会二次确认;
|
||||
删除文件夹会连同其中的内容一并删除(需「删除」权限)。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><NotificationOutlined /></span>
|
||||
<span>
|
||||
<b>发布</b>:按所选文件的类型自动判断 —— 图片 → 相册,音频 → 音乐专辑,视频 → 影视专辑。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><cust-icon-font size="16" type="icon-qingbaojiankong" /></span>
|
||||
<span>
|
||||
<b>相册展览 / 影视厅 / 音乐厅</b>:随文件类型变化的预览入口 ——
|
||||
图片进相册展览、视频进影视厅、音频进音乐厅。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon"><cust-icon-font size="16" type="icon-foldervideo" /></span>
|
||||
<span><b>播放</b>(视频):仅选中单个视频文件时出现,可在线播放。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon help-icon--wide">
|
||||
<PlayCircleOutlined class="help-danger" />
|
||||
<PauseCircleOutlined />
|
||||
<PlayCircleOutlined class="help-danger" />
|
||||
</span>
|
||||
<span>
|
||||
<b>播放 / 暂停 / 继续</b>(音频):选中单个音频文件时出现,可直接在线播放,
|
||||
播放中可暂停、暂停后可继续。
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="help-tip">
|
||||
提示:选中多个条目时,只保留「删除」等适用于多条目的操作;
|
||||
没有权限或不适用的按钮会自动隐藏或显示为灰色。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h4 class="help-title">三、文件区域</h4>
|
||||
<ul class="help-list help-list--icon">
|
||||
<li>
|
||||
<span class="help-icon"><a-checkbox class="help-checkbox" /></span>
|
||||
<span><b>全选</b>:勾选 / 取消勾选当前列表的全部条目。</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon help-icon--wide"><UnorderedListOutlined /><AppstoreOutlined /></span>
|
||||
<span>
|
||||
<b>列表视图 / 图标视图</b>:切换文件区的展示方式
|
||||
(<span class="help-inline"><UnorderedListOutlined /></span> 列表视图,
|
||||
<span class="help-inline"><AppstoreOutlined /></span> 图标视图,当前生效的那个为蓝色)。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-chip">单击</span>
|
||||
<span>
|
||||
<b>单击条目</b>:选中该条目(可多选,也可直接勾选左侧复选框),选中后上方的操作按钮才会出现。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-chip">双击</span>
|
||||
<span>
|
||||
<b>双击条目</b>:文件夹 → 进入下一级;图片 → 打开大图预览;视频 → 在线播放;
|
||||
音频 → 在线播放;其它类型 → 直接下载。
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="help-icon help-icon--wide">
|
||||
<FolderOutlined class="help-folder" />
|
||||
<FileImageOutlined class="help-image" />
|
||||
<VideoCameraOutlined class="help-video" />
|
||||
<PlayCircleOutlined class="help-audio" />
|
||||
<FileOutlined class="help-file" />
|
||||
</span>
|
||||
<span>
|
||||
<b>文件类型图标</b>:黄色为文件夹、绿色为图片、蓝色为视频、粉色为音频、灰色为其它文件;
|
||||
点击图片的缩略图可直接预览。
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h4 class="help-title">四、权限说明</h4>
|
||||
<ul class="help-list">
|
||||
<li><b>新建权限</b>:新建文件夹、上传文件。</li>
|
||||
<li><b>修改权限</b>:重命名、移动、下载。</li>
|
||||
<li><b>删除权限</b>:删除文件 / 文件夹。</li>
|
||||
<li><b>查看权限</b>:进入本页面浏览文件。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h4 class="help-title">五、常见问题</h4>
|
||||
<ul class="help-list">
|
||||
<li><b>上传大文件时</b>:请保持页面打开,等待提示「上传成功」后列表会自动刷新。</li>
|
||||
<li>
|
||||
<b>搜索不到文件</b>:搜索只在<b>当前目录</b>内进行,请先进入文件所在目录再搜索。
|
||||
</li>
|
||||
<li>
|
||||
<b>提示「未知格式无法预览」</b>:该格式不支持在线预览,请先下载到本地再查看。
|
||||
</li>
|
||||
<li><b>按钮是灰色不可点</b>:一般是当前账号缺少对应权限,或当前选择不满足操作条件。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
HomeOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ReloadOutlined,
|
||||
LaptopOutlined,
|
||||
FolderOutlined,
|
||||
SearchOutlined,
|
||||
FolderAddOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
EditOutlined,
|
||||
RotateRightOutlined,
|
||||
DeleteOutlined,
|
||||
NotificationOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
UnorderedListOutlined,
|
||||
AppstoreOutlined,
|
||||
FileImageOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
|
||||
defineOptions({ name: "DmsFileEnquiryHelp" });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-doc {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #1f2329;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.help-intro {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e8f1ff;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f2f8ff 0%, #ffffff 70%);
|
||||
color: #3b4252;
|
||||
}
|
||||
|
||||
.help-intro code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.help-section + .help-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
margin: 0 0 6px 0;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.help-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.help-list > li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.help-list b {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 带图标的列表:每项前面是一个与页面按钮同款的小图标框 */
|
||||
.help-list--icon {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.help-list--icon > li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
width: 30px;
|
||||
height: 26px;
|
||||
margin-top: 1px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
background: #fafbfc;
|
||||
color: #1677ff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.help-icon--wide {
|
||||
width: auto;
|
||||
min-width: 30px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
/* 上传按钮在页面上是主按钮(蓝色实心) */
|
||||
.help-icon--primary {
|
||||
border-color: #1677ff;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 删除按钮图标在页面上是红色 */
|
||||
.help-icon--danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.help-chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 22px;
|
||||
margin-top: 2px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
background: #f5f6f8;
|
||||
color: #4e5969;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.help-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 2px;
|
||||
color: #1677ff;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
.help-checkbox {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.help-danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.help-folder {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.help-image {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.help-video {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.help-audio {
|
||||
color: #eb2f96;
|
||||
}
|
||||
|
||||
.help-file {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.help-tip {
|
||||
margin: 6px 0 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
font-size: 13px;
|
||||
color: #8a919f;
|
||||
}
|
||||
|
||||
/* 大屏下帮助弹窗很宽,分两栏展示,避免每行文字过长不便阅读 */
|
||||
@media (min-width: 1200px) {
|
||||
.help-doc {
|
||||
column-count: 2;
|
||||
column-gap: 28px;
|
||||
}
|
||||
|
||||
.help-intro {
|
||||
column-span: all;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.help-section + .help-section {
|
||||
margin-top: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,12 @@
|
||||
<teleport :to="getContainer()">
|
||||
<div ref="modalWrapRef" class="draggable-modal" :class="{ fullscreen: fullscreenModel }">
|
||||
<Modal
|
||||
v-bind="omit(props, ['open', 'onCancel', 'onOk', 'onUpdate:open'])"
|
||||
v-bind="omit(props, ['open', 'onCancel', 'onOk', 'onUpdate:open', 'width'])"
|
||||
v-model:open="visibleModel"
|
||||
:destroyOnClose="true"
|
||||
:mask-closable="false"
|
||||
:get-container="() => modalWrapRef"
|
||||
:width="innerWidth || width"
|
||||
:width="modalWidth"
|
||||
@ok="emit('ok')"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
@@ -38,7 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { ref, watch, nextTick, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { modalProps } from 'ant-design-vue/es/modal/Modal';
|
||||
import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons-vue';
|
||||
@@ -46,6 +46,11 @@
|
||||
import { throttle, omit } from 'lodash-es';
|
||||
import { Modal, Space } from 'ant-design-vue';
|
||||
|
||||
/** 调用方未指定 width 时的默认宽度:占视口 90%
|
||||
* (antd Modal 默认只有 520px,大屏下会跟小屏一样窄,浪费展示空间)。
|
||||
* fullscreen 模式下会被 CSS 的 width: 100% 覆盖。 */
|
||||
const DEFAULT_WIDTH = '90vw';
|
||||
|
||||
const props = defineProps({
|
||||
...modalProps(),
|
||||
fullscreen: {
|
||||
@@ -65,6 +70,11 @@
|
||||
const fullscreenModel = ref(props.fullscreen);
|
||||
const innerWidth = ref('');
|
||||
|
||||
// 宽度优先级:手动缩放后的宽度 > 调用方传入的 width > 默认 90vw
|
||||
const modalWidth = computed<string | number>(
|
||||
() => innerWidth.value || (props.width as string | number) || DEFAULT_WIDTH,
|
||||
);
|
||||
|
||||
const cursorStyle = {
|
||||
top: 'n-resize',
|
||||
left: 'w-resize',
|
||||
@@ -275,6 +285,11 @@
|
||||
};
|
||||
|
||||
watch(visibleModel, async (val) => {
|
||||
// 每次打开都回到默认宽度,否则上一次手动缩放后的宽度会一直跟着这个弹窗
|
||||
if (val) {
|
||||
innerWidth.value = '';
|
||||
}
|
||||
|
||||
if ((val && Object.is(inited, false)) || props.destroyOnClose) {
|
||||
initDrag();
|
||||
}
|
||||
@@ -296,6 +311,7 @@
|
||||
.ant-modal-content {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +362,10 @@
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
/* 弹窗最大高度限制在视口内(antd 默认 top: 100px,再留 24px 底部余量):
|
||||
否则内容过高时弹窗会整体超出屏幕,而 body 因为没有确定高度不会出现滚动条,
|
||||
表现为「刚弹出时没有滚动条、下面的内容也滚不到」。 */
|
||||
max-height: calc(100vh - 124px);
|
||||
overflow: hidden;
|
||||
|
||||
.ant-modal-body {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="file-upload" :class="{ 'file-upload-hidden': !showButton && !hasSlot }">
|
||||
<a-upload ref="uploadRef" v-model:file-list="fileList" :accept="accept" :multiple="multiple" :disabled="disabled"
|
||||
:show-upload-list="false" :before-upload="handleBeforeUpload">
|
||||
<slot>
|
||||
<a-button v-if="showButton" type="primary" :disabled="disabled" :loading="uploading">
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
{{ buttonText }}
|
||||
</a-button>
|
||||
</slot>
|
||||
</a-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots } from "vue";
|
||||
import { UploadOutlined } from "@ant-design/icons-vue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import axios from "axios";
|
||||
import type { UploadProps } from "ant-design-vue";
|
||||
import { useLoginProfileStore } from "@/store/modules/loginProfile";
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
|
||||
defineOptions({ name: "FileUpload" });
|
||||
|
||||
/** 上传成功回调数据 */
|
||||
interface FileUploadResult {
|
||||
/** 原始选择的文件 */
|
||||
file: File;
|
||||
/** 上传时携带的文件夹路径(请求头 FilePath) */
|
||||
path: string;
|
||||
/** 分片总数 */
|
||||
total: number;
|
||||
/** 最后一个分片的文件名(序号=总数),如 xxxx-3-3.jpg,可作为服务端的文件标识 */
|
||||
fileName: string;
|
||||
/** 服务端最后一次响应数据 */
|
||||
response: any;
|
||||
}
|
||||
|
||||
/** 上传进度数据 */
|
||||
interface FileUploadProgress {
|
||||
/** 百分比 0-100 */
|
||||
percent: number;
|
||||
/** 当前分片序号 */
|
||||
current: number;
|
||||
/** 分片总数 */
|
||||
total: number;
|
||||
/** 提示文案 */
|
||||
text: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 上传接口地址,默认使用 DmsFile/UploadFile */
|
||||
action?: string;
|
||||
/** 文件夹路径(写入请求头 FilePath) */
|
||||
filePath?: string;
|
||||
/** 动态解析文件夹路径,优先级高于 filePath */
|
||||
resolveFilePath?: () => string | Promise<string>;
|
||||
/** 额外请求头 */
|
||||
headers?: Record<string, any>;
|
||||
/** 接受的文件类型,如 image/* */
|
||||
accept?: string;
|
||||
/** 是否支持多选 */
|
||||
multiple?: boolean;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 是否显示默认上传按钮 */
|
||||
showButton?: boolean;
|
||||
/** 默认上传按钮文案 */
|
||||
buttonText?: string;
|
||||
/** 分片大小(字节) */
|
||||
chunkSize?: number;
|
||||
/** 请求超时时间(毫秒) */
|
||||
timeout?: number;
|
||||
}>(),
|
||||
{
|
||||
action: "",
|
||||
filePath: "",
|
||||
headers: () => ({}),
|
||||
accept: "*",
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
showButton: false,
|
||||
buttonText: "上传文件",
|
||||
chunkSize: 1024 * 1024,
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "start", file: File): void;
|
||||
(e: "progress", payload: FileUploadProgress): void;
|
||||
(e: "success", payload: FileUploadResult): void;
|
||||
(e: "error", error: unknown): void;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
const hasSlot = computed(() => !!slots.default);
|
||||
|
||||
const uploadRef = ref<any>(null);
|
||||
const fileList = ref<UploadProps["fileList"]>([]);
|
||||
const uploading = ref(false);
|
||||
|
||||
/** 默认上传地址 */
|
||||
const defaultAction = () => {
|
||||
const root =
|
||||
process.env.NODE_ENV === "development"
|
||||
? WebSitePolicy.WebApiRootDev
|
||||
: WebSitePolicy.WebApiRoot;
|
||||
return `${root.replace(/\/$/, "")}/DmsFile/UploadFile`;
|
||||
};
|
||||
|
||||
/** 解析本次上传使用的文件夹路径 */
|
||||
const resolvePath = async (): Promise<string> => {
|
||||
if (props.resolveFilePath) {
|
||||
return (await props.resolveFilePath()) || "";
|
||||
}
|
||||
return props.filePath || "";
|
||||
};
|
||||
|
||||
/** 文件分片 */
|
||||
const splitFile = (file: File): Blob[] => {
|
||||
const chunks: Blob[] = [];
|
||||
const size = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
|
||||
let start = 0;
|
||||
while (start < file.size) {
|
||||
const end = Math.min(start + size, file.size);
|
||||
chunks.push(file.slice(start, end));
|
||||
start += size;
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
/** 生成分片文件名:uid-序号-总数.扩展名 */
|
||||
const buildChunkName = (
|
||||
uid: string,
|
||||
index: number,
|
||||
total: number,
|
||||
fileName: string
|
||||
): string => {
|
||||
const fileExtension = fileName.includes(".")
|
||||
? fileName.split(".").pop()
|
||||
: "";
|
||||
return `${uid}-${index}-${total}${fileExtension ? "." + fileExtension : ""}`;
|
||||
};
|
||||
|
||||
/** 分片上传(顺序上传) */
|
||||
const uploadChunk = async (
|
||||
chunks: Blob[],
|
||||
fileName: string,
|
||||
uid: string,
|
||||
index: number,
|
||||
path: string
|
||||
): Promise<any> => {
|
||||
const formData = new FormData();
|
||||
const chunkName = buildChunkName(uid, index, chunks.length, fileName);
|
||||
formData.append(fileName, chunks[index - 1], chunkName);
|
||||
|
||||
const response = await axios.create({ timeout: props.timeout }).request({
|
||||
url: props.action || defaultAction(),
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: {
|
||||
...(props.headers || {}),
|
||||
Authorization: useLoginProfileStore().getToken(),
|
||||
"Content-Type": "multipart/form-data",
|
||||
FilePath: path,
|
||||
},
|
||||
});
|
||||
|
||||
emit("progress", {
|
||||
percent: Math.round((index / chunks.length) * 100),
|
||||
current: index,
|
||||
total: chunks.length,
|
||||
text: `上传中(${index}/${chunks.length})...`,
|
||||
});
|
||||
|
||||
if (index < chunks.length) {
|
||||
return await uploadChunk(chunks, fileName, uid, index + 1, path);
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 上传入口 */
|
||||
const upload = async (file: File) => {
|
||||
if (!file || typeof file.size !== "number") {
|
||||
return;
|
||||
}
|
||||
if (uploading.value) {
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
emit("start", file);
|
||||
try {
|
||||
const path = await resolvePath();
|
||||
const chunks = splitFile(file);
|
||||
if (chunks.length === 0) {
|
||||
throw new Error("无法读取文件内容");
|
||||
}
|
||||
const uid = uuidv4();
|
||||
const response = await uploadChunk(chunks, file.name, uid, 1, path);
|
||||
// 返回分割后的最后一个(序号最大的)分片文件名
|
||||
const fileName = buildChunkName(uid, chunks.length, chunks.length, file.name);
|
||||
emit("success", { file, path, total: chunks.length, fileName, response });
|
||||
} catch (error) {
|
||||
emit("error", error);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
fileList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeUpload: UploadProps["beforeUpload"] = (file) => {
|
||||
upload(file as unknown as File);
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 触发文件选择(供外部按钮调用) */
|
||||
const trigger = () => {
|
||||
if (props.disabled || uploading.value) {
|
||||
return;
|
||||
}
|
||||
uploadRef.value?.$el?.querySelector("input")?.click();
|
||||
};
|
||||
|
||||
defineExpose({ trigger, upload });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-upload-hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -84,11 +84,9 @@
|
||||
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div style="display: none">
|
||||
<a-upload v-if="canCreate" v-model:file-list="fileList" name="file" :before-upload="onBeforeUpload"
|
||||
:headers="headers" :show-upload-list="false" :action="uploadUrl" ref="uploadRef">
|
||||
</a-upload>
|
||||
</div>
|
||||
<FileUpload v-if="canCreate" ref="fileUploadRef" :action="uploadUrl" :headers="headers"
|
||||
:resolve-file-path="resolveFilePath" @start="onUploadStart" @progress="onUploadProgress"
|
||||
@success="onUploadSuccess" @error="onUploadError" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -110,12 +108,13 @@ import {
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { DmsFileUploadViewDisplayData } from "@/data/auto/dmsFileUploadViewDisplayData";
|
||||
import type { UploadProps } from "ant-design-vue";
|
||||
import FileUpload from "@/components/basic/FileUpload.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedItems: DmsFileUploadViewDisplayData[];
|
||||
uploadUrl: string;
|
||||
headers: any;
|
||||
resolveFilePath?: () => string | Promise<string>;
|
||||
canCreate: boolean;
|
||||
canAmend: boolean;
|
||||
canDelete: boolean;
|
||||
@@ -124,7 +123,10 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits([
|
||||
"create-folder",
|
||||
"before-upload",
|
||||
"upload-start",
|
||||
"upload-progress",
|
||||
"upload-success",
|
||||
"upload-error",
|
||||
"download",
|
||||
"share",
|
||||
"publish",
|
||||
@@ -139,8 +141,7 @@ const emit = defineEmits([
|
||||
"delete",
|
||||
]);
|
||||
|
||||
const fileList = ref<UploadProps["fileList"]>([]);
|
||||
const uploadRef = ref<any>(null);
|
||||
const fileUploadRef = ref<any>(null);
|
||||
|
||||
const hasSelection = computed(() => props.selectedItems.length > 0);
|
||||
const isDirectorySelected = computed(() =>
|
||||
@@ -194,12 +195,23 @@ const previewTooltip = computed(() => {
|
||||
});
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadRef.value?.$el?.querySelector("input")?.click();
|
||||
fileUploadRef.value?.trigger();
|
||||
};
|
||||
|
||||
const onBeforeUpload: UploadProps["beforeUpload"] = (file) => {
|
||||
emit("before-upload", file);
|
||||
return false;
|
||||
const onUploadStart = (file: File) => {
|
||||
emit("upload-start", file);
|
||||
};
|
||||
|
||||
const onUploadProgress = (payload: any) => {
|
||||
emit("upload-progress", payload);
|
||||
};
|
||||
|
||||
const onUploadSuccess = (payload: any) => {
|
||||
emit("upload-success", payload);
|
||||
};
|
||||
|
||||
const onUploadError = (error: unknown) => {
|
||||
emit("upload-error", error);
|
||||
};
|
||||
|
||||
const onDownload = () => {
|
||||
|
||||
@@ -53,9 +53,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, h } from "vue";
|
||||
import { computed, h } from "vue";
|
||||
import {
|
||||
UploadOutlined,
|
||||
FolderAddOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
@@ -71,12 +70,9 @@ import {
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { DmsFileUploadDeleteLogViewDisplayData } from "@/data/auto/dmsFileUploadDeleteLogViewDisplayData";
|
||||
import type { UploadProps } from "ant-design-vue";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedItems: DmsFileUploadDeleteLogViewDisplayData[];
|
||||
uploadUrl: string;
|
||||
headers: any;
|
||||
canCreate: boolean;
|
||||
canAmend: boolean;
|
||||
canDelete: boolean;
|
||||
@@ -85,7 +81,6 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits([
|
||||
"create-folder",
|
||||
"before-upload",
|
||||
"download",
|
||||
"share",
|
||||
"publish",
|
||||
@@ -101,9 +96,6 @@ const emit = defineEmits([
|
||||
"restore",
|
||||
]);
|
||||
|
||||
const fileList = ref<UploadProps["fileList"]>([]);
|
||||
const uploadRef = ref<any>(null);
|
||||
|
||||
const hasSelection = computed(() => props.selectedItems.length > 0);
|
||||
const isDirectorySelected = computed(() =>
|
||||
props.selectedItems.some((s) => s.IsDirectoryIndc == "Y"),
|
||||
@@ -155,15 +147,6 @@ const previewTooltip = computed(() => {
|
||||
return "预览";
|
||||
});
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadRef.value?.$el?.querySelector("input")?.click();
|
||||
};
|
||||
|
||||
const onBeforeUpload: UploadProps["beforeUpload"] = (file) => {
|
||||
emit("before-upload", file);
|
||||
return false;
|
||||
};
|
||||
|
||||
const onDownload = () => {
|
||||
emit("download", props.selectedItems);
|
||||
};
|
||||
|
||||
@@ -60,9 +60,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, h } from "vue";
|
||||
import { computed, h } from "vue";
|
||||
import {
|
||||
UploadOutlined,
|
||||
FolderAddOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
@@ -77,7 +76,6 @@ import {
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { FamilyDmsFileViewDisplayData } from "@/data/auto/familyDmsFileViewDisplayData";
|
||||
import type { UploadProps } from "ant-design-vue";
|
||||
interface BreadcrumbItem {
|
||||
value: string;
|
||||
displayName: string;
|
||||
@@ -86,8 +84,6 @@ interface BreadcrumbItem {
|
||||
const props = defineProps<{
|
||||
selectedItems: FamilyDmsFileViewDisplayData[];
|
||||
pathStack: BreadcrumbItem[];
|
||||
uploadUrl: string;
|
||||
headers: any;
|
||||
canCreate: boolean;
|
||||
canAmend: boolean;
|
||||
canDelete: boolean;
|
||||
@@ -97,7 +93,6 @@ const props = defineProps<{
|
||||
const emit = defineEmits([
|
||||
"create-folder",
|
||||
"set-share-folder",
|
||||
"before-upload",
|
||||
"download",
|
||||
"share",
|
||||
"publish",
|
||||
@@ -112,9 +107,6 @@ const emit = defineEmits([
|
||||
"delete",
|
||||
]);
|
||||
|
||||
const fileList = ref<UploadProps["fileList"]>([]);
|
||||
const uploadRef = ref<any>(null);
|
||||
|
||||
const hasSelection = computed(() => props.selectedItems.length > 0);
|
||||
const isDirectorySelected = computed(() =>
|
||||
props.selectedItems.some((s) => s.IsDirectoryIndc == "Y"),
|
||||
@@ -185,15 +177,6 @@ const previewTooltip = computed(() => {
|
||||
return "预览";
|
||||
});
|
||||
|
||||
const triggerUpload = () => {
|
||||
uploadRef.value?.$el?.querySelector("input")?.click();
|
||||
};
|
||||
|
||||
const onBeforeUpload: UploadProps["beforeUpload"] = (file) => {
|
||||
emit("before-upload", file);
|
||||
return false;
|
||||
};
|
||||
|
||||
const onDownload = () => {
|
||||
emit("download", props.selectedItems);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
<template>
|
||||
<div
|
||||
ref="searchRef"
|
||||
style="margin-left: 4px; padding-top: 4px; margin-right: 12px"
|
||||
>
|
||||
<searchCriteria
|
||||
@submit="onSubmit"
|
||||
@reset="onReset"
|
||||
@handle-change-bind-value="handleChangeBindValue"
|
||||
>
|
||||
<template #control>
|
||||
<slot name="control"></slot>
|
||||
</template>
|
||||
</searchCriteria>
|
||||
<hr />
|
||||
</div>
|
||||
<div ref="settingRef" class="setting">
|
||||
<a-row :wrap="false">
|
||||
<!-- 大屏用 4 列占位把操作区推到右侧;中小屏占位列隐藏,内容列占满整行再靠右 -->
|
||||
<a-col :xs="0" :sm="0" :md="0" :lg="4"></a-col>
|
||||
<a-col
|
||||
style="display: flex; justify-content: flex-end; flex-wrap: wrap"
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="24"
|
||||
:lg="20"
|
||||
>
|
||||
<a-input
|
||||
flex="none"
|
||||
readonly
|
||||
value="视图"
|
||||
class="site-input-split"
|
||||
style="
|
||||
width: 80px;
|
||||
border-radius: 0;
|
||||
border-top-left-radius: 5pt 5pt;
|
||||
border-bottom-left-radius: 5pt 5pt;
|
||||
background-color: rgb(250, 250, 250);
|
||||
pointer-events: none;
|
||||
"
|
||||
/>
|
||||
<a-select
|
||||
style="min-width: 200px"
|
||||
v-model:value="viewOption.Value"
|
||||
@select="onSelectColumn"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="o in viewOption.SelectOptions"
|
||||
:key="o.DisplayName"
|
||||
:value="o.BindValue"
|
||||
>{{ o.DisplayName }}</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
<a-button
|
||||
type="dashed"
|
||||
:icon="h(SettingTwoTone)"
|
||||
@click.prevent="onViewResult"
|
||||
></a-button>
|
||||
<a-button
|
||||
type="dashed"
|
||||
:icon="h(PrinterTwoTone)"
|
||||
@click.prevent="onPrint"
|
||||
></a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<columnModal
|
||||
:show-modal="showColumnModal"
|
||||
@on-change-columns="onChangeColumns"
|
||||
@handle-close="onHandleClose"
|
||||
@on-change-view-option="onChangeViewOption"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 结果区:卡片列表 ==================== -->
|
||||
<div class="sc-result" style="margin-left: 2px; margin-right: 6px">
|
||||
<!-- 卡片列表 -->
|
||||
<div
|
||||
ref="listRef"
|
||||
class="sc-list"
|
||||
:style="isModal ? undefined : { height: resultH || undefined }"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="datas.length" class="sc-list-inner">
|
||||
<div
|
||||
v-for="(record, index) in datas"
|
||||
:key="index"
|
||||
class="sc-card"
|
||||
:class="{ 'sc-card-selected': isSelected(record) }"
|
||||
@click="onCardClick($event, record)"
|
||||
@dblclick="onCardDblclick($event, record)"
|
||||
>
|
||||
<div class="sc-card-head">
|
||||
<span class="sc-check">
|
||||
<a-checkbox v-if="isCheckbox" :checked="isSelected(record)" />
|
||||
<a-radio v-else :checked="isSelected(record)" />
|
||||
</span>
|
||||
<span class="sc-index">{{ getRowIndex(index) }}</span>
|
||||
<span class="sc-title">{{ getTitle(record) }}</span>
|
||||
</div>
|
||||
<div class="sc-card-body">
|
||||
<div
|
||||
v-for="column in getCardColumns(index)"
|
||||
:key="column.dataIndex"
|
||||
class="sc-field"
|
||||
>
|
||||
<span class="sc-field-label"><slot
|
||||
name="headerCell"
|
||||
v-bind="{ title: column.title, column: column }"
|
||||
>{{ column.title }}</slot></span>
|
||||
<span class="sc-field-value"><span
|
||||
class="sc-slot"
|
||||
><slot
|
||||
name="bodyCell"
|
||||
v-bind="{
|
||||
text: record[column.dataIndex],
|
||||
column: column,
|
||||
record: record,
|
||||
index: index,
|
||||
}"
|
||||
></slot></span><span class="sc-fallback">{{
|
||||
formatCell(record, column)
|
||||
}}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canExpand" class="sc-card-more">
|
||||
<a
|
||||
class="sc-more-btn"
|
||||
@click.stop="toggleExpand(index)"
|
||||
@dblclick.stop="noop"
|
||||
>
|
||||
<span>{{
|
||||
isExpanded(index)
|
||||
? "收起"
|
||||
: `展开全部(共 ${allColumns.length} 项)`
|
||||
}}</span>
|
||||
<UpOutlined v-if="isExpanded(index)" />
|
||||
<DownOutlined v-else />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else class="sc-empty" description="暂无数据" />
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 分页:固定在 footer 上方 -->
|
||||
<div
|
||||
ref="paginationRef"
|
||||
class="sc-pagination"
|
||||
:class="{ 'sc-pagination-fixed': !isModal }"
|
||||
:style="!isModal ? { bottom: paginationBottom } : undefined"
|
||||
>
|
||||
<a-pagination
|
||||
size="small"
|
||||
:current="pageCurrent"
|
||||
:page-size="pageSizeCurrent"
|
||||
:total="paginationJ.Total"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
:show-total="showTotal"
|
||||
@change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="footer" style="padding-left: 4px"></slot>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
watch,
|
||||
inject,
|
||||
computed,
|
||||
onMounted,
|
||||
h,
|
||||
ref,
|
||||
unref,
|
||||
nextTick,
|
||||
onUnmounted,
|
||||
} from "vue";
|
||||
import { usePagination } from "vue-request";
|
||||
import searchCriteria from "@/components/basic/SearchCriteria.vue";
|
||||
import { SearchFilter } from "@/data/auto/searchFilter";
|
||||
import { SearchCriteriaResultData } from "@/data/auto/searchCriteriaResultData";
|
||||
import {
|
||||
SettingTwoTone,
|
||||
PrinterTwoTone,
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import columnModal from "./ColumnModal.vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { bsInfo } from "@/utils/browser";
|
||||
import { enquiryMethodKey } from "@/injections";
|
||||
|
||||
//================props================
|
||||
const props = defineProps({
|
||||
isModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollx: {
|
||||
type: Number,
|
||||
default: 1024,
|
||||
},
|
||||
scrolly: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
pagination: {
|
||||
type: SearchCriteriaResultData,
|
||||
default: new SearchCriteriaResultData(),
|
||||
},
|
||||
rowSelectionType: {
|
||||
type: String,
|
||||
default: "radio",
|
||||
},
|
||||
// 卡片标题取值的列(按 dataIndex),默认取第一个字段列
|
||||
cardTitleKeys: {
|
||||
type: Array as () => string[],
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const isModal = ref(props["isModal"]);
|
||||
|
||||
const paginationJ = ref(props["pagination"]);
|
||||
const rowSelectionType = ref(props["rowSelectionType"]);
|
||||
const tableY = ref(props["scrolly"]);
|
||||
|
||||
const enquiry = inject(enquiryMethodKey, undefined);
|
||||
|
||||
const datas = enquiry?.getDatas()!;
|
||||
|
||||
const viewOption = ref(
|
||||
enquiry
|
||||
? enquiry.getViewOption()
|
||||
: {
|
||||
Value: "default",
|
||||
SelectOptions: [{ BindValue: "default", DisplayName: "默认" }],
|
||||
Columns: [{ BindValue: "default", BindColumn: [] }],
|
||||
},
|
||||
);
|
||||
|
||||
//================inject================
|
||||
const isSM = ref<boolean>(false);
|
||||
const { width, height } = useWindowSize();
|
||||
if (bsInfo.size.sm) {
|
||||
isSM.value = true;
|
||||
} else {
|
||||
isSM.value = false;
|
||||
}
|
||||
|
||||
const resultH = ref("");
|
||||
const showColumnModal = ref<boolean>(false);
|
||||
const selected = ref<any[]>([]);
|
||||
|
||||
const emit = defineEmits([
|
||||
"onReset",
|
||||
"onSubmit",
|
||||
"handleChangeBindValue",
|
||||
"usePagination",
|
||||
"onRowDblclick",
|
||||
"onclick",
|
||||
"onSelectChange",
|
||||
"onViewResult",
|
||||
"onPrint",
|
||||
"updateColumns",
|
||||
]);
|
||||
|
||||
const onViewResult = () => {
|
||||
showColumnModal.value = true;
|
||||
emit("onViewResult");
|
||||
};
|
||||
|
||||
const onHandleClose = () => {
|
||||
showColumnModal.value = false;
|
||||
};
|
||||
|
||||
const onPrint = () => {
|
||||
emit("onPrint", viewOption.value);
|
||||
};
|
||||
|
||||
const searchRef = ref<HTMLElement | null>();
|
||||
const settingRef = ref<HTMLElement | null>();
|
||||
|
||||
//================分页固定:始终贴在 footer 上方================
|
||||
const paginationBottom = ref("64px");
|
||||
const paginationRef = ref<HTMLElement | null>(null);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const modalBodyEl = ref<HTMLElement | null>(null);
|
||||
|
||||
/** 获取页面级 footer(SearchCard 所在容器内的 .page-footer) */
|
||||
const getPageFooter = (): HTMLElement | null => {
|
||||
const container = searchRef.value?.parentElement ?? document.body;
|
||||
return container.querySelector(".page-footer") as HTMLElement | null;
|
||||
};
|
||||
|
||||
/** 普通页面:按 footer 真实高度算出分页栏的 bottom 偏移 */
|
||||
const computePaginationBottom = () => {
|
||||
if (isModal.value) return;
|
||||
const footerEl = getPageFooter();
|
||||
if (!footerEl) {
|
||||
paginationBottom.value = "64px";
|
||||
return;
|
||||
}
|
||||
// 视口底部 -> footer 顶部的距离(含 footer 高度与其 bottom 偏移) + 间距
|
||||
const offset = window.innerHeight - footerEl.getBoundingClientRect().top;
|
||||
paginationBottom.value = `${Math.max(Math.round(offset) + 6, 0)}px`;
|
||||
};
|
||||
|
||||
/** 普通页面:底部为「分页栏 + 页面 footer」预留的真实高度(列表直接顶到分页栏上沿) */
|
||||
const getFooterReservedHeight = (): number => {
|
||||
const bottom = Number.parseInt(paginationBottom.value, 10);
|
||||
const offset = Number.isFinite(bottom) ? bottom : 64;
|
||||
// 分页栏自身高度(首帧未挂载时按 34 估算)
|
||||
const paginationH = paginationRef.value?.offsetHeight || 34;
|
||||
return offset + paginationH + 6;
|
||||
};
|
||||
|
||||
/** 弹窗模式:给 modal 设定高度,使内容区撑满(内部 flex 生效) */
|
||||
const applyModalHeight = () => {
|
||||
const bodyEl = modalBodyEl.value;
|
||||
if (!bodyEl) return;
|
||||
const modalEl = bodyEl.closest(".ant-modal") as HTMLElement | null;
|
||||
if (!modalEl) return;
|
||||
const h = Math.max(window.innerHeight - 140, 240);
|
||||
modalEl.style.height = `${h}px`;
|
||||
};
|
||||
|
||||
/** 弹窗模式:让 modal body 变成纵向 flex 容器,分页贴底(modal footer 上方) */
|
||||
const setupModalLayout = async () => {
|
||||
if (!isModal.value) return;
|
||||
await nextTick();
|
||||
const bodyEl = searchRef.value?.closest(
|
||||
".ant-modal-body",
|
||||
) as HTMLElement | null;
|
||||
if (!bodyEl) return;
|
||||
modalBodyEl.value = bodyEl;
|
||||
bodyEl.classList.add("sc-modal-body");
|
||||
applyModalHeight();
|
||||
};
|
||||
|
||||
/** 统一重新布局 */
|
||||
const relayout = async () => {
|
||||
// 先定位分页栏 / 弹窗,等 DOM 位置生效后再反推列表高度,避免用到上一帧位置
|
||||
computePaginationBottom();
|
||||
applyModalHeight();
|
||||
await nextTick();
|
||||
computResultTableHeight();
|
||||
};
|
||||
|
||||
// 增加监听窗口宽高变化,窗口缩放自动重算
|
||||
watch([width, height, isModal, tableY], () => {
|
||||
void relayout();
|
||||
});
|
||||
|
||||
const HEIGHT_OFFSETS = {
|
||||
minTableHeight: 120, // 列表最小高度,防止计算结果为负
|
||||
};
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
/** 列表高度:优先用「列表顶部 → 分页栏顶部」的真实距离,保证滚动条紧贴页码 */
|
||||
const computResultTableHeight = () => {
|
||||
// 弹窗内高度由 flex 撑满,无需计算
|
||||
if (isModal.value) return;
|
||||
|
||||
const listEl = listRef.value;
|
||||
const paginationEl = paginationRef.value;
|
||||
|
||||
let finalY: number;
|
||||
|
||||
if (listEl && paginationEl) {
|
||||
// 两者同为视口坐标,其差值就是真实可用高度(与页面滚动无关)
|
||||
finalY =
|
||||
paginationEl.getBoundingClientRect().top -
|
||||
listEl.getBoundingClientRect().top -
|
||||
6;
|
||||
} else {
|
||||
// 兜底:DOM 尚未就绪时沿用估算方式
|
||||
const viewportH = window.innerHeight;
|
||||
const searchHeight = searchRef.value?.offsetHeight ?? 0;
|
||||
const settingHeight = settingRef.value?.offsetHeight ?? 0;
|
||||
const base = tableY.value > 0 ? tableY.value : settingHeight;
|
||||
finalY = viewportH - searchHeight - base - getFooterReservedHeight();
|
||||
}
|
||||
|
||||
// 最小高度兜底,不能小于minTableHeight
|
||||
finalY = Math.max(finalY, HEIGHT_OFFSETS.minTableHeight);
|
||||
resultH.value = `${finalY}px`; // 直接输出px,不再calc
|
||||
};
|
||||
|
||||
// 重新绑定observer,处理v‑if动态创建的setting DOM
|
||||
const rebindObserver = () => {
|
||||
if (!resizeObserver) return;
|
||||
// 先全部取消
|
||||
resizeObserver.disconnect();
|
||||
if (searchRef.value) resizeObserver.observe(searchRef.value);
|
||||
if (settingRef.value) resizeObserver.observe(settingRef.value);
|
||||
if (paginationRef.value) resizeObserver.observe(paginationRef.value);
|
||||
const footerEl = getPageFooter();
|
||||
if (footerEl) resizeObserver.observe(footerEl);
|
||||
|
||||
void relayout();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
void relayout();
|
||||
});
|
||||
rebindObserver();
|
||||
void setupModalLayout();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ✅监听v‑if条件变化,条件一变,重新绑定观测器
|
||||
watch([isModal, isSM], () => {
|
||||
rebindObserver();
|
||||
void setupModalLayout();
|
||||
});
|
||||
|
||||
watch([width, height, tableY], () => {
|
||||
void relayout();
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
name: "searchCard",
|
||||
});
|
||||
|
||||
const columns = ref(
|
||||
viewOption.value.Columns[0].BindColumn?.filter((x: any) => x.hidden != "Y"),
|
||||
);
|
||||
|
||||
//接收拖动变化
|
||||
const onChangeColumns = (cls: Column[]) => {
|
||||
columns.value = cls?.filter((x) => x.hidden != "Y");
|
||||
};
|
||||
|
||||
const onChangeViewOption = (vop: ViewOption) => {
|
||||
viewOption.value = vop;
|
||||
};
|
||||
|
||||
const onSelectColumn = () => {
|
||||
columns.value = viewOption.value.Columns.filter(
|
||||
(x: any) => x.BindValue == viewOption.value.Value,
|
||||
)[0].BindColumn?.filter((x: any) => x.hidden != "Y") as Column[];
|
||||
};
|
||||
|
||||
const onSubmit = async (filters: SearchFilter[]) => {
|
||||
emit("onSubmit", filters);
|
||||
};
|
||||
|
||||
const handleChangeBindValue = (bindValue: string, value: string) => {
|
||||
emit("handleChangeBindValue", bindValue, value);
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
datas.value = [];
|
||||
selected.value = [];
|
||||
emit("onReset");
|
||||
};
|
||||
|
||||
//================卡片列表数据================
|
||||
// 字段列(排除序号列)
|
||||
const fieldColumns = computed(() =>
|
||||
((columns.value ?? []) as any[]).filter((c: any) => c.dataIndex !== "index"),
|
||||
);
|
||||
|
||||
// 卡片标题使用的列
|
||||
const titleColumns = computed(() => {
|
||||
const keys = props.cardTitleKeys ?? [];
|
||||
if (keys.length) {
|
||||
return keys
|
||||
.map((k) => fieldColumns.value.find((c: any) => c.dataIndex === k))
|
||||
.filter(Boolean) as any[];
|
||||
}
|
||||
return fieldColumns.value.slice(0, 1);
|
||||
});
|
||||
|
||||
// 卡片主体展示的列:完整展示全部字段列(含用于标题的列),保证不丢列
|
||||
const allColumns = computed(() => fieldColumns.value);
|
||||
|
||||
// 折叠时展示的字段数量(手机端精简为 3 项,桌面端尽量全展示)
|
||||
const collapsedCount = computed(() => (isSM.value ? 3 : 9));
|
||||
|
||||
// 是否需要"展开/收起"
|
||||
const canExpand = computed(
|
||||
() => allColumns.value.length > collapsedCount.value,
|
||||
);
|
||||
|
||||
// 展开状态(按列表序号记录)
|
||||
const expandedSet = ref<Record<number, boolean>>({});
|
||||
|
||||
const isExpanded = (index: number | string) =>
|
||||
!!expandedSet.value[Number(index)];
|
||||
|
||||
const toggleExpand = (index: number | string) => {
|
||||
const key = Number(index);
|
||||
expandedSet.value = {
|
||||
...expandedSet.value,
|
||||
[key]: !expandedSet.value[key],
|
||||
};
|
||||
};
|
||||
|
||||
// 当前卡片需要渲染的列:折叠时取前 N 项,展开时取全部
|
||||
const getCardColumns = (index: number | string) => {
|
||||
if (!canExpand.value || isExpanded(index)) return allColumns.value;
|
||||
return allColumns.value.slice(0, collapsedCount.value);
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// 数据变化(查询/分页/排序)后重置展开状态
|
||||
watch(datas, () => {
|
||||
expandedSet.value = {};
|
||||
});
|
||||
|
||||
const getTitle = (record: any) => {
|
||||
const parts = titleColumns.value
|
||||
.map((c: any) => record?.[c.dataIndex])
|
||||
.filter((v: any) => v !== null && v !== undefined && v !== "");
|
||||
return parts.length ? parts.join(" ") : "-";
|
||||
};
|
||||
|
||||
const formatCell = (record: any, column: any) => {
|
||||
const v = record?.[column.dataIndex];
|
||||
return v === null || v === undefined || v === "" ? "-" : v;
|
||||
};
|
||||
|
||||
const getRowIndex = (index: number | string) =>
|
||||
(pageCurrent.value - 1) * pageSizeCurrent.value + Number(index) + 1;
|
||||
|
||||
//================选择================
|
||||
const isCheckbox = computed(() => unref(rowSelectionType) === "checkbox");
|
||||
|
||||
const isSelected = (record: any) => selected.value.includes(record);
|
||||
|
||||
const emitSelection = () => emit("onSelectChange", selected.value);
|
||||
|
||||
const onCardClick = (event: any, record: any) => {
|
||||
if (unref(rowSelectionType) == "radio") {
|
||||
selected.value = [record];
|
||||
} else {
|
||||
if (selected.value.includes(record)) {
|
||||
selected.value = selected.value.filter((item) => item !== record);
|
||||
} else {
|
||||
selected.value.push(record);
|
||||
}
|
||||
}
|
||||
emit("onclick", event, [record]);
|
||||
emitSelection();
|
||||
};
|
||||
|
||||
const onCardDblclick = (event: any, record: any) => {
|
||||
if (selected.value.includes(record)) {
|
||||
selected.value = selected.value.filter((item) => item !== record);
|
||||
} else {
|
||||
selected.value.push(record);
|
||||
}
|
||||
emit("onRowDblclick", event, record);
|
||||
emitSelection();
|
||||
};
|
||||
|
||||
//================分页================
|
||||
const pageCurrent = ref<number>(paginationJ.value.Page || 1);
|
||||
const pageSizeCurrent = ref<number>(paginationJ.value.Limit || 20);
|
||||
|
||||
watch(
|
||||
() => [paginationJ.value.Page, paginationJ.value.Limit],
|
||||
() => {
|
||||
pageCurrent.value = paginationJ.value.Page || 1;
|
||||
pageSizeCurrent.value = paginationJ.value.Limit || 20;
|
||||
},
|
||||
);
|
||||
|
||||
const showTotal = (total: number) => `共 ${total} 条`;
|
||||
|
||||
const onPageChange = (page: number, pageSize: number) => {
|
||||
pageCurrent.value = page;
|
||||
pageSizeCurrent.value = pageSize;
|
||||
run({ page, pageSize });
|
||||
};
|
||||
|
||||
const queryData = async (params: PagingParams): Promise<any> => {
|
||||
try {
|
||||
emit("usePagination", params);
|
||||
} catch (err) {}
|
||||
|
||||
return datas.value;
|
||||
};
|
||||
|
||||
const { run } = usePagination(queryData, {
|
||||
pagination: {
|
||||
currentKey: "page",
|
||||
pageSizeKey: "pageSize",
|
||||
totalKey: "totalCount",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.search-criteria .ant-select-single .ant-select-selector {
|
||||
border-top-left-radius: 0pt 0pt;
|
||||
border-bottom-left-radius: 0pt 0pt;
|
||||
border-top-right-radius: 5pt 5pt;
|
||||
border-bottom-right-radius: 5pt 5pt;
|
||||
border-left: 0 !important;
|
||||
}
|
||||
|
||||
.search-criteria .ant-select-multiple .ant-select-selector {
|
||||
border-top-left-radius: 0pt 0pt;
|
||||
border-bottom-left-radius: 0pt 0pt;
|
||||
border-top-right-radius: 5pt 5pt;
|
||||
border-bottom-right-radius: 5pt 5pt;
|
||||
border-left: 0 !important;
|
||||
}
|
||||
|
||||
.search-criteria .ant-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.setting .ant-select-selector {
|
||||
border-radius: 0;
|
||||
border-left: 0 !important;
|
||||
}
|
||||
|
||||
.setting .ant-btn {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
z-index: 999;
|
||||
padding-left: 8px;
|
||||
padding-right: 18px;
|
||||
padding-top: 8px;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
bottom: 8px;
|
||||
border-top: 1px solid silver;
|
||||
}
|
||||
|
||||
/* ==================== 结果区:卡片列表 ==================== */
|
||||
.sc-list {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
padding: 2px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.sc-list-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sc-card {
|
||||
border: 1px solid #eef0f4;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.sc-card-selected {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.sc-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eef0f4;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.sc-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sc-index {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 11px;
|
||||
background: #e8f1ff;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sc-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sc-card-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.sc-card-body {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.sc-card-body {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.sc-field {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sc-field-label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 42%;
|
||||
font-size: 12px;
|
||||
color: #8a919f;
|
||||
}
|
||||
|
||||
.sc-field-label::after {
|
||||
content: ":";
|
||||
}
|
||||
|
||||
.sc-field-value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
color: #1f2329;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 自定义插槽有渲染内容时,隐藏默认文本 */
|
||||
.sc-slot:not(:empty) + .sc-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sc-card-more {
|
||||
padding: 0 12px 8px 12px;
|
||||
text-align: right;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sc-more-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sc-more-btn:hover {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.sc-empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.sc-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
/* 普通页面:分页栏固定在页面 footer 上方(bottom 由内联 style 动态覆盖) */
|
||||
.sc-pagination-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px;
|
||||
z-index: 998;
|
||||
padding: 6px 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eef0f4;
|
||||
}
|
||||
|
||||
/* 弹窗模式:内容区撑满 modal body,分页贴在 modal footer 上方 */
|
||||
.sc-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.sc-modal-body > * {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sc-modal-body > .sc-result {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sc-modal-body > .sc-result > .sc-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.sc-modal-body > .sc-result > .sc-pagination {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eef0f4;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.sc-pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sc-pagination-fixed {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -139,16 +139,56 @@
|
||||
<a-button size="small" type="default" @click="viewMode = 'search'">
|
||||
<cust-icon-font size="14" type="icon-sousuo" />
|
||||
</a-button>
|
||||
<a-button size="small" type="default" @click="">
|
||||
<a-button
|
||||
size="small"
|
||||
type="default"
|
||||
title="使用帮助"
|
||||
@click="helpVisible = true"
|
||||
>
|
||||
<cust-icon-font size="14" type="icon-help" />
|
||||
</a-button>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="0" :sm="0" :lg="1" flex="none">
|
||||
<div
|
||||
v-if="viewMode == 'list'"
|
||||
style="
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
style="height: 32px"
|
||||
size="small"
|
||||
type="default"
|
||||
title="使用帮助"
|
||||
@click="helpVisible = true"
|
||||
>
|
||||
<cust-icon-font size="16" type="icon-help" />
|
||||
</a-button>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 使用帮助弹窗:内容默认取「我的云盘」页面说明,父组件可用 #help 插槽覆盖 -->
|
||||
<!-- 宽度不写死:跟随 DragModal 的默认宽度(视口 90%) -->
|
||||
<dragModal
|
||||
v-model:open="helpVisible"
|
||||
title="我的云盘 · 使用帮助"
|
||||
:footer="null"
|
||||
:fullscreen="width < 640"
|
||||
>
|
||||
<slot name="help">
|
||||
<DmsFileEnquiryHelp />
|
||||
</slot>
|
||||
</dragModal>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import {
|
||||
HomeOutlined,
|
||||
ArrowLeftOutlined,
|
||||
@@ -157,6 +197,8 @@ import {
|
||||
FolderOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import dragModal from "@/components/basic/DragModal.vue";
|
||||
import DmsFileEnquiryHelp from "@/components/basic/DmsFileEnquiryHelp.vue";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
value: string;
|
||||
@@ -181,6 +223,12 @@ const searchKeyword = ref("");
|
||||
|
||||
const viewMode = ref<"search" | "list">("list");
|
||||
|
||||
/** 使用帮助弹窗显示状态 */
|
||||
const helpVisible = ref(false);
|
||||
|
||||
/** 小屏(手机)下帮助弹窗全屏显示 */
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const canGoTop = computed(() => {
|
||||
return props.pathStack.length > 1;
|
||||
});
|
||||
|
||||
@@ -56,8 +56,9 @@ const getPolicyText = (options: PolicyOption[], value?: string | null) => {
|
||||
const genderLabel = (g?: string) => getPolicyText(genderOptions, g);
|
||||
const relationLabel = (r?: string) => getPolicyText(relationTypeOptions, r);
|
||||
|
||||
/** 性别为空时默认按男性处理 */
|
||||
const isMale = (g?: string) => {
|
||||
if (!g) return undefined;
|
||||
if (!g) return true;
|
||||
const t = (genderLabel(g) ?? "").toLowerCase();
|
||||
return t.includes("男") || t.includes("male") || g === "M" || g === "1";
|
||||
};
|
||||
@@ -84,12 +85,21 @@ const esc = (v?: string | null) =>
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
/** 无头像时的默认头像图标(白底 + 性别色人像:男蓝 / 女粉) */
|
||||
const USER_ICON_SVG =
|
||||
'<svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M858.5 763.6a374 374 0 00-80.6-119.5 375.63 375.63 0 00-119.5-80.6c-.4-.2-.8-.3-1.2-.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-.4.2-.8.3-1.2.5-44.8 18.9-85 46-119.5 80.6a375.63 375.63 0 00-80.6 119.5A371.7 371.7 0 00136 901.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c.1 4.4 3.6 7.8 8 7.8h60a8 8 0 008-8.2c-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z"></path></svg>';
|
||||
|
||||
/** 自定义人员卡片 HTML */
|
||||
const renderCard = (d: any) => {
|
||||
const nd = d?.data ?? {};
|
||||
const male = isMale(nd.gender);
|
||||
const theme = nd.kind === "self" ? "#fa8c16" : male === false ? "#eb2f96" : "#1890ff";
|
||||
const ico = male === undefined ? "" : male ? "♂" : "♀";
|
||||
const theme = nd.kind === "self" ? "#fa8c16" : male ? "#1890ff" : "#eb2f96";
|
||||
// 无头像占位图标颜色:男蓝 / 女粉(白底)
|
||||
const avatarColor = male ? "#1890ff" : "#eb2f96";
|
||||
// 有头像显示头像,无头像显示默认头像(白底 + 性别色人像图标)
|
||||
const avatar = nd.avatarUrl
|
||||
? `<img class="ftc-avatar" src="${esc(nd.avatarUrl)}" alt="" onerror="this.style.visibility='hidden'" />`
|
||||
: "";
|
||||
const common = nd.commonName
|
||||
? `<div class="ftc-sub">常用名:${esc(nd.commonName)}</div>`
|
||||
: "";
|
||||
@@ -99,11 +109,15 @@ const renderCard = (d: any) => {
|
||||
const clickable = nd.hasChildren ? "ftc-clickable" : "";
|
||||
return `
|
||||
<div class="ftc-card ${clickable}" style="border-top: 3px solid ${theme};">
|
||||
<div class="ftc-head">
|
||||
<span class="ftc-ico" style="color:${theme};">${ico}</span>
|
||||
<span class="ftc-name" title="${esc(nd.name)}">${esc(nd.name)}</span>
|
||||
<div class="ftc-main">
|
||||
<div class="ftc-avatar-wrap" style="border-color:${theme};">
|
||||
<span class="ftc-avatar-ph" style="color:${avatarColor};">${USER_ICON_SVG}</span>${avatar}
|
||||
</div>
|
||||
<div class="ftc-info">
|
||||
<div class="ftc-name" title="${esc(nd.name)}">${esc(nd.name)}</div>
|
||||
${common}
|
||||
</div>
|
||||
</div>
|
||||
${common}
|
||||
<div class="ftc-footer">
|
||||
<div class="ftc-tag" style="color:${theme}; background:${theme}1a;">${esc(nd.tag)}</div>
|
||||
${actionTag}
|
||||
@@ -123,6 +137,7 @@ const ensurePerson = (id: string, info: any) => {
|
||||
if (!old.Surname && info.Surname) old.Surname = info.Surname;
|
||||
if (!old.GenealogyName && info.GenealogyName) old.GenealogyName = info.GenealogyName;
|
||||
if (!old.CommonName && info.CommonName) old.CommonName = info.CommonName;
|
||||
if (!old.AvatarUrl && info.AvatarUrl) old.AvatarUrl = info.AvatarUrl;
|
||||
};
|
||||
|
||||
/** 追加配偶(按 id 去重) */
|
||||
@@ -151,6 +166,7 @@ const buildIndex = (resp: FamilyTreeData) => {
|
||||
GenealogyName: p.GenealogyName,
|
||||
CommonName: p.CommonName,
|
||||
Gender: p.Gender,
|
||||
AvatarUrl: p.AvatarUrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -162,6 +178,7 @@ const buildIndex = (resp: FamilyTreeData) => {
|
||||
GenealogyName: ancestor.GenealogyName,
|
||||
CommonName: ancestor.CommonName,
|
||||
Gender: ancestor.Gender,
|
||||
AvatarUrl: ancestor.AvatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,7 +249,7 @@ const buildGraphData = () => {
|
||||
const root = rootPersonId.value;
|
||||
if (!root) return { nodes, edges };
|
||||
|
||||
const Y_STEP = 116;
|
||||
const Y_STEP = 144;
|
||||
let cursor = 0;
|
||||
const visited = new Set<string>();
|
||||
|
||||
@@ -261,6 +278,7 @@ const buildGraphData = () => {
|
||||
name: (info.Surname ?? "") + (info.GenealogyName ?? ""),
|
||||
commonName: info.CommonName,
|
||||
gender: info.Gender,
|
||||
avatarUrl: info.AvatarUrl ?? "",
|
||||
hasChildren,
|
||||
expanded,
|
||||
loaded: true,
|
||||
@@ -269,9 +287,9 @@ const buildGraphData = () => {
|
||||
|
||||
// 配偶与本人同一行,水平间隔 200 对齐;
|
||||
// 有孩子的配偶作为成员节点展开其子树,其余渲染为简单配偶卡片
|
||||
const SPOUSE_GAP = 200;
|
||||
const SPOUSE_GAP = 250;
|
||||
spouses.forEach((s: any, i: number) => {
|
||||
const sx = x + 210 + i * SPOUSE_GAP;
|
||||
const sx = x + 260 + i * SPOUSE_GAP;
|
||||
const hasSub = (childrenOf.get(s.id) ?? []).length > 0;
|
||||
if (hasSub) {
|
||||
walk(s.id, depth + 1, sx);
|
||||
@@ -283,10 +301,12 @@ const buildGraphData = () => {
|
||||
style: { x: sx, y },
|
||||
data: {
|
||||
kind: "spouse",
|
||||
personId: s.id,
|
||||
tag: "配偶",
|
||||
name: s.name,
|
||||
commonName: s.commonName,
|
||||
gender: s.gender ?? personMap.get(s.id)?.Gender,
|
||||
avatarUrl: personMap.get(s.id)?.AvatarUrl ?? "",
|
||||
loaded: true,
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
@@ -297,8 +317,8 @@ const buildGraphData = () => {
|
||||
});
|
||||
|
||||
if (expanded) {
|
||||
// 孩子列与本人第一行的配偶列对齐(x + 210),即孩子位于配偶正下方,竖直跟第一行对齐
|
||||
const childBaseX = x + 210;
|
||||
// 孩子列与本人第一行的配偶列对齐(x + 260),即孩子位于配偶正下方,竖直跟第一行对齐
|
||||
const childBaseX = x + 260;
|
||||
children.forEach((c: any) => {
|
||||
walk(c.id, depth + 1, childBaseX);
|
||||
edges.push({ source: "n-" + personId, target: "n-" + c.id, data: { kind: "parent" } });
|
||||
@@ -314,7 +334,7 @@ const buildGraphData = () => {
|
||||
nodes.forEach((n: any) => {
|
||||
if (n.style?.x > maxX) maxX = n.style.x;
|
||||
});
|
||||
const orphanX = maxX + 210;
|
||||
const orphanX = maxX + 260;
|
||||
personMap.forEach((_, pid) => {
|
||||
if (visited.has(pid) || pid === root) return;
|
||||
const children = [...(childrenOf.get(pid) ?? [])].sort((a, b) => (a.orderby ?? 0) - (b.orderby ?? 0));
|
||||
@@ -374,9 +394,9 @@ const initGraph = async () => {
|
||||
node: {
|
||||
type: "html",
|
||||
style: {
|
||||
size: [180, 96],
|
||||
dx: -90,
|
||||
dy: -48,
|
||||
size: [220, 120],
|
||||
dx: -110,
|
||||
dy: -60,
|
||||
innerHTML: renderCard as any,
|
||||
},
|
||||
},
|
||||
@@ -525,12 +545,54 @@ const onCancel = () => {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ftc-ico {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
/* 人员卡片:左侧头像 + 右侧姓名信息 */
|
||||
.ftc-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ftc-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ftc-avatar-wrap {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 无头像占位:白底 + 性别色人像图标(男蓝 / 女粉,颜色由内联 style 指定) */
|
||||
.ftc-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #1890ff;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 头像图片绝对定位覆盖占位图标,加载失败时隐藏,露出下方默认头像 */
|
||||
.ftc-avatar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ftc-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -57,7 +57,9 @@ const getPolicyText = (options: PolicyOption[], value?: string | null) => {
|
||||
const genderLabel = (g?: string) => getPolicyText(genderOptions, g);
|
||||
const relationLabel = (r?: string) => getPolicyText(relationTypeOptions, r);
|
||||
|
||||
/** 性别为空时默认按男性处理 */
|
||||
const isMale = (g?: string) => {
|
||||
if (!g) return true;
|
||||
const t = (genderLabel(g) ?? "").toLowerCase();
|
||||
return t.includes("男") || t.includes("male") || g === "M" || g === "1";
|
||||
};
|
||||
@@ -66,6 +68,21 @@ const hasPerson = computed(() => !!props.data?.CurrentPerson?.FamilyPerson);
|
||||
|
||||
const rootPersonId = computed(() => props.data?.CurrentPerson?.FamilyPerson?.PersonId ?? "");
|
||||
|
||||
/** personId -> 头像访问地址(本人 / 全族人员 / 已加载族员) */
|
||||
const avatarMap = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
const root: any = props.data?.CurrentPerson?.FamilyPerson;
|
||||
if (root?.PersonId && root?.AvatarUrl) map[root.PersonId] = root.AvatarUrl;
|
||||
((props.data?.CurrentPerson?.FamilyTreePersons ?? []) as any[]).forEach((p: any) => {
|
||||
if (p?.PersonId && p?.AvatarUrl) map[p.PersonId] = p.AvatarUrl;
|
||||
});
|
||||
Object.values((props.loadedNodes ?? {}) as Record<string, any>).forEach((n: any) => {
|
||||
const p = n?.person;
|
||||
if (p?.PersonId && p?.AvatarUrl) map[p.PersonId] = p.AvatarUrl;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
/** HTML 字符串转义,防止人名中的特殊字符破坏卡片结构 */
|
||||
const esc = (v?: string | null) =>
|
||||
(v ?? "")
|
||||
@@ -74,13 +91,22 @@ const esc = (v?: string | null) =>
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
/** 无头像时的默认头像图标(白底 + 性别色人像:男蓝 / 女粉) */
|
||||
const USER_ICON_SVG =
|
||||
'<svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M858.5 763.6a374 374 0 00-80.6-119.5 375.63 375.63 0 00-119.5-80.6c-.4-.2-.8-.3-1.2-.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-.4.2-.8.3-1.2.5-44.8 18.9-85 46-119.5 80.6a375.63 375.63 0 00-80.6 119.5A371.7 371.7 0 00136 901.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c.1 4.4 3.6 7.8 8 7.8h60a8 8 0 008-8.2c-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z"></path></svg>';
|
||||
|
||||
/** 自定义人员卡片 HTML */
|
||||
const renderCard = (d: any) => {
|
||||
const data = d?.data ?? {};
|
||||
const male = isMale(data.gender);
|
||||
// 本人=橙色,男性=蓝色,女性=粉色
|
||||
const theme = data.kind === "self" ? "#fa8c16" : male ? "#1890ff" : "#eb2f96";
|
||||
const ico = male ? "♂" : "♀";
|
||||
// 无头像占位图标颜色:男蓝 / 女粉(白底)
|
||||
const avatarColor = male ? "#1890ff" : "#eb2f96";
|
||||
// 有头像显示头像,无头像显示默认头像(白底 + 性别色人像图标)
|
||||
const avatar = data.avatarUrl
|
||||
? `<img class="ftc-avatar" src="${esc(data.avatarUrl)}" alt="" onerror="this.style.visibility='hidden'" />`
|
||||
: "";
|
||||
const common = data.commonName
|
||||
? `<div class="ftc-sub">常用名:${esc(data.commonName)}</div>`
|
||||
: "";
|
||||
@@ -92,11 +118,15 @@ const renderCard = (d: any) => {
|
||||
const clickable = data.hasChildren || !data.loaded ? "ftc-clickable" : "";
|
||||
return `
|
||||
<div class="ftc-card ftc-card-lg ${clickable}" style="border-top: 3px solid ${theme};">
|
||||
<div class="ftc-head">
|
||||
<span class="ftc-ico" style="color:${theme};">${ico}</span>
|
||||
<span class="ftc-name" title="${esc(data.name)}">${esc(data.name)}</span>
|
||||
<div class="ftc-main">
|
||||
<div class="ftc-avatar-wrap" style="border-color:${theme};">
|
||||
<span class="ftc-avatar-ph" style="color:${avatarColor};">${USER_ICON_SVG}</span>${avatar}
|
||||
</div>
|
||||
<div class="ftc-info">
|
||||
<div class="ftc-name" title="${esc(data.name)}">${esc(data.name)}</div>
|
||||
${common}
|
||||
</div>
|
||||
</div>
|
||||
${common}
|
||||
<div class="ftc-footer">
|
||||
<div class="ftc-tag" style="color:${theme}; background:${theme}1a;">${esc(data.tag)}</div>
|
||||
${actionTag}
|
||||
@@ -139,7 +169,7 @@ const buildGraphData = () => {
|
||||
if (!root) return { nodes, edges };
|
||||
|
||||
const CARD_WIDTH = 360;
|
||||
const CARD_HEIGHT = 192;
|
||||
const CARD_HEIGHT = 220;
|
||||
const H_GAP = 60;
|
||||
const V_GAP = 40;
|
||||
const X_STEP = CARD_WIDTH + H_GAP; // 420,避免水平重叠
|
||||
@@ -169,6 +199,7 @@ const buildGraphData = () => {
|
||||
name: (p?.Surname ?? "") + (p?.GenealogyName ?? ""),
|
||||
commonName: p?.CommonName,
|
||||
gender: p?.Gender,
|
||||
avatarUrl: p?.AvatarUrl ?? avatarMap.value[personId] ?? "",
|
||||
hasChildren,
|
||||
expanded,
|
||||
loaded: desc.loaded,
|
||||
@@ -183,9 +214,11 @@ const buildGraphData = () => {
|
||||
style: { x: xBase + X_STEP + i * SPOUSE_GAP, y },
|
||||
data: {
|
||||
kind: "spouse",
|
||||
personId: s.SpouseId,
|
||||
tag: "配偶",
|
||||
name: (s.SpouseSurname ?? "") + (s.SpouseGenealogyName ?? ""),
|
||||
gender: s.Gender,
|
||||
avatarUrl: avatarMap.value[s.SpouseId] ?? "",
|
||||
loaded: true,
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
@@ -213,6 +246,7 @@ const buildGraphData = () => {
|
||||
name: (c.ChildrenSurname ?? "") + (c.ChildrenGenealogyName ?? ""),
|
||||
commonName: c.ChildrenCommonName,
|
||||
gender: c.Gender,
|
||||
avatarUrl: avatarMap.value[childId] ?? "",
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
loaded: false,
|
||||
@@ -280,9 +314,9 @@ const initGraph = async () => {
|
||||
node: {
|
||||
type: "html",
|
||||
style: {
|
||||
size: [360, 192],
|
||||
size: [360, 220],
|
||||
dx: -180,
|
||||
dy: -96,
|
||||
dy: -110,
|
||||
// 官方文档推荐函数形式,类型声明仅支持 string/HTMLElement,故断言
|
||||
innerHTML: renderCard as any,
|
||||
},
|
||||
@@ -443,10 +477,6 @@ const onCancel = () => {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ftc-card-lg .ftc-ico {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.ftc-card-lg .ftc-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -467,12 +497,63 @@ const onCancel = () => {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ftc-ico {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
/* 人员卡片:左侧头像 + 右侧姓名信息 */
|
||||
.ftc-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ftc-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ftc-avatar-wrap {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ftc-card-lg .ftc-avatar-wrap {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
}
|
||||
|
||||
/* 无头像占位:白底 + 性别色人像图标(男蓝 / 女粉,颜色由内联 style 指定) */
|
||||
.ftc-avatar-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #1890ff;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ftc-card-lg .ftc-avatar-ph {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
/* 头像图片绝对定位覆盖占位图标,加载失败时隐藏,露出下方默认头像 */
|
||||
.ftc-avatar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ftc-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
Reference in new issue
Block a user