20260802220544
This commit is contained in:
1 parent
c00329b5a4
commit
4fc9040a8a
60 files changed
+6273
-908
No files matched your search
@@ -0,0 +1,277 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { Trash2, Download, Eye, MoreHorizontal } from "@lucide/vue";
|
||||
import { getPreviewUrl } from "@/services/fileService";
|
||||
import {
|
||||
extFromName,
|
||||
extColor,
|
||||
isImageType,
|
||||
formatSize,
|
||||
} from "./fileVisual";
|
||||
|
||||
const props = defineProps({
|
||||
file: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select", "preview", "download", "delete"]);
|
||||
|
||||
const isPending = computed(() => !!props.file?._pending);
|
||||
const isImage = computed(() => isImageType(props.file?.mx_filename || ""));
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (!isImage.value) return "";
|
||||
return props.file?._blobUrl || getPreviewUrl(props.file) || "";
|
||||
});
|
||||
|
||||
const extname = computed(() => extFromName(props.file?.mx_filename || ""));
|
||||
const extnameUpper = computed(() => extname.value.toUpperCase() || "?");
|
||||
const typeColor = computed(() => extColor(props.file?.mx_filename || ""));
|
||||
|
||||
function onCheck(e) {
|
||||
emit("select", props.file, e.target.checked);
|
||||
}
|
||||
|
||||
function onCardClick() {
|
||||
if (!isPending.value) emit("preview", props.file);
|
||||
}
|
||||
|
||||
function onDownload() {
|
||||
if (!isPending.value) emit("download", props.file);
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
emit("delete", props.file);
|
||||
}
|
||||
|
||||
function onMoreClick(info) {
|
||||
if (info?.key === "preview" && !isPending.value) emit("preview", props.file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fcard"
|
||||
:class="{ 'fcard--selected': selected, 'fcard--pending': isPending }"
|
||||
:title="file.mx_filename"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<!-- 缩略图区域 -->
|
||||
<div class="fcard__thumb">
|
||||
<img v-if="isImage && thumbnailUrl" :src="thumbnailUrl" alt="" />
|
||||
<div v-else class="fcard__type" :style="{ '--c': typeColor }">
|
||||
{{ extnameUpper }}
|
||||
</div>
|
||||
|
||||
<!-- 左上 checkbox(始终可见) -->
|
||||
<span class="fcard__check" @click.stop>
|
||||
<a-checkbox :checked="selected" @change="onCheck" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<div class="fcard__info">
|
||||
<div class="fcard__name">
|
||||
<span class="fcard__title" :title="file.mx_filename">{{
|
||||
file.mx_filename
|
||||
}}</span>
|
||||
<a-tag v-if="isPending" class="fcard__tag">待上传</a-tag>
|
||||
</div>
|
||||
<div class="fcard__meta">{{ formatSize(file.mx_filesize) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏:图标按钮(无文字,语言无关,避免多语言下文字长度撑破布局) -->
|
||||
<div class="fcard__footer" @click.stop>
|
||||
<a-button type="text" size="small" :disabled="isPending" @click="onDownload">
|
||||
<template #icon><Download :size="15" /></template>
|
||||
</a-button>
|
||||
<span class="fcard__divider" />
|
||||
<a-button type="text" size="small" danger @click="onDelete">
|
||||
<template #icon><Trash2 :size="15" /></template>
|
||||
</a-button>
|
||||
<span class="fcard__divider" />
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small" :disabled="isPending">
|
||||
<template #icon><MoreHorizontal :size="15" /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="onMoreClick">
|
||||
<a-menu-item key="preview">
|
||||
<Eye :size="13" /> 预览
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fcard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 6px;
|
||||
background: var(--fms-surface);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
&--selected {
|
||||
border-color: var(--fms-primary);
|
||||
}
|
||||
|
||||
&--pending {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* 缩略图 */
|
||||
.fcard__thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 84px;
|
||||
background: var(--fms-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/* 彩色类型方块(签名元素) */
|
||||
.fcard__type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 32%, #fff);
|
||||
}
|
||||
|
||||
/* checkbox:直接浮在缩略图上,不加底块/阴影,保持轻量 */
|
||||
.fcard__check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 3;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 常驻操作栏:下载 / 删除 / 更多 */
|
||||
.fcard__footer {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
background: var(--fms-surface);
|
||||
|
||||
:deep(.ant-btn) {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
/* 预留 1px 透明边框,保证 hover 时宽度恒定,不再因边框出现而收缩 */
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
color: var(--fms-text-secondary);
|
||||
/* 仅过渡颜色,避免 hover 时因 all 过渡引发的闪烁/位移 */
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
/* 关闭 antdv 按钮自带的波纹伪元素,消除 hover/点击时的边框状残影与闪烁 */
|
||||
&::before,
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--fms-primary);
|
||||
background: var(--fms-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous:hover:not(:disabled) {
|
||||
color: var(--fms-danger);
|
||||
background: color-mix(in srgb, var(--fms-danger) 10%, #fff);
|
||||
}
|
||||
|
||||
/* 明确的禁用态:覆盖 antdv 默认 + 上面无条件 color 覆盖,
|
||||
让 pending 时的下载/更多按钮图标明显变淡灰、鼠标显示禁止手势,
|
||||
lucide 图标用 currentColor 自动跟随。 */
|
||||
&:disabled,
|
||||
&[disabled] {
|
||||
color: var(--fms-disabled);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作栏按钮间的竖向分隔线:独立元素,不依赖按钮类名 / 禁用态,
|
||||
避免「更多」按钮被 a-dropdown 包裹且禁用时 border 被抹掉导致分隔线消失。 */
|
||||
.fcard__divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: var(--fms-border);
|
||||
}
|
||||
|
||||
/* 文件信息 */
|
||||
.fcard__info {
|
||||
padding: 7px 10px 9px;
|
||||
}
|
||||
|
||||
.fcard__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fcard__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fms-text);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fcard__tag {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--fms-warning);
|
||||
background: color-mix(in srgb, var(--fms-warning) 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--fms-warning) 35%, #fff);
|
||||
}
|
||||
|
||||
.fcard__meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--fms-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import FmsTree from "@/components/FmsTree.vue";
|
||||
|
||||
const props = defineProps({
|
||||
treeData: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
selectedKey: { type: String, default: "" },
|
||||
expandedKeys: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select", "update:expandedKeys"]);
|
||||
|
||||
const selectedKeys = computed(() =>
|
||||
props.selectedKey ? [props.selectedKey] : [],
|
||||
);
|
||||
|
||||
function onSelect(keys, info) {
|
||||
if (info?.node?.isFmsRoot) {
|
||||
emit("select", "");
|
||||
return;
|
||||
}
|
||||
emit("select", keys.length ? String(keys[0]) : "");
|
||||
}
|
||||
|
||||
function onExpand(keys) {
|
||||
emit("update:expandedKeys", keys);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="fc-panel">
|
||||
<div class="fc-panel__tree">
|
||||
<FmsTree
|
||||
:tree-data="treeData"
|
||||
:selected-keys="selectedKeys"
|
||||
:expanded-keys="expandedKeys"
|
||||
:block-node="true"
|
||||
:virtual="true"
|
||||
@select="onSelect"
|
||||
@update:expanded-keys="onExpand"
|
||||
/>
|
||||
<div v-if="loading" class="fc-panel__loading">
|
||||
<a-spin size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fc-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--fms-surface, #fff);
|
||||
}
|
||||
|
||||
.fc-panel__tree {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.fc-panel__tree :deep(.ant-tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.fc-panel__tree :deep(.ant-tree-node-content-wrapper) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fc-panel__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,808 @@
|
||||
<script setup>
|
||||
import { h, ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import { Download, Trash2, FolderOpen, Upload, LayoutGrid, List } from "@lucide/vue";
|
||||
import { pageDataApi } from "@/services/api";
|
||||
import { upload as fileUpload, removeFile, getPreviewUrl } from "@/services/fileService";
|
||||
import FileCard from "./FileCard.vue";
|
||||
import {
|
||||
extFromName,
|
||||
extColor,
|
||||
isImageType,
|
||||
formatSize,
|
||||
} from "./fileVisual";
|
||||
|
||||
const props = defineProps({
|
||||
moduleCode: { type: [String, Number], default: "" },
|
||||
moduleId: { type: String, default: "" },
|
||||
father: { type: [String, Number], required: true },
|
||||
cateId: { type: String, default: "" },
|
||||
readonly: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["preview", "uploaded", "deleted"]);
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const files = ref([]);
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const uploading = ref(false);
|
||||
const selectedFileIds = ref(new Set());
|
||||
|
||||
// 视图模式
|
||||
const viewMode = ref("grid"); // 'grid' | 'list'
|
||||
|
||||
// 模式切换:仅图标(卡片 / 列表),使用 @lucide/vue 图标
|
||||
const modeOptions = [
|
||||
{ value: "grid", icon: h(LayoutGrid, { size: 16 }) },
|
||||
{ value: "list", icon: h(List, { size: 16 }) },
|
||||
];
|
||||
|
||||
// 列表模式:表格滚动高度自适应
|
||||
const tableWrapRef = ref(null);
|
||||
const tableScrollY = ref(300);
|
||||
let resizeObserver = null;
|
||||
|
||||
function updateScrollY() {
|
||||
const el = tableWrapRef.value;
|
||||
if (!el || el.clientHeight === 0) return;
|
||||
tableScrollY.value = Math.max(80, el.clientHeight - 40);
|
||||
}
|
||||
|
||||
const pendingFiles = ref([]);
|
||||
const pendingBlobUrls = ref([]);
|
||||
const uploadInputRef = ref(null);
|
||||
const mainRef = ref(null);
|
||||
const panelRef = ref(null);
|
||||
const isDragOver = ref(false);
|
||||
let dragCounter = 0;
|
||||
|
||||
const displayFiles = computed(() => {
|
||||
const pending = pendingFiles.value.map((f, i) => ({
|
||||
_pending: true,
|
||||
subid: `_pending_${i}`,
|
||||
mx_filename: f.name,
|
||||
mx_filesize: f.size,
|
||||
mx_fileext: extFromName(f.name),
|
||||
_blobUrl: pendingBlobUrls.value[i] || "",
|
||||
_rawFile: f,
|
||||
}));
|
||||
return [...pending, ...files.value];
|
||||
});
|
||||
|
||||
const hasFiles = computed(() => displayFiles.value.length > 0);
|
||||
const hasPending = computed(() => pendingFiles.value.length > 0);
|
||||
const uploadedFiles = computed(() => files.value);
|
||||
const uploadedCount = computed(() => files.value.length);
|
||||
|
||||
const selectedCount = computed(() => selectedFileIds.value.size);
|
||||
const allChecked = computed(
|
||||
() => hasFiles.value && selectedFileIds.value.size === displayFiles.value.length,
|
||||
);
|
||||
const indeterminate = computed(
|
||||
() => selectedCount.value > 0 && !allChecked.value,
|
||||
);
|
||||
|
||||
// ---------- 列表模式 ----------
|
||||
const listColumns = computed(() => [
|
||||
{ title: "文件名", key: "mx_filename", dataIndex: "mx_filename", ellipsis: true },
|
||||
{ title: "大小", key: "mx_filesize", width: 88, align: "right" },
|
||||
{ title: "类型", key: "mx_fileext", width: 60, align: "center" },
|
||||
{ title: "操作", key: "action", width: 72, align: "center" },
|
||||
]);
|
||||
|
||||
const listRowSelection = computed(() => ({
|
||||
selectedRowKeys: [...selectedFileIds.value],
|
||||
onChange: (keys) => {
|
||||
selectedFileIds.value = new Set(keys);
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
function buildCondition() {
|
||||
const f = props.father;
|
||||
let cond = `[father] = N'${String(f).replaceAll("'", "''")}'`;
|
||||
if (props.moduleId) {
|
||||
cond += ` AND [mx_moduleid] = N'${String(props.moduleId).replaceAll("'", "''")}'`;
|
||||
}
|
||||
if (props.cateId) {
|
||||
cond += ` AND [mx_cate_id] = N'${String(props.cateId).replaceAll("'", "''")}'`;
|
||||
}
|
||||
return cond;
|
||||
}
|
||||
|
||||
function normalizeFile(row) {
|
||||
return {
|
||||
...row,
|
||||
subid: String(row.subid),
|
||||
father: String(row.father),
|
||||
mx_moduleid: row.mx_moduleid ? String(row.mx_moduleid) : "",
|
||||
mx_cate_id: row.mx_cate_id ? String(row.mx_cate_id) : "",
|
||||
mx_filesize: Number(row.mx_filesize || 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFiles(page = 1) {
|
||||
currentPage.value = page;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await pageDataApi({
|
||||
viewName: "bf_files",
|
||||
orderBy: "mx_xh ASC, subid DESC",
|
||||
searchCondition: buildCondition(),
|
||||
pageNo: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
files.value = (res.data?.rows || []).map(normalizeFile);
|
||||
total.value = res.data?.total || 0;
|
||||
} catch {
|
||||
files.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 选择逻辑 ----------
|
||||
function toggleAll() {
|
||||
if (allChecked.value) {
|
||||
selectedFileIds.value = new Set();
|
||||
} else {
|
||||
selectedFileIds.value = new Set(displayFiles.value.map((f) => f.subid));
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelect(file, checked) {
|
||||
const sid = file.subid;
|
||||
const next = new Set(selectedFileIds.value);
|
||||
if (checked) {
|
||||
next.add(sid);
|
||||
} else {
|
||||
next.delete(sid);
|
||||
}
|
||||
selectedFileIds.value = next;
|
||||
}
|
||||
|
||||
// ---------- 上传 ----------
|
||||
function triggerSelect() {
|
||||
uploadInputRef.value?.click();
|
||||
}
|
||||
|
||||
function onSelectChange(e) {
|
||||
const selected = e.target?.files;
|
||||
if (!selected?.length) return;
|
||||
addFiles(selected);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function addFiles(fileList) {
|
||||
// 允许重复上传同一文件:不再与已加入队列的文件做去重比较。
|
||||
// 拖拽重复入栈的问题已由 dragenter/dragleave 的 currentTarget 判断解决。
|
||||
for (const f of fileList) {
|
||||
pendingFiles.value.push(f);
|
||||
if (isImageType(f.name)) {
|
||||
pendingBlobUrls.value.push(URL.createObjectURL(f));
|
||||
} else {
|
||||
pendingBlobUrls.value.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 拖拽上传 ----------
|
||||
function handleDragEnter(e) {
|
||||
e.preventDefault();
|
||||
// 只在进入 fl-main 自身时计数,忽略子元素冒泡
|
||||
if (e.currentTarget !== e.target) return;
|
||||
dragCounter++;
|
||||
isDragOver.value = true;
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
// 只在离开 fl-main 自身时计数
|
||||
if (e.currentTarget !== e.target) return;
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
isDragOver.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
isDragOver.value = false;
|
||||
const dt = e.dataTransfer;
|
||||
if (dt?.files?.length) addFiles(dt.files);
|
||||
}
|
||||
|
||||
// ---------- 粘贴上传 ----------
|
||||
function handlePaste(e) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const fileList = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "file") {
|
||||
const f = item.getAsFile();
|
||||
if (f) fileList.push(f);
|
||||
}
|
||||
}
|
||||
if (fileList.length) addFiles(fileList);
|
||||
}
|
||||
|
||||
function revokeBlobUrls() {
|
||||
for (const url of pendingBlobUrls.value) {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
}
|
||||
pendingBlobUrls.value = [];
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
if (!pendingFiles.value.length) return;
|
||||
if (!props.moduleId) {
|
||||
message.warning("未解析到模块 ID,无法上传");
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
await fileUpload({
|
||||
files: [...pendingFiles.value],
|
||||
father: String(props.father),
|
||||
moduleId: props.moduleId,
|
||||
cateId: props.cateId || undefined,
|
||||
});
|
||||
message.success(`成功上传 ${pendingFiles.value.length} 个文件`);
|
||||
revokeBlobUrls();
|
||||
pendingFiles.value = [];
|
||||
emit("uploaded");
|
||||
await loadFiles(1);
|
||||
} catch (e) {
|
||||
message.error("上传失败:" + (e?.message || e));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function deleteSelected() {
|
||||
if (!selectedFileIds.value.size) return;
|
||||
const ids = [...selectedFileIds.value];
|
||||
const pendingIdxs = ids
|
||||
.filter((id) => String(id).startsWith("_pending_"))
|
||||
.map((id) => Number(String(id).replace("_pending_", "")))
|
||||
.sort((a, b) => b - a);
|
||||
const realIds = ids.filter((id) => !String(id).startsWith("_pending_"));
|
||||
Modal.confirm({
|
||||
title: `确定删除选中的 ${ids.length} 个文件?`,
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 待上传文件:直接从队列移除
|
||||
for (const idx of pendingIdxs) {
|
||||
if (pendingFiles.value[idx]) {
|
||||
if (pendingBlobUrls.value[idx]) URL.revokeObjectURL(pendingBlobUrls.value[idx]);
|
||||
pendingFiles.value.splice(idx, 1);
|
||||
pendingBlobUrls.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
// 已上传文件:调接口删除
|
||||
for (const id of realIds) {
|
||||
await removeFile(id);
|
||||
}
|
||||
message.success(`已删除 ${ids.length} 个文件`);
|
||||
selectedFileIds.value = new Set();
|
||||
emit("deleted");
|
||||
await loadFiles(currentPage.value);
|
||||
} catch (e) {
|
||||
message.error("删除失败:" + (e?.message || e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSingle(file) {
|
||||
if (file._pending) {
|
||||
const idx = pendingFiles.value.findIndex((f) => f === file._rawFile);
|
||||
if (idx >= 0) {
|
||||
if (pendingBlobUrls.value[idx]) URL.revokeObjectURL(pendingBlobUrls.value[idx]);
|
||||
pendingFiles.value.splice(idx, 1);
|
||||
pendingBlobUrls.value.splice(idx, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: `确定删除「${file.mx_filename}」?`,
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeFile(file.subid);
|
||||
selectedFileIds.value.delete(file.subid);
|
||||
emit("deleted");
|
||||
await loadFiles(currentPage.value);
|
||||
} catch (e) {
|
||||
message.error("删除失败:" + (e?.message || e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 下载 ----------
|
||||
function downloadSelected() {
|
||||
const selected = files.value.filter((f) => selectedFileIds.value.has(f.subid));
|
||||
for (const f of selected) {
|
||||
const url = getPreviewUrl(f);
|
||||
if (url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = f.mx_filename;
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSingle(file) {
|
||||
const url = getPreviewUrl(file);
|
||||
if (url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.mx_filename;
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 预览 ----------
|
||||
function onPreview(file) {
|
||||
emit("preview", file);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFiles();
|
||||
nextTick(() => {
|
||||
updateScrollY();
|
||||
if (tableWrapRef.value) {
|
||||
resizeObserver = new ResizeObserver(updateScrollY);
|
||||
resizeObserver.observe(tableWrapRef.value);
|
||||
}
|
||||
});
|
||||
document.addEventListener("paste", handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.father, props.moduleId, props.cateId],
|
||||
() => {
|
||||
selectedFileIds.value = new Set();
|
||||
revokeBlobUrls();
|
||||
pendingFiles.value = [];
|
||||
loadFiles(1);
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({ files, triggerSelect });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="panelRef" class="fl-panel">
|
||||
<!-- Header -->
|
||||
<div class="fl-header">
|
||||
<a-checkbox
|
||||
:checked="allChecked"
|
||||
:indeterminate="indeterminate"
|
||||
:disabled="!hasFiles"
|
||||
@change="toggleAll"
|
||||
>
|
||||
全选
|
||||
</a-checkbox>
|
||||
<a-button
|
||||
:disabled="!selectedCount"
|
||||
@click="downloadSelected"
|
||||
>
|
||||
<template #icon><Download :size="14" /></template>
|
||||
下载
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:disabled="!selectedCount"
|
||||
@click="deleteSelected"
|
||||
>
|
||||
<template #icon><Trash2 :size="14" /></template>
|
||||
删除
|
||||
</a-button>
|
||||
<div class="fl-header__spacer" />
|
||||
<a-segmented
|
||||
v-model:value="viewMode"
|
||||
class="fl-header__modes fms-seg-icon"
|
||||
:options="modeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Main:文件网格 / 列表 -->
|
||||
<div
|
||||
ref="mainRef"
|
||||
class="fl-main"
|
||||
:class="{ 'fl-main--dragover': isDragOver }"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<a-spin :spinning="loading" class="fl-spin">
|
||||
<div v-if="hasFiles">
|
||||
<!-- 卡片模式 -->
|
||||
<div v-if="viewMode === 'grid'" class="fl-grid">
|
||||
<FileCard
|
||||
v-for="f in displayFiles"
|
||||
:key="f.subid"
|
||||
:file="f"
|
||||
:selected="selectedFileIds.has(f.subid)"
|
||||
@select="onFileSelect"
|
||||
@preview="onPreview"
|
||||
@download="downloadSingle"
|
||||
@delete="deleteSingle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表模式 -->
|
||||
<div v-else ref="tableWrapRef" class="fl-list">
|
||||
<a-table
|
||||
:data-source="displayFiles"
|
||||
:columns="listColumns"
|
||||
:loading="false"
|
||||
:row-selection="listRowSelection"
|
||||
:pagination="false"
|
||||
row-key="subid"
|
||||
size="small"
|
||||
:scroll="{ y: tableScrollY }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mx_filename'">
|
||||
<a
|
||||
class="fl-filename"
|
||||
:class="{ 'is-pending': record._pending }"
|
||||
:title="record.mx_filename"
|
||||
@click="!record._pending && onPreview(record)"
|
||||
>
|
||||
{{ record.mx_filename }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'mx_filesize'">
|
||||
<span class="fl-size">{{ formatSize(record.mx_filesize) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'mx_fileext'">
|
||||
<a-tag v-if="record._pending" class="fl-ext-tag fl-ext-tag--pending">待上传</a-tag>
|
||||
<span
|
||||
v-else
|
||||
class="fl-ext-chip"
|
||||
:style="{ '--c': extColor(record.mx_filename) }"
|
||||
>{{ (record.mx_fileext || "?").toUpperCase() }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<div class="fl-actions">
|
||||
<a-tooltip title="下载">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
:disabled="!!record._pending"
|
||||
@click="downloadSingle(record)"
|
||||
>
|
||||
<Download :size="14" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="!readonly" title="删除">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
danger
|
||||
@click="deleteSingle(record)"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText>
|
||||
<div class="fl-empty">暂无文件</div>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="fl-empty fl-empty--fill"
|
||||
:class="{ 'fl-empty--clickable': !readonly }"
|
||||
@click="!readonly && triggerSelect()"
|
||||
>
|
||||
<a-empty description="暂无相关文件" />
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<!-- 拖拽悬浮遮罩 -->
|
||||
<div v-if="isDragOver" class="fl-drop-mask">
|
||||
<div class="fl-drop-hint">
|
||||
<Upload :size="28" />
|
||||
<span>释放文件到此处</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="!readonly" class="fl-footer">
|
||||
<div class="fl-footer__bar">
|
||||
<span class="fl-footer__count" :class="{ 'is-pending': hasPending }">
|
||||
<template v-if="hasPending">待上传 {{ pendingFiles.length }} 个</template>
|
||||
<template v-else>共 {{ uploadedCount }} 个文件</template>
|
||||
</span>
|
||||
<div class="fl-footer__btns">
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
type="file"
|
||||
class="fl-footer__input"
|
||||
multiple
|
||||
@change="onSelectChange"
|
||||
/>
|
||||
<a-button @click="triggerSelect">
|
||||
<template #icon><FolderOpen :size="14" /></template>
|
||||
选择
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="!hasPending"
|
||||
:loading="uploading"
|
||||
@click="submitUpload"
|
||||
>
|
||||
<template #icon><Upload :size="14" /></template>
|
||||
上传
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > pageSize" class="fl-pagination">
|
||||
<a-pagination
|
||||
:current="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="loadFiles"
|
||||
@show-size-change="
|
||||
(_, size) => {
|
||||
pageSize = size;
|
||||
loadFiles(1);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fl-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--fms-surface);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.fl-header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: var(--fms-surface);
|
||||
|
||||
&__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__modes {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.fl-main {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: var(--fms-surface);
|
||||
|
||||
&--dragover {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-surface));
|
||||
}
|
||||
}
|
||||
|
||||
.fl-spin {
|
||||
/* 不锁死高度:内容短时(min-height)仍撑满文件区,便于空态/加载态居中;
|
||||
内容长时自然溢出,由 .fl-main(overflow:auto) 统一产生 Y 轴滚动条 */
|
||||
min-height: 100%;
|
||||
|
||||
/* a-spin 容器默认 position:relative 且曾被设为 height:100%,
|
||||
会把超长 grid “装”进固定高度盒子里,导致 fl-main 不出现滚动条、
|
||||
底部 padding 也像失效。改为自然高度 + 取消固定高度,并把定位上下文
|
||||
让回 .fl-main(position:relative),使空态 .fl-empty--fill 正确铺满居中。 */
|
||||
:deep(.ant-spin-container) {
|
||||
height: auto;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.fl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* ── 列表模式 ── */
|
||||
.fl-list {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fl-filename {
|
||||
color: var(--fms-primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: default;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fl-size {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.fl-ext-tag {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
|
||||
&--pending {
|
||||
color: var(--fms-warning);
|
||||
background: color-mix(in srgb, var(--fms-warning) 12%, #fff);
|
||||
border-color: color-mix(in srgb, var(--fms-warning) 35%, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
/* 彩色类型方块(与卡片一致) */
|
||||
.fl-ext-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 32%, #fff);
|
||||
}
|
||||
|
||||
.fl-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fl-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
|
||||
&--fill {
|
||||
/* 相对 .fl-main(position:relative)铺满整个文件区,再做垂直水平居中,
|
||||
避免依赖 a-spin 容器的高度链导致仅贴顶居中 */
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* 非只读时空状态可点击选择文件 */
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 拖拽遮罩 ── */
|
||||
.fl-drop-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--fms-primary) 8%, transparent);
|
||||
border: 2px dashed var(--fms-primary);
|
||||
border-radius: 6px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fl-drop-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fms-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.fl-footer {
|
||||
flex: none;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
background: var(--fms-surface);
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
|
||||
&.is-pending {
|
||||
color: var(--fms-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 分页 ── */
|
||||
.fl-pagination {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { ChevronLeft, ChevronRight, FileText } from "@lucide/vue";
|
||||
import { getPreviewUrl } from "@/services/fileService";
|
||||
import { extColor, formatSize } from "./fileVisual";
|
||||
|
||||
const props = defineProps({
|
||||
file: { type: Object, default: null },
|
||||
fileList: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["prev", "next"]);
|
||||
|
||||
const currentIndex = computed(() => {
|
||||
if (!props.file || !props.fileList.length) return -1;
|
||||
return props.fileList.findIndex((f) => f.subid === props.file.subid);
|
||||
});
|
||||
|
||||
const totalCount = computed(() => props.fileList.length);
|
||||
|
||||
const canPrev = computed(() => currentIndex.value > 0);
|
||||
const canNext = computed(
|
||||
() => currentIndex.value >= 0 && currentIndex.value < totalCount.value - 1,
|
||||
);
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (!props.file) return "";
|
||||
return getPreviewUrl(props.file);
|
||||
});
|
||||
|
||||
const isImage = computed(() => {
|
||||
const ext = (props.file?.mx_fileext || "").toLowerCase();
|
||||
return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext);
|
||||
});
|
||||
|
||||
const typeColor = computed(() => extColor(props.file?.mx_filename || ""));
|
||||
const extUpper = computed(() => (props.file?.mx_fileext || "?").toUpperCase());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="fp-panel">
|
||||
<div class="fp-toolbar">
|
||||
<div class="fp-toolbar__nav">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
:disabled="!canPrev"
|
||||
title="上一个"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<ChevronLeft :size="16" />
|
||||
</a-button>
|
||||
<span class="fp-toolbar__counter" v-if="currentIndex >= 0">
|
||||
{{ currentIndex + 1 }} / {{ totalCount }}
|
||||
</span>
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
:disabled="!canNext"
|
||||
title="下一个"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
<ChevronRight :size="16" />
|
||||
</a-button>
|
||||
</div>
|
||||
<span class="fp-toolbar__name" :title="file?.mx_filename">
|
||||
{{ file?.mx_filename || "" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="fp-content">
|
||||
<template v-if="file">
|
||||
<div v-if="isImage && previewUrl" class="fp-content__image">
|
||||
<img :src="previewUrl" :alt="file.mx_filename" />
|
||||
</div>
|
||||
<div v-else class="fp-content__generic">
|
||||
<div class="fp-content__chip" :style="{ '--c': typeColor }">
|
||||
{{ extUpper }}
|
||||
</div>
|
||||
<div class="fp-content__info">
|
||||
<div class="fp-content__name">{{ file.mx_filename }}</div>
|
||||
<div class="fp-content__meta">
|
||||
<span>{{ formatSize(file.mx_filesize) }}</span>
|
||||
<span v-if="file.mx_fileext">· {{ file.mx_fileext }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="fp-empty">
|
||||
<FileText :size="48" :stroke-width="1" />
|
||||
<div class="fp-empty__text">选择文件查看预览</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fp-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.fp-toolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: var(--fms-surface);
|
||||
gap: 8px;
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
&__counter {
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fms-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.fp-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--fms-bg);
|
||||
|
||||
&__image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__generic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 彩色类型方块(与卡片一致) */
|
||||
&__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 32%, #fff);
|
||||
}
|
||||
|
||||
&__info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 空状态 ── */
|
||||
.fp-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
color: var(--fms-border);
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { loadDataApi } from "@/services/api";
|
||||
import FileCategoryPanel from "./FileCategoryPanel.vue";
|
||||
import FileListPanel from "./FileListPanel.vue";
|
||||
import FilePreviewPanel from "./FilePreviewPanel.vue";
|
||||
import { useFileCategory } from "./composables/useFileCategory";
|
||||
|
||||
const props = defineProps({
|
||||
moduleCode: { type: [String, Number], required: true },
|
||||
father: { type: [String, Number], required: true },
|
||||
showTree: { type: Boolean, default: true },
|
||||
readonly: { type: Boolean, default: false },
|
||||
initialCateId: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["uploaded", "deleted", "selected"]);
|
||||
|
||||
// ---------- 模块解析 ----------
|
||||
const moduleId = ref("");
|
||||
const moduleResolving = ref(false);
|
||||
const moduleError = ref("");
|
||||
|
||||
async function resolveModule() {
|
||||
const code = props.moduleCode;
|
||||
if (!code) {
|
||||
moduleId.value = "";
|
||||
return;
|
||||
}
|
||||
moduleResolving.value = true;
|
||||
moduleError.value = "";
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
"s_module",
|
||||
`b_code = N'${String(code).replaceAll("'", "''")}' AND b_canuse = 1`,
|
||||
"",
|
||||
["b_id", "b_code", "b_name"],
|
||||
);
|
||||
if (res.data?.length) {
|
||||
moduleId.value = String(res.data[0].b_id);
|
||||
} else {
|
||||
moduleId.value = "";
|
||||
moduleError.value = `未找到模块 "${code}"`;
|
||||
}
|
||||
} catch (e) {
|
||||
moduleError.value = e?.message || "模块解析失败";
|
||||
moduleId.value = "";
|
||||
} finally {
|
||||
moduleResolving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.moduleCode,
|
||||
() => resolveModule(),
|
||||
);
|
||||
|
||||
// ---------- 分类 ----------
|
||||
const {
|
||||
treeData: categoryTreeData,
|
||||
loading: categoryLoading,
|
||||
selectedKey: categorySelectedKey,
|
||||
loadCategories,
|
||||
selectCategory,
|
||||
} = useFileCategory();
|
||||
|
||||
const activeCateId = computed(() => {
|
||||
const key = categorySelectedKey.value;
|
||||
if (!key || key === "__fms_tree_root__") return "";
|
||||
return key;
|
||||
});
|
||||
|
||||
const categoryExpandedKeys = ref(["__fms_tree_root__"]);
|
||||
|
||||
function onCategorySelect(key) {
|
||||
selectCategory(key);
|
||||
}
|
||||
|
||||
// ---------- 预览选中 ----------
|
||||
const fileListRef = ref(null);
|
||||
const selectedFile = ref(null);
|
||||
|
||||
const currentFileList = computed(() => fileListRef.value?.files || []);
|
||||
|
||||
function onPreviewFile(file) {
|
||||
selectedFile.value = file;
|
||||
emit("selected", file);
|
||||
}
|
||||
|
||||
function onPreviewPrev() {
|
||||
const list = currentFileList.value;
|
||||
const idx = list.findIndex((f) => f.subid === selectedFile.value?.subid);
|
||||
if (idx > 0) {
|
||||
selectedFile.value = list[idx - 1];
|
||||
emit("selected", selectedFile.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onPreviewNext() {
|
||||
const list = currentFileList.value;
|
||||
const idx = list.findIndex((f) => f.subid === selectedFile.value?.subid);
|
||||
if (idx >= 0 && idx < list.length - 1) {
|
||||
selectedFile.value = list[idx + 1];
|
||||
emit("selected", selectedFile.value);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件代理 ----------
|
||||
function onUploaded() {
|
||||
emit("uploaded");
|
||||
}
|
||||
|
||||
function onDeleted() {
|
||||
selectedFile.value = null;
|
||||
emit("deleted");
|
||||
}
|
||||
|
||||
// ---------- 挂载 ----------
|
||||
onMounted(() => {
|
||||
resolveModule();
|
||||
loadCategories();
|
||||
if (props.initialCateId) {
|
||||
selectCategory(props.initialCateId);
|
||||
categoryExpandedKeys.value = ["__fms_tree_root__", String(props.initialCateId)];
|
||||
}
|
||||
});
|
||||
|
||||
const ready = computed(() => !moduleResolving.value && !moduleError.value);
|
||||
|
||||
defineExpose({ moduleId, refreshCategories: loadCategories });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ff-container">
|
||||
<div v-if="moduleResolving" class="ff-status">
|
||||
<a-spin size="small" />
|
||||
<span class="ff-status__text">正在解析模块...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="moduleError" class="ff-status ff-status--error">
|
||||
{{ moduleError }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<a-splitter class="ff-splitter" lazy>
|
||||
<!-- 左:分类树(固定 220px 侧栏) -->
|
||||
<a-splitter-panel
|
||||
v-if="showTree"
|
||||
:default-size="220"
|
||||
:min="160"
|
||||
:max="320"
|
||||
>
|
||||
<FileCategoryPanel
|
||||
:tree-data="categoryTreeData"
|
||||
:loading="categoryLoading"
|
||||
:selected-key="categorySelectedKey"
|
||||
:expanded-keys="categoryExpandedKeys"
|
||||
@select="onCategorySelect"
|
||||
@update:expanded-keys="(keys) => (categoryExpandedKeys = keys)"
|
||||
/>
|
||||
</a-splitter-panel>
|
||||
|
||||
<!-- 中:文件列表(固定 500px) -->
|
||||
<a-splitter-panel :default-size="500" :min="280">
|
||||
<div class="ff-center">
|
||||
<FileListPanel
|
||||
ref="fileListRef"
|
||||
:module-code="moduleCode"
|
||||
:module-id="moduleId"
|
||||
:father="father"
|
||||
:cate-id="activeCateId"
|
||||
:readonly="readonly"
|
||||
@preview="onPreviewFile"
|
||||
@uploaded="onUploaded"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</a-splitter-panel>
|
||||
|
||||
<!-- 右:预览(不指定默认宽,自动占剩余空间) -->
|
||||
<a-splitter-panel :min="440" collapsible>
|
||||
<FilePreviewPanel
|
||||
:file="selectedFile"
|
||||
:file-list="currentFileList"
|
||||
@prev="onPreviewPrev"
|
||||
@next="onPreviewNext"
|
||||
/>
|
||||
</a-splitter-panel>
|
||||
</a-splitter>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ff-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ff-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ff-center {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ff-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #646a73;
|
||||
|
||||
&--error {
|
||||
color: #cf1322;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { useFileModal } from "./useFileModal";
|
||||
import FmsFile from "./FmsFile.vue";
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
moduleCode: { type: [String, Number], required: true },
|
||||
father: { type: [String, Number], required: true },
|
||||
showTree: { type: Boolean, default: true },
|
||||
readonly: { type: Boolean, default: false },
|
||||
initialCateId: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:open", "uploaded", "deleted", "selected"]);
|
||||
|
||||
function onClose() {
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
const { state } = useFileModal();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 声明式用法 -->
|
||||
<a-modal
|
||||
v-if="open"
|
||||
:open="open"
|
||||
title="附件管理"
|
||||
:width="'80%'"
|
||||
:body-style="{ padding: 0, height: 'calc(70vh - 55px)' }"
|
||||
wrap-class-name="ff-modal"
|
||||
:destroy-on-close="true"
|
||||
:footer="null"
|
||||
@cancel="onClose"
|
||||
>
|
||||
<FmsFile
|
||||
:module-code="moduleCode"
|
||||
:father="father"
|
||||
:show-tree="showTree"
|
||||
:readonly="readonly"
|
||||
:initial-cate-id="initialCateId"
|
||||
@uploaded="$emit('uploaded')"
|
||||
@deleted="$emit('deleted')"
|
||||
@selected="(f) => $emit('selected', f)"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 命令式用法 -->
|
||||
<a-modal
|
||||
v-if="state.open"
|
||||
:open="state.open"
|
||||
title="附件管理"
|
||||
:width="'80%'"
|
||||
:body-style="{ padding: 0, height: 'calc(70vh - 55px)' }"
|
||||
wrap-class-name="ff-modal"
|
||||
:destroy-on-close="true"
|
||||
:footer="null"
|
||||
@cancel="state.open = false"
|
||||
>
|
||||
<FmsFile
|
||||
:key="state.key"
|
||||
:module-code="state.moduleCode"
|
||||
:father="state.father"
|
||||
:show-tree="state.showTree"
|
||||
:readonly="state.readonly"
|
||||
:initial-cate-id="state.initialCateId"
|
||||
@uploaded="state.onUploaded?.()"
|
||||
@deleted="state.onDeleted?.()"
|
||||
@selected="(f) => state.onSelected?.(f)"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.ff-modal {
|
||||
.ant-modal {
|
||||
max-width: 1440px; /* 超宽屏封顶,避免三栏被拉得过宽 */
|
||||
min-width: 960px; /* 极小屏保底,三栏不挤 */
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 520px; /* 矮屏保底高度 */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { loadDataApi } from "@/services/api";
|
||||
|
||||
/**
|
||||
* 文件分类逻辑 —— 加载 bf_files_cateid 扁平表并通过 FmsTree 展示
|
||||
*/
|
||||
export function useFileCategory() {
|
||||
const categories = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const selectedKey = ref("");
|
||||
|
||||
/** 加载所有启用的分类 */
|
||||
async function loadCategories() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
"bf_files_cateid",
|
||||
"b_canuse = 1",
|
||||
"b_xh ASC, b_id ASC",
|
||||
);
|
||||
categories.value = (res.data || []).map((item) => ({
|
||||
...item,
|
||||
b_id: String(item.b_id),
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e?.message || String(e);
|
||||
categories.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 扁平分类转 FmsTree 所需的 treeData(b_id → key, b_name → title) */
|
||||
const treeData = computed(() =>
|
||||
categories.value.map((item) => ({
|
||||
key: String(item.b_id),
|
||||
title: item.b_name || item.b_code || "",
|
||||
b_id: String(item.b_id),
|
||||
isLeaf: true,
|
||||
})),
|
||||
);
|
||||
|
||||
/** 按 key 查找分类原始数据 */
|
||||
function findCategory(key) {
|
||||
const id = String(key || "");
|
||||
return categories.value.find((item) => String(item.b_id) === id) || null;
|
||||
}
|
||||
|
||||
function selectCategory(key) {
|
||||
selectedKey.value = String(key || "");
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
loading,
|
||||
error,
|
||||
selectedKey,
|
||||
treeData,
|
||||
loadCategories,
|
||||
findCategory,
|
||||
selectCategory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import { loadDataApi, pageDataApi, nextIdApi, saveObjectApi } from "@/services/api";
|
||||
import { upload as fileUpload, removeFile } from "@/services/fileService";
|
||||
|
||||
/**
|
||||
* 文件列表逻辑 —— 分页加载 / 上传 / 删除 / 视图切换
|
||||
*/
|
||||
export function useFileList({ moduleCode, father, cateId }) {
|
||||
const files = ref([]);
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const viewMode = ref("grid"); // 'grid' | 'list'
|
||||
const moduleId = ref("");
|
||||
const moduleResolved = ref(false);
|
||||
const resolving = ref(false);
|
||||
const selectedFileIds = ref([]);
|
||||
const uploading = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const hasFiles = computed(() => files.value.length > 0);
|
||||
const hasSelection = computed(() => selectedFileIds.value.length > 0);
|
||||
|
||||
/** 根据 moduleCode 解析 moduleId */
|
||||
async function resolveModuleId() {
|
||||
const code = moduleCode?.value;
|
||||
if (!code) {
|
||||
moduleId.value = "";
|
||||
moduleResolved.value = true;
|
||||
return;
|
||||
}
|
||||
resolving.value = true;
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
"s_module",
|
||||
`b_code = N'${String(code).replaceAll("'", "''")}' AND b_canuse = 1 AND b_module_type = 'data'`,
|
||||
"",
|
||||
["b_id", "b_code"],
|
||||
);
|
||||
if (res.data?.length) {
|
||||
moduleId.value = String(res.data[0].b_id);
|
||||
} else {
|
||||
moduleId.value = "";
|
||||
}
|
||||
} catch {
|
||||
moduleId.value = "";
|
||||
} finally {
|
||||
moduleResolved.value = true;
|
||||
resolving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildSearchCondition() {
|
||||
const f = father?.value;
|
||||
if (!f) return "1 = 0";
|
||||
let cond = `[father] = N'${String(f).replaceAll("'", "''")}'`;
|
||||
if (moduleId.value) {
|
||||
cond += ` AND [mx_moduleid] = N'${moduleId.value}'`;
|
||||
}
|
||||
const cid = cateId?.value;
|
||||
if (cid) {
|
||||
cond += ` AND [mx_cate_id] = N'${String(cid).replaceAll("'", "''")}'`;
|
||||
}
|
||||
return cond;
|
||||
}
|
||||
|
||||
/** 分页加载文件列表 */
|
||||
async function loadFiles(page = 1) {
|
||||
currentPage.value = page;
|
||||
const f = father?.value;
|
||||
if (!f) {
|
||||
files.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const cond = buildSearchCondition();
|
||||
const res = await pageDataApi({
|
||||
viewName: "bf_files",
|
||||
orderBy: "mx_xh ASC, subid DESC",
|
||||
searchCondition: cond,
|
||||
pageNo: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
files.value = (res.data?.rows || []).map(normalizeFile);
|
||||
total.value = res.data?.total || 0;
|
||||
} catch (e) {
|
||||
message.error("加载文件列表失败:" + (e?.message || e));
|
||||
files.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFile(row) {
|
||||
return {
|
||||
...row,
|
||||
subid: String(row.subid),
|
||||
father: String(row.father),
|
||||
mx_moduleid: row.mx_moduleid ? String(row.mx_moduleid) : "",
|
||||
mx_cate_id: row.mx_cate_id ? String(row.mx_cate_id) : "",
|
||||
mx_filesize: Number(row.mx_filesize || 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 上传文件 */
|
||||
async function uploadFiles(fileList) {
|
||||
if (!fileList?.length) return;
|
||||
const f = father?.value;
|
||||
if (!f || !moduleId.value) {
|
||||
message.warning("缺少业务单据 ID 或模块 ID,无法上传");
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
await fileUpload({
|
||||
files: Array.from(fileList),
|
||||
father: String(f),
|
||||
moduleId: moduleId.value,
|
||||
cateId: cateId?.value || undefined,
|
||||
});
|
||||
message.success(`成功上传 ${fileList.length} 个文件`);
|
||||
await loadFiles(1);
|
||||
} catch (e) {
|
||||
message.error("上传失败:" + (e?.message || e));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量删除选中文件 */
|
||||
async function deleteSelected() {
|
||||
const ids = selectedFileIds.value;
|
||||
if (!ids.length) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
for (const id of ids) {
|
||||
await removeFile(id);
|
||||
}
|
||||
message.success(`已删除 ${ids.length} 个文件`);
|
||||
selectedFileIds.value = [];
|
||||
await loadFiles(currentPage.value);
|
||||
} catch (e) {
|
||||
message.error("删除失败:" + (e?.message || e));
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleView() {
|
||||
viewMode.value = viewMode.value === "grid" ? "list" : "grid";
|
||||
}
|
||||
|
||||
function onSelectionChange(keys) {
|
||||
selectedFileIds.value = keys.map(String);
|
||||
}
|
||||
|
||||
// cateId 变化时重新加载
|
||||
watch(
|
||||
cateId || (() => null),
|
||||
() => {
|
||||
if (father?.value) loadFiles(1);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
files,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
viewMode,
|
||||
moduleId,
|
||||
moduleResolved,
|
||||
resolving,
|
||||
selectedFileIds,
|
||||
uploading,
|
||||
deleting,
|
||||
hasFiles,
|
||||
hasSelection,
|
||||
resolveModuleId,
|
||||
loadFiles,
|
||||
uploadFiles,
|
||||
deleteSelected,
|
||||
toggleView,
|
||||
onSelectionChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 文件视觉共享工具:扩展名语义配色、图片判断、大小格式化
|
||||
// 集中此处,避免 FileCard / FileListPanel / FilePreviewPanel 三处重复定义
|
||||
|
||||
// 扩展名 → 语义配色(ERP 附件管理可一眼辨类)
|
||||
const EXT_COLOR = {
|
||||
// 文档
|
||||
pdf: "#f5222d",
|
||||
doc: "#1677ff",
|
||||
docx: "#1677ff",
|
||||
txt: "#8c8c8c",
|
||||
md: "#8c8c8c",
|
||||
// 表格
|
||||
xls: "#52c41a",
|
||||
xlsx: "#52c41a",
|
||||
csv: "#52c41a",
|
||||
// 演示
|
||||
ppt: "#fa541c",
|
||||
pptx: "#fa541c",
|
||||
// 图片
|
||||
jpg: "#722ed1",
|
||||
jpeg: "#722ed1",
|
||||
png: "#722ed1",
|
||||
gif: "#722ed1",
|
||||
bmp: "#722ed1",
|
||||
webp: "#722ed1",
|
||||
svg: "#722ed1",
|
||||
// 压缩
|
||||
zip: "#fa8c16",
|
||||
rar: "#fa8c16",
|
||||
"7z": "#fa8c16",
|
||||
// 音视频
|
||||
mp3: "#13c2c2",
|
||||
wav: "#13c2c2",
|
||||
mp4: "#13c2c2",
|
||||
mov: "#13c2c2",
|
||||
};
|
||||
|
||||
const FALLBACK_COLOR = "#8c8c8c";
|
||||
|
||||
export function extFromName(name) {
|
||||
const idx = (name || "").lastIndexOf(".");
|
||||
return idx >= 0 ? name.substring(idx + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function extColor(name) {
|
||||
const ext = extFromName(name);
|
||||
return EXT_COLOR[ext] || FALLBACK_COLOR;
|
||||
}
|
||||
|
||||
export function isImageType(name) {
|
||||
return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
||||
extFromName(name),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSize(bytes) {
|
||||
const n = Number(bytes || 0);
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
const state = reactive({
|
||||
open: false,
|
||||
moduleCode: "",
|
||||
father: "",
|
||||
showTree: true,
|
||||
readonly: false,
|
||||
initialCateId: "",
|
||||
key: 0,
|
||||
// 事件回调
|
||||
onUploaded: null,
|
||||
onDeleted: null,
|
||||
onSelected: null,
|
||||
});
|
||||
|
||||
let keyCounter = 0;
|
||||
|
||||
/**
|
||||
* 文件 Modal 命令式调用 composable。
|
||||
*
|
||||
* 用法:
|
||||
* const fileModal = useFileModal();
|
||||
* fileModal.open({ moduleCode: 'b_port', father: '123' });
|
||||
*/
|
||||
export function useFileModal() {
|
||||
function open(opts) {
|
||||
keyCounter++;
|
||||
state.key = keyCounter;
|
||||
state.moduleCode = opts.moduleCode || "";
|
||||
state.father = String(opts.father ?? "");
|
||||
state.showTree = opts.showTree !== false;
|
||||
state.readonly = opts.readonly || false;
|
||||
state.initialCateId = opts.initialCateId || "";
|
||||
state.onUploaded = opts.onUploaded || null;
|
||||
state.onDeleted = opts.onDeleted || null;
|
||||
state.onSelected = opts.onSelected || null;
|
||||
state.open = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.open = false;
|
||||
}
|
||||
|
||||
return { open, close, state };
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useRoute } from "vue-router";
|
||||
import { message } from "antdv-next";
|
||||
import { Columns3, RefreshCw, Save } from "@lucide/vue";
|
||||
import { loadDataApi, pageDataApi, saveObjectApi } from "@/services/api";
|
||||
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import {
|
||||
identifierRe,
|
||||
@@ -36,9 +36,22 @@ const props = defineProps({
|
||||
expandable: { type: [Boolean, Object], default: false },
|
||||
pageSize: { type: Number, default: 20 },
|
||||
inlineActionLimit: { type: Number, default: 2 },
|
||||
// 可编辑表格模式:取编辑配置生成行内编辑器,支持保存/批量删除。
|
||||
editable: { type: Boolean, default: false },
|
||||
// 行拖拽排序:开启后显示拖拽手柄,拖拽按 orderField 重排并纳入脏保存。
|
||||
rowDraggable: { type: Boolean, default: false },
|
||||
// 行拖拽排序字段,仅 rowDraggable 时生效。
|
||||
orderField: { type: String, default: "b_xh" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand", "loaded"]);
|
||||
const emit = defineEmits([
|
||||
"row-click",
|
||||
"cell-click",
|
||||
"row-action",
|
||||
"expand",
|
||||
"loaded",
|
||||
"dirty-change",
|
||||
]);
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
@@ -52,10 +65,16 @@ const dataModule = ref(null);
|
||||
const activeFields = shallowRef([]);
|
||||
const activeFieldById = shallowRef(new Map());
|
||||
const listConfig = shallowRef([]);
|
||||
const editConfigById = shallowRef(new Map());
|
||||
const queryItems = shallowRef([]);
|
||||
const dataSource = shallowRef([]);
|
||||
const searchConditions = shallowRef([]);
|
||||
const keyField = ref("");
|
||||
// 可编辑表格:脏行追踪(rowKey -> Set<已修改字段>),仅在 editable 模式下使用。
|
||||
const dirtyRows = shallowRef(new Map());
|
||||
// 可编辑表格:未持久化的新增行 rowKey 集合(新增行保存时作为 inserts 提交)。
|
||||
const newRowKeys = shallowRef(new Set());
|
||||
const saving = ref(false);
|
||||
const configLoading = ref(false);
|
||||
const dataLoading = ref(false);
|
||||
const columnSaving = ref(false);
|
||||
@@ -163,9 +182,11 @@ async function loadModuleConfig() {
|
||||
}
|
||||
|
||||
const dataId = requireModuleId(runtimeData.b_id);
|
||||
// 列显示配置:普通模式取列表配置,可编辑模式取编辑配置,二者只取其一。
|
||||
const listTable = props.editable ? "s_module_field_edit" : "s_module_field_list";
|
||||
const responses = await Promise.all([
|
||||
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
|
||||
loadDataApi("s_module_field_list", `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi(listTable, `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
|
||||
loadDataApi(
|
||||
"s_module_field_query",
|
||||
`b_module_id = ${dataId} AND b_canuse = 1`,
|
||||
@@ -182,6 +203,16 @@ async function loadModuleConfig() {
|
||||
fieldById.set(String(field.b_id), field);
|
||||
}
|
||||
|
||||
// 可编辑模式下:记录编辑配置的 readonly/disabled,用于生成只读编辑器。
|
||||
const editConfigByIdMap = new Map();
|
||||
for (const item of props.editable ? responses[1].data || [] : []) {
|
||||
if (item.b_visible !== 1) continue;
|
||||
if (!fieldById.has(String(item.b_field_id))) continue;
|
||||
editConfigByIdMap.set(String(item.b_field_id), item);
|
||||
}
|
||||
editConfigById.value = editConfigByIdMap;
|
||||
|
||||
// 列表配置:普通模式为 s_module_field_list,可编辑模式为 s_module_field_edit。
|
||||
const lists = [];
|
||||
for (const item of responses[1].data || []) {
|
||||
if (item.b_visible !== 1) continue;
|
||||
@@ -215,6 +246,8 @@ async function loadModuleConfig() {
|
||||
queryItems.value = queries;
|
||||
searchConditions.value = conditions;
|
||||
keyField.value = resolveKeyField(runtimeData, fields);
|
||||
dirtyRows.value = new Map();
|
||||
newRowKeys.value = new Set();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (getDataCode() === code) {
|
||||
@@ -301,9 +334,13 @@ async function initialize() {
|
||||
activeFields.value = [];
|
||||
activeFieldById.value = new Map();
|
||||
listConfig.value = [];
|
||||
editConfigById.value = new Map();
|
||||
queryItems.value = [];
|
||||
searchConditions.value = [];
|
||||
keyField.value = "";
|
||||
dirtyRows.value = new Map();
|
||||
newRowKeys.value = new Set();
|
||||
saving.value = false;
|
||||
dataLoading.value = false;
|
||||
dataError.value = "";
|
||||
pagination.current = 1;
|
||||
@@ -371,9 +408,11 @@ async function saveColumnSettings() {
|
||||
|
||||
columnSaving.value = true;
|
||||
try {
|
||||
// 可编辑表格的列来自编辑配置,栏位设置保存到 s_module_field_edit;普通模式保存到列表配置。
|
||||
const table = props.editable ? "s_module_field_edit" : "s_module_field_list";
|
||||
await saveObjectApi([
|
||||
{
|
||||
table: "s_module_field_list",
|
||||
table,
|
||||
key_field: "b_id",
|
||||
updates,
|
||||
},
|
||||
@@ -405,6 +444,252 @@ function resolveRowKey(row) {
|
||||
return JSON.stringify(row);
|
||||
}
|
||||
|
||||
// ── 可编辑表格:脏行追踪 / 保存 / 批量删除 ──
|
||||
// 主键与系统审计字段(由后端维护)在可编辑表格中一律只读。
|
||||
const SYSTEM_FIELDS = ["b_inputdatetime", "b_inputuser_id", "b_updatedatetime", "b_updateuser_id"];
|
||||
const readonlyFields = computed(() => {
|
||||
const fields = new Set(SYSTEM_FIELDS);
|
||||
if (keyField.value) fields.add(keyField.value);
|
||||
return [...fields];
|
||||
});
|
||||
|
||||
function handleCellChange({ row, field }) {
|
||||
if (!props.editable || !keyField.value) return;
|
||||
const rowKey = String(resolveRowKey(row));
|
||||
const current = dirtyRows.value.get(rowKey);
|
||||
if (current) {
|
||||
current.add(field);
|
||||
} else {
|
||||
dirtyRows.value.set(rowKey, new Set([field]));
|
||||
}
|
||||
emit("dirty-change", { count: dirtyRows.value.size });
|
||||
}
|
||||
|
||||
function resolveSaveTable() {
|
||||
if (!dataModule.value) throw new Error("数据模块尚未加载");
|
||||
return String(dataModule.value.b_savetable || dataModule.value.b_viewtable || "");
|
||||
}
|
||||
|
||||
function dirtyUpdateRows() {
|
||||
if (!keyField.value) return [];
|
||||
const updates = [];
|
||||
const rowByKey = new Map(
|
||||
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
|
||||
);
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
// 未持久化的新增行走 inserts 分支,避免对不存在的记录执行 UPDATE。
|
||||
if (newRowKeys.value.has(rowKey)) continue;
|
||||
const row = rowByKey.get(rowKey);
|
||||
if (!row) continue;
|
||||
const update = { [keyField.value]: row[keyField.value] };
|
||||
for (const field of fields) {
|
||||
if (field === keyField.value) continue;
|
||||
update[field] = row[field];
|
||||
}
|
||||
updates.push(update);
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
// 未持久化的新增行:仅收集有实际修改的行,作为 inserts 提交。
|
||||
function dirtyInsertRows() {
|
||||
if (!keyField.value) return [];
|
||||
const rowByKey = new Map(
|
||||
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
|
||||
);
|
||||
const inserts = [];
|
||||
for (const rowKey of newRowKeys.value) {
|
||||
const fields = dirtyRows.value.get(rowKey);
|
||||
const row = rowByKey.get(rowKey);
|
||||
if (!fields || !row) continue;
|
||||
const insert = { [keyField.value]: row[keyField.value] };
|
||||
for (const field of fields) {
|
||||
if (field === keyField.value) continue;
|
||||
insert[field] = row[field];
|
||||
}
|
||||
inserts.push(insert);
|
||||
}
|
||||
return inserts;
|
||||
}
|
||||
|
||||
// 找到第一个可编辑的列字段(按编辑配置顺序,跳过只读列、主键与 checkbox),
|
||||
// 用于新增行后定位光标聚焦的单元格。
|
||||
function firstEditableField() {
|
||||
const readonly = new Set(readonlyFields.value);
|
||||
for (const config of listConfig.value) {
|
||||
const field = activeFieldById.value.get(String(config.b_field_id));
|
||||
if (!field) continue;
|
||||
if (readonly.has(field.b_field)) continue;
|
||||
if (field.b_type === "checkbox") continue;
|
||||
return field.b_field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 可编辑模式下新增一行:生成雪花主键,追加到表尾并让第一个可编辑单元格进入编辑态并聚焦。
|
||||
async function addRow() {
|
||||
if (!props.editable || saving.value || loading.value) return;
|
||||
if (!keyField.value) {
|
||||
message.error("该模块未配置主键,无法新增");
|
||||
return;
|
||||
}
|
||||
const idRes = await nextIdApi();
|
||||
const newId = String(idRes.data);
|
||||
const newRow = { [keyField.value]: newId };
|
||||
// 排序字段取当前最大值 + 10;checkbox 字段默认 0。
|
||||
if (props.orderField && identifierRe.test(props.orderField)) {
|
||||
let maxXh = 0;
|
||||
for (const row of dataSource.value) {
|
||||
maxXh = Math.max(maxXh, Number(row[props.orderField]) || 0);
|
||||
}
|
||||
newRow[props.orderField] = maxXh + 10;
|
||||
}
|
||||
for (const field of activeFields.value) {
|
||||
if (field.b_field === keyField.value || field.b_field === props.orderField) continue;
|
||||
if (newRow[field.b_field] !== undefined) continue;
|
||||
if (field.b_type === "checkbox") newRow[field.b_field] = 0;
|
||||
}
|
||||
dataSource.value = [...dataSource.value, newRow];
|
||||
newRowKeys.value = new Set([...newRowKeys.value, newId]);
|
||||
await nextTick();
|
||||
// 聚焦第一个可编辑字段,让用户可直接输入。
|
||||
tableRef.value?.editRow?.(newId, firstEditableField());
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (saving.value) return;
|
||||
const table = resolveSaveTable();
|
||||
if (!table) {
|
||||
message.error("该模块未配置保存表,无法保存");
|
||||
return;
|
||||
}
|
||||
const updates = dirtyUpdateRows();
|
||||
const inserts = dirtyInsertRows();
|
||||
if (!updates.length && !inserts.length) {
|
||||
message.info("没有需要保存的修改");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const operation = { table, key_field: keyField.value };
|
||||
if (updates.length) operation.updates = updates;
|
||||
if (inserts.length) operation.inserts = inserts;
|
||||
await saveObjectApi([operation]);
|
||||
// 已提交的新增行不再视为未持久化。
|
||||
const insertedSet = new Set(inserts.map((row) => String(row[keyField.value])));
|
||||
newRowKeys.value = new Set(
|
||||
[...newRowKeys.value].filter((key) => !insertedSet.has(key)),
|
||||
);
|
||||
dirtyRows.value = new Map();
|
||||
// 保存成功后让正在编辑的行退出编辑态。
|
||||
tableRef.value?.clearEditing?.();
|
||||
message.success("保存成功");
|
||||
emit("dirty-change", { count: 0 });
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
message.error("保存失败:" + (error?.message || error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(rowKeys) {
|
||||
if (saving.value || !rowKeys?.length) return;
|
||||
const table = resolveSaveTable();
|
||||
if (!table) {
|
||||
message.error("该模块未配置保存表,无法删除");
|
||||
return;
|
||||
}
|
||||
if (!keyField.value) {
|
||||
message.error("该模块未配置主键,无法删除");
|
||||
return;
|
||||
}
|
||||
// 未持久化的新增行直接本地移除,不调删除接口。
|
||||
const newSet = new Set(newRowKeys.value);
|
||||
const pendingKeys = rowKeys.filter((key) => newSet.has(String(key)));
|
||||
const persistedKeys = rowKeys.filter((key) => !newSet.has(String(key)));
|
||||
if (pendingKeys.length) {
|
||||
const pendingSet = new Set(pendingKeys.map((key) => String(key)));
|
||||
dataSource.value = dataSource.value.filter(
|
||||
(row) => !pendingSet.has(String(resolveRowKey(row))),
|
||||
);
|
||||
const remaining = new Map();
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
if (!pendingSet.has(rowKey)) remaining.set(rowKey, fields);
|
||||
}
|
||||
dirtyRows.value = remaining;
|
||||
newRowKeys.value = new Set(
|
||||
[...newRowKeys.value].filter((key) => !pendingSet.has(key)),
|
||||
);
|
||||
tableRef.value?.clearEditing?.();
|
||||
emit("dirty-change", { count: dirtyRows.value.size });
|
||||
}
|
||||
if (!persistedKeys.length) {
|
||||
message.success(`已删除 ${rowKeys.length} 条记录`);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{
|
||||
table,
|
||||
key_field: keyField.value,
|
||||
deletes: persistedKeys.map((key) => ({ [keyField.value]: key })),
|
||||
},
|
||||
]);
|
||||
// 清除被删行的脏记录
|
||||
const deletedSet = new Set(persistedKeys.map((key) => String(key)));
|
||||
const remaining = new Map();
|
||||
for (const [rowKey, fields] of dirtyRows.value) {
|
||||
if (!deletedSet.has(rowKey)) remaining.set(rowKey, fields);
|
||||
}
|
||||
dirtyRows.value = remaining;
|
||||
// 删除成功后让正在编辑的行退出编辑态。
|
||||
tableRef.value?.clearEditing?.();
|
||||
message.success(`已删除 ${rowKeys.length} 条记录`);
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
message.error("删除失败:" + (error?.message || error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 行拖拽排序:回灌排序结果,按 orderField 重新编号并标记为脏,随保存一起提交。
|
||||
function handleRowOrderChange(orderedRows) {
|
||||
if (!props.rowDraggable || !props.orderField) return;
|
||||
if (!identifierRe.test(props.orderField)) return;
|
||||
const rowByOld = new Map(
|
||||
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
|
||||
);
|
||||
const dirtyFieldsByKey = new Map();
|
||||
const nextRows = [];
|
||||
let order = 10;
|
||||
for (const row of orderedRows) {
|
||||
const key = String(resolveRowKey(row));
|
||||
const oldRow = rowByOld.get(key);
|
||||
const oldValue = oldRow ? Number(oldRow[props.orderField]) || 0 : 0;
|
||||
const copy = { ...row, [props.orderField]: order };
|
||||
nextRows.push(copy);
|
||||
if (oldValue !== order) {
|
||||
const fields = dirtyFieldsByKey.get(key) || new Set();
|
||||
fields.add(props.orderField);
|
||||
dirtyFieldsByKey.set(key, fields);
|
||||
}
|
||||
order += 10;
|
||||
}
|
||||
dataSource.value = nextRows;
|
||||
for (const [key, fields] of dirtyFieldsByKey) {
|
||||
const current = dirtyRows.value.get(key);
|
||||
if (current) {
|
||||
fields.forEach((field) => current.add(field));
|
||||
} else {
|
||||
dirtyRows.value.set(key, fields);
|
||||
}
|
||||
}
|
||||
emit("dirty-change", { count: dirtyRows.value.size });
|
||||
}
|
||||
|
||||
async function handleMoreAction({ key }) {
|
||||
if (key === "refresh") {
|
||||
await reload();
|
||||
@@ -458,6 +743,10 @@ defineExpose({
|
||||
dataSource,
|
||||
dataModule,
|
||||
pagination,
|
||||
saveChanges,
|
||||
deleteSelected,
|
||||
addRow,
|
||||
dirtyRows,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -487,7 +776,16 @@ defineExpose({
|
||||
@more-action="handleMoreAction"
|
||||
>
|
||||
<template #actions>
|
||||
<slot name="actions" :reload="reload" :data-module="dataModule" :loading="loading" />
|
||||
<slot
|
||||
name="actions"
|
||||
:reload="reload"
|
||||
:data-module="dataModule"
|
||||
:loading="loading"
|
||||
:editable="editable"
|
||||
:saving="saving"
|
||||
:dirty-count="dirtyRows.size"
|
||||
:save-changes="saveChanges"
|
||||
/>
|
||||
</template>
|
||||
</FmsQueryToolbar>
|
||||
|
||||
@@ -497,6 +795,8 @@ defineExpose({
|
||||
ref="tableRef"
|
||||
:fields="activeFields"
|
||||
:list-config="listConfig"
|
||||
:edit-config-by-id="editConfigById"
|
||||
:readonly-fields="readonlyFields"
|
||||
:data-source="dataSource"
|
||||
:row-key="resolveRowKey"
|
||||
:column-extensions="columnExtensions"
|
||||
@@ -505,10 +805,14 @@ defineExpose({
|
||||
:row-class-name="rowClassName"
|
||||
:expandable="expandable"
|
||||
:inline-action-limit="inlineActionLimit"
|
||||
:editable="editable"
|
||||
:row-draggable="rowDraggable"
|
||||
@row-click="$emit('row-click', $event)"
|
||||
@cell-click="$emit('cell-click', $event)"
|
||||
@row-action="$emit('row-action', $event)"
|
||||
@expand="$emit('expand', $event)"
|
||||
@cell-change="handleCellChange"
|
||||
@row-order-change="handleRowOrderChange"
|
||||
>
|
||||
<template v-for="slotName in cellSlotNames" :key="slotName" #[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
|
||||
@@ -8,6 +8,8 @@ import { formatDateValue, parseOptions } from "./moduleListUtils";
|
||||
const props = defineProps({
|
||||
fields: { type: Array, default: () => [] },
|
||||
listConfig: { type: Array, default: () => [] },
|
||||
editConfigById: { type: [Object, Map], default: () => new Map() },
|
||||
readonlyFields: { type: Array, default: () => [] },
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
rowKey: { type: [String, Function], default: "b_id" },
|
||||
columnExtensions: { type: Object, default: () => ({}) },
|
||||
@@ -16,9 +18,19 @@ const props = defineProps({
|
||||
rowClassName: { type: Function, default: undefined },
|
||||
expandable: { type: [Boolean, Object], default: false },
|
||||
inlineActionLimit: { type: Number, default: 2 },
|
||||
editable: { type: Boolean, default: false },
|
||||
// 行拖拽排序:开启后显示拖拽手柄,通过 row-order-change 回传排序后的完整数据。
|
||||
rowDraggable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand"]);
|
||||
const emit = defineEmits([
|
||||
"row-click",
|
||||
"cell-click",
|
||||
"row-action",
|
||||
"expand",
|
||||
"cell-change",
|
||||
"row-order-change",
|
||||
]);
|
||||
const appStore = useAppStore();
|
||||
const slots = useSlots();
|
||||
const tableRef = ref(null);
|
||||
@@ -65,11 +77,30 @@ function resolveExtension(fieldName) {
|
||||
return props.columnExtensions?.[fieldName] || {};
|
||||
}
|
||||
|
||||
// 根据字段类型与编辑配置生成行内编辑器配置。
|
||||
function buildEditor(field, config) {
|
||||
if (
|
||||
props.readonlyFields.includes(field.b_field) ||
|
||||
(config && (Number(config.b_readonly) === 1 || Number(config.b_disabled) === 1))
|
||||
) {
|
||||
return { type: "input", disabled: true };
|
||||
}
|
||||
if (field.b_type === "checkbox") return { type: "checkbox" };
|
||||
if (field.b_type === "select") {
|
||||
const { options } = parseOptions(field);
|
||||
return { type: "select", options };
|
||||
}
|
||||
return { type: "input" };
|
||||
}
|
||||
|
||||
function createRuntimeColumn(config, field) {
|
||||
const optionInfo = parseOptions(field);
|
||||
const extension = resolveExtension(field.b_field);
|
||||
const { formatter, clickable, cellClassName, onClick: _onClick, ...columnProps } = extension;
|
||||
const slot = slots[`cell-${field.b_field}`];
|
||||
const editConfig = props.editable
|
||||
? props.editConfigById.get(String(config.b_field_id))
|
||||
: null;
|
||||
const column = {
|
||||
title: appStore.t(field.b_i18n, field.b_name || field.b_field),
|
||||
dataIndex: field.b_field,
|
||||
@@ -77,9 +108,14 @@ function createRuntimeColumn(config, field) {
|
||||
width: Number(config.b_width) || 120,
|
||||
checkbox: field.b_type === "checkbox",
|
||||
align: field.b_type === "checkbox" ? "center" : undefined,
|
||||
editor: props.editable ? buildEditor(field, editConfig) : undefined,
|
||||
...columnProps,
|
||||
};
|
||||
|
||||
// 可编辑模式下由 editor 接管单元格渲染(resolveCustomCell 会优先 editor 分支),
|
||||
// 不能再设置 customCell,否则编辑器永远不会被渲染。
|
||||
if (props.editable) return column;
|
||||
|
||||
column.customCell = ({ row, cellValue, rowIndex, colIndex }) => {
|
||||
const formattedValue = formatter
|
||||
? formatter({ value: cellValue, record: row, field, config })
|
||||
@@ -251,6 +287,10 @@ function handleRowClick(event, record, meta) {
|
||||
emit("row-click", { event, record, ...meta });
|
||||
}
|
||||
|
||||
function handleCellChange(row, col, value) {
|
||||
emit("cell-change", { row, field: col.dataIndex, value });
|
||||
}
|
||||
|
||||
function handleToggleExpand(payload) {
|
||||
const single = expansionOptions.value.mode !== "multiple";
|
||||
if (payload.expanded && single && expandedRecord && expandedRecord !== payload.row) {
|
||||
@@ -270,6 +310,8 @@ watch(
|
||||
() => props.listConfig,
|
||||
() => props.columnExtensions,
|
||||
() => props.rowActions,
|
||||
() => props.editable,
|
||||
() => props.editConfigById,
|
||||
() => appStore.locale,
|
||||
expansionEnabled,
|
||||
],
|
||||
@@ -289,6 +331,9 @@ defineExpose({
|
||||
refreshLayout,
|
||||
getColumnSettings,
|
||||
clearSelectedArea: () => tableRef.value?.clearSelectedArea?.(),
|
||||
clearEditing: () => tableRef.value?.clearEditing?.(),
|
||||
editRow: (rowKey, focusCol) => tableRef.value?.editRow?.(rowKey, focusCol),
|
||||
scrollToRow: (rowKey) => tableRef.value?.scrollToRow?.(rowKey),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -301,10 +346,13 @@ defineExpose({
|
||||
:row-selection="rowSelection"
|
||||
:row-class-name="rowClassName"
|
||||
:expand-config="expansionEnabled ? expandConfig : undefined"
|
||||
:editable="false"
|
||||
:editable="editable"
|
||||
:row-draggable="rowDraggable"
|
||||
@cell-click="handleCellClick"
|
||||
@row-click="handleRowClick"
|
||||
@toggle-row-expand="handleToggleExpand"
|
||||
@change="handleCellChange"
|
||||
@row-order-change="(rows, sourceIndex, targetIndex) => emit('row-order-change', rows, sourceIndex, targetIndex)"
|
||||
>
|
||||
<template v-if="expansionEnabled" #expand="{ row, col }">
|
||||
<slot name="expanded-row" :record="row" :column="col" />
|
||||
|
||||
@@ -135,9 +135,11 @@ function editorValuesEqual(col, previous, current) {
|
||||
return String(previous ?? "") === String(current ?? "");
|
||||
}
|
||||
|
||||
const selectOnFocus = ref(true);
|
||||
const editingCtx = {
|
||||
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
|
||||
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
|
||||
shouldSelectOnFocus: selectOnFocus,
|
||||
shouldNotify: (row, col, newValue) => {
|
||||
const values = getRowEditingState(row)?.value.initialValues;
|
||||
if (!values?.has(col.dataIndex)) return true;
|
||||
@@ -329,6 +331,7 @@ function commitPendingEditing() {
|
||||
);
|
||||
if (row) {
|
||||
activateEditing(row, target.focusCol);
|
||||
tableRef.value?.setCurrentRow?.(row, { silent: true });
|
||||
lastEditActivateAt = performance.now();
|
||||
}
|
||||
}
|
||||
@@ -405,6 +408,7 @@ function handleAreaSelectionChange(ranges) {
|
||||
pendingEditingTarget = null;
|
||||
clearEditActivateTimer();
|
||||
activateEditing(row, focusCol);
|
||||
tableRef.value?.setCurrentRow?.(row, { silent: true });
|
||||
lastEditActivateAt = now;
|
||||
} else {
|
||||
pendingEditingTarget = { row, focusCol };
|
||||
@@ -414,10 +418,18 @@ function handleAreaSelectionChange(ranges) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 非键盘导航(鼠标点击等):立即激活
|
||||
// 非键盘导航(鼠标点击等):立即激活。
|
||||
// 同一行已编辑时仍更新 focusCol(保证焦点切换到新列),
|
||||
// 但暂时禁用 select 全选(避免鼠标点击触发全选文本)。
|
||||
pendingEditingTarget = null;
|
||||
clearEditActivateTimer();
|
||||
activateEditing(row, focusCol);
|
||||
if (activeEditingRow === row) {
|
||||
selectOnFocus.value = false;
|
||||
activateEditing(row, focusCol);
|
||||
nextTick(() => { selectOnFocus.value = true; });
|
||||
} else {
|
||||
activateEditing(row, focusCol);
|
||||
}
|
||||
lastEditActivateAt = performance.now();
|
||||
}
|
||||
|
||||
@@ -599,6 +611,7 @@ defineExpose({
|
||||
.fms-cell-display {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.fms-table__inner.text-overflow .fms-cell-display {
|
||||
|
||||
@@ -158,5 +158,20 @@ export function resolveCustomCell(col, editingContext) {
|
||||
const customCell = createActionsCell(col.actions);
|
||||
if (customCell) return { ...col, customCell };
|
||||
}
|
||||
// 纯展示列(无 editor、无 actions):保持 mousedown 冒泡以支持点击激活行编辑,
|
||||
// 文字选中通过 CSS 层 user-select: text 保障
|
||||
if (col.dataIndex) {
|
||||
return {
|
||||
...col,
|
||||
customCell: ({ row }) => {
|
||||
const value = row[col.dataIndex];
|
||||
const content = value ?? "";
|
||||
return h("span", {
|
||||
class: "fms-cell-display",
|
||||
title: String(content),
|
||||
}, content);
|
||||
},
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}
|
||||
@@ -72,14 +72,15 @@ function createFocusHandler(ctx, row, col, selectText = false) {
|
||||
if (document.activeElement === input) return;
|
||||
|
||||
const instance = component?.exposed || component?.proxy;
|
||||
const opts = selectText ? { cursor: "all", preventScroll: true } : { preventScroll: true };
|
||||
const shouldSelect = selectText && (ctx.shouldSelectOnFocus?.value ?? true);
|
||||
const opts = shouldSelect ? { cursor: "all", preventScroll: true } : { preventScroll: true };
|
||||
if (instance?.focus) {
|
||||
instance.focus(opts);
|
||||
return;
|
||||
}
|
||||
|
||||
input?.focus(opts);
|
||||
if (selectText) input?.select();
|
||||
if (shouldSelect) input?.select();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user