feat: 补齐素材文件夹、挂号改期、系统配置分组与工作台简报开关
素材库按 object_key 目录同步文件夹侧栏,选图和筛选可按目录收窄。 挂号列表支持管理端代改期,号源日历与患者端同源。 系统配置按端拆模块(患者首页展示、医生默认常用入口、工作台简报总开关、处方签名),旧菜单 key 做迁移以免刷新丢选中。 AI 生成记录补每日简报场景和缓存命中筛选;个人中心改走后端菜单 Profile。
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FileFolderItem } from '#/api/core/file-folder';
|
||||
import type {
|
||||
FileGallerySidebarTab,
|
||||
FileGalleryTab,
|
||||
} from '#/composables/use-file-gallery-filter';
|
||||
|
||||
/**
|
||||
* 素材库左侧栏:分类(xk_file_group)与文件夹(由 object_key 按 / 拆目录)互斥筛选
|
||||
* 文件夹按 pid 组成树,父级可折叠;列表区有最大高度,超出滚动
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
FolderAddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Button, Input, message, Modal, Tabs } from 'ant-design-vue';
|
||||
|
||||
import { getFileFolderList } from '#/api/core/file-folder';
|
||||
import { createFileGroup } from '#/api/core/file-group';
|
||||
import {
|
||||
ALL_FOLDER_VALUE,
|
||||
UNFILED_FOLDER_VALUE,
|
||||
} from '#/composables/use-file-gallery-filter';
|
||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
||||
|
||||
defineOptions({ name: 'FileGalleryFolderSidebar' });
|
||||
|
||||
const props = defineProps<{
|
||||
activeFolderId: number;
|
||||
activeGroupId: number;
|
||||
groupTabs: FileGalleryTab[];
|
||||
sidebarTab: FileGallerySidebarTab;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectFolder: [id: number];
|
||||
selectGroup: [id: number];
|
||||
'update:sidebarTab': [tab: FileGallerySidebarTab];
|
||||
}>();
|
||||
|
||||
const { invalidateGroupOptions } = useFileGroupCache();
|
||||
const folders = ref<FileFolderItem[]>([]);
|
||||
const expandedIds = ref<number[]>([]);
|
||||
const createOpen = ref(false);
|
||||
const createName = ref('');
|
||||
const creating = ref(false);
|
||||
|
||||
function unwrapList(res: any): FileFolderItem[] {
|
||||
const data = res?.result ?? res?.data ?? res;
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return Array.isArray(data?.items) ? data.items : [];
|
||||
}
|
||||
|
||||
/** 按 pid 分组,根目录 pid=0 */
|
||||
const childrenByPid = computed(() => {
|
||||
const map = new Map<number, FileFolderItem[]>();
|
||||
for (const item of folders.value) {
|
||||
const pid = item.pid > 0 ? item.pid : 0;
|
||||
const list = map.get(pid) ?? [];
|
||||
list.push(item);
|
||||
map.set(pid, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** 当前展开状态下可见的文件夹行(含子节点标记) */
|
||||
const visibleFolderRows = computed(() => {
|
||||
const rows: { hasChildren: boolean; item: FileFolderItem }[] = [];
|
||||
const walk = (pid: number) => {
|
||||
for (const item of childrenByPid.value.get(pid) ?? []) {
|
||||
const hasChildren = (childrenByPid.value.get(item.id) ?? []).length > 0;
|
||||
rows.push({ item, hasChildren });
|
||||
if (hasChildren && expandedIds.value.includes(item.id)) {
|
||||
walk(item.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(0);
|
||||
return rows;
|
||||
});
|
||||
|
||||
function isExpanded(id: number) {
|
||||
return expandedIds.value.includes(id);
|
||||
}
|
||||
|
||||
/** 点箭头只折叠/展开,不切换当前筛选 */
|
||||
function toggleExpand(id: number) {
|
||||
if (isExpanded(id)) {
|
||||
expandedIds.value = expandedIds.value.filter((item) => item !== id);
|
||||
return;
|
||||
}
|
||||
expandedIds.value = [...expandedIds.value, id];
|
||||
}
|
||||
|
||||
/** 选中深层文件夹时把祖先展开,避免当前项被藏起来 */
|
||||
function expandAncestors(folderId: number) {
|
||||
const byId = new Map(folders.value.map((item) => [item.id, item]));
|
||||
const next = new Set(expandedIds.value);
|
||||
let current = byId.get(folderId);
|
||||
while (current && current.pid > 0) {
|
||||
next.add(current.pid);
|
||||
current = byId.get(current.pid);
|
||||
}
|
||||
expandedIds.value = [...next];
|
||||
}
|
||||
|
||||
/** 拉文件夹树:后端会先按 object_key 同步目录 */
|
||||
async function loadFolders() {
|
||||
const res = await getFileFolderList();
|
||||
folders.value = unwrapList(res);
|
||||
if (props.activeFolderId > 0) {
|
||||
expandAncestors(props.activeFolderId);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(key: number | string) {
|
||||
const tab: FileGallerySidebarTab = key === 'folder' ? 'folder' : 'group';
|
||||
emit('update:sidebarTab', tab);
|
||||
if (tab === 'folder') {
|
||||
void loadFolders();
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createName.value = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
/** 仅分类 Tab 支持新建;文件夹由 OSS 路径自动生成 */
|
||||
async function submitCreate() {
|
||||
const name = createName.value.trim();
|
||||
if (!name) {
|
||||
message.warning('请填写分类名称');
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
try {
|
||||
await createFileGroup({ name, sort: 0 });
|
||||
message.success('已新建分类');
|
||||
createOpen.value = false;
|
||||
await invalidateGroupOptions();
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activeFolderId,
|
||||
(id) => {
|
||||
if (id > 0) {
|
||||
expandAncestors(id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadFolders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="folder-panel">
|
||||
<Tabs
|
||||
class="sidebar-tabs"
|
||||
size="small"
|
||||
:active-key="sidebarTab"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<Tabs.TabPane key="group" tab="分类" />
|
||||
<Tabs.TabPane key="folder" tab="文件夹" />
|
||||
</Tabs>
|
||||
|
||||
<div class="folder-head">
|
||||
<span class="folder-title">{{
|
||||
sidebarTab === 'folder' ? '文件夹' : '分类'
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="sidebarTab === 'group'"
|
||||
size="small"
|
||||
type="text"
|
||||
@click="openCreate"
|
||||
>
|
||||
<FolderAddOutlined />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="sidebarTab === 'folder'" class="folder-hint">
|
||||
按上传路径 / 自动生成
|
||||
</div>
|
||||
|
||||
<div v-if="sidebarTab === 'group'" class="folder-list">
|
||||
<button
|
||||
v-for="tab in groupTabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
class="folder-item"
|
||||
:class="{ active: activeGroupId === tab.value }"
|
||||
@click="emit('selectGroup', tab.value)"
|
||||
>
|
||||
<FolderOpenOutlined
|
||||
v-if="activeGroupId === tab.value"
|
||||
class="folder-icon"
|
||||
/>
|
||||
<FolderOutlined v-else class="folder-icon" />
|
||||
<span class="folder-name">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="folder-list">
|
||||
<button
|
||||
type="button"
|
||||
class="folder-item"
|
||||
:class="{ active: activeFolderId === ALL_FOLDER_VALUE }"
|
||||
@click="emit('selectFolder', ALL_FOLDER_VALUE)"
|
||||
>
|
||||
<span class="folder-caret is-placeholder"></span>
|
||||
<FolderOpenOutlined
|
||||
v-if="activeFolderId === ALL_FOLDER_VALUE"
|
||||
class="folder-icon"
|
||||
/>
|
||||
<FolderOutlined v-else class="folder-icon" />
|
||||
<span class="folder-name">全部文件夹</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="folder-item"
|
||||
:class="{ active: activeFolderId === UNFILED_FOLDER_VALUE }"
|
||||
@click="emit('selectFolder', UNFILED_FOLDER_VALUE)"
|
||||
>
|
||||
<span class="folder-caret is-placeholder"></span>
|
||||
<InboxOutlined class="folder-icon" />
|
||||
<span class="folder-name">未入文件夹</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="row in visibleFolderRows"
|
||||
:key="row.item.id"
|
||||
type="button"
|
||||
class="folder-item folder-tree-item"
|
||||
:class="{ active: activeFolderId === row.item.id }"
|
||||
:style="{ paddingLeft: `${10 + row.item.depth * 14}px` }"
|
||||
:title="row.item.path"
|
||||
@click="emit('selectFolder', row.item.id)"
|
||||
>
|
||||
<span
|
||||
class="folder-caret"
|
||||
:class="{ 'is-placeholder': !row.hasChildren }"
|
||||
@click.stop="row.hasChildren && toggleExpand(row.item.id)"
|
||||
>
|
||||
<CaretDownOutlined
|
||||
v-if="row.hasChildren && isExpanded(row.item.id)"
|
||||
/>
|
||||
<CaretRightOutlined v-else-if="row.hasChildren" />
|
||||
</span>
|
||||
<FolderOpenOutlined
|
||||
v-if="activeFolderId === row.item.id"
|
||||
class="folder-icon"
|
||||
/>
|
||||
<FolderOutlined v-else class="folder-icon" />
|
||||
<span class="folder-name">{{ row.item.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
v-model:open="createOpen"
|
||||
title="新建分类"
|
||||
ok-text="创建"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="creating"
|
||||
@ok="submitCreate"
|
||||
>
|
||||
<Input
|
||||
v-model:value="createName"
|
||||
placeholder="例如:轮播图、证件照"
|
||||
@press-enter="submitCreate"
|
||||
/>
|
||||
</Modal>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.folder-panel {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
width: 228px;
|
||||
max-height: calc(100vh - 240px);
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.folder-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.folder-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.folder-hint {
|
||||
flex-shrink: 0;
|
||||
margin: -4px 4px 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-color: hsl(var(--primary) / 30%);
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-caret {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 10px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
|
||||
&:not(.is-placeholder) {
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.folder-panel {
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user