20260802220544

This commit is contained in:
oneao committed 2026-08-02 22:05:44 +08:00
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();
};
}