feat: 补齐素材文件夹、挂号改期、系统配置分组与工作台简报开关

素材库按 object_key 目录同步文件夹侧栏,选图和筛选可按目录收窄。
挂号列表支持管理端代改期,号源日历与患者端同源。
系统配置按端拆模块(患者首页展示、医生默认常用入口、工作台简报总开关、处方签名),旧菜单 key 做迁移以免刷新丢选中。
AI 生成记录补每日简报场景和缓存命中筛选;个人中心改走后端菜单 Profile。
This commit is contained in:
李琦
2026-08-18 09:12:07 +08:00
parent c6d2b0f917
commit d159e514b2
33 changed files with 4342 additions and 1133 deletions

View File

@@ -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