|
|
@@ -0,0 +1,282 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import type { ExperienceCategory, ExperienceVO } from '#/api/outcome';
|
|
|
+
|
|
|
+import { computed, h, ref, shallowRef, triggerRef, watch } from 'vue';
|
|
|
+
|
|
|
+import { Page } from '@vben/common-ui';
|
|
|
+import { Plus } from '@vben/icons';
|
|
|
+
|
|
|
+import { watchDebounced } from '@vueuse/core';
|
|
|
+import {
|
|
|
+ Button,
|
|
|
+ Empty,
|
|
|
+ Input,
|
|
|
+ message,
|
|
|
+ Modal,
|
|
|
+ Pagination,
|
|
|
+ Select,
|
|
|
+ Spin,
|
|
|
+} from 'ant-design-vue';
|
|
|
+import { storeToRefs } from 'pinia';
|
|
|
+
|
|
|
+import { useShell } from '#/adapter/shell';
|
|
|
+import { invokeMethod } from '#/adapter/vxe-table/proxy/invoke-method';
|
|
|
+import {
|
|
|
+ deleteExperienceMethod,
|
|
|
+ EXPERIENCE_CATEGORY_OPTIONS,
|
|
|
+ getExperienceCategoryLabel,
|
|
|
+ listExperienceMethod,
|
|
|
+ parseExperienceTags,
|
|
|
+} from '#/api/outcome';
|
|
|
+import { useWorkroomStore } from '#/stores';
|
|
|
+
|
|
|
+import ExperienceCard from './components/ExperienceCard.vue';
|
|
|
+import ExperienceEdit from './modules/ExperienceEdit.vue';
|
|
|
+
|
|
|
+const workroomStore = useWorkroomStore();
|
|
|
+const { workroomId } = storeToRefs(workroomStore);
|
|
|
+
|
|
|
+const keyword = ref('');
|
|
|
+const searchKeyword = ref('');
|
|
|
+const category = ref<'' | ExperienceCategory>('');
|
|
|
+const pageNum = ref(1);
|
|
|
+const pageSize = ref(20);
|
|
|
+const loading = ref(false);
|
|
|
+const pageData = ref<{ items: ExperienceVO[]; total: number }>({
|
|
|
+ total: 0,
|
|
|
+ items: [],
|
|
|
+});
|
|
|
+
|
|
|
+const deletingIds = shallowRef(new Set<string>());
|
|
|
+
|
|
|
+const categoryOptions = [
|
|
|
+ { label: '全部类别', value: '' },
|
|
|
+ ...EXPERIENCE_CATEGORY_OPTIONS,
|
|
|
+];
|
|
|
+
|
|
|
+async function loadList() {
|
|
|
+ if (!workroomId.value) {
|
|
|
+ pageData.value = { total: 0, items: [] };
|
|
|
+ loading.value = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.value = true;
|
|
|
+ try {
|
|
|
+ pageData.value = await invokeMethod(
|
|
|
+ listExperienceMethod(pageNum.value, pageSize.value, {
|
|
|
+ $filters: [],
|
|
|
+ $sorts: [],
|
|
|
+ keyword: searchKeyword.value || undefined,
|
|
|
+ workroomId: workroomId.value,
|
|
|
+ category: category.value || undefined,
|
|
|
+ }),
|
|
|
+ { force: true },
|
|
|
+ );
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const items = computed(() => pageData.value.items);
|
|
|
+const total = computed(() => pageData.value.total);
|
|
|
+
|
|
|
+const [Edit, editApi] = useShell('modal', {
|
|
|
+ connectedComponent: ExperienceEdit,
|
|
|
+});
|
|
|
+
|
|
|
+function isDeleting(id?: string) {
|
|
|
+ return id ? deletingIds.value.has(id) : false;
|
|
|
+}
|
|
|
+
|
|
|
+function setDeleting(id: string, value: boolean) {
|
|
|
+ if (value) {
|
|
|
+ deletingIds.value.add(id);
|
|
|
+ } else {
|
|
|
+ deletingIds.value.delete(id);
|
|
|
+ }
|
|
|
+ triggerRef(deletingIds);
|
|
|
+}
|
|
|
+
|
|
|
+async function openCreate() {
|
|
|
+ const result = await editApi
|
|
|
+ .setData({
|
|
|
+ workroomId: workroomId.value,
|
|
|
+ category: 'clinical_experience',
|
|
|
+ } as ExperienceVO)
|
|
|
+ .open<{ id?: string }>(Promise.withResolvers());
|
|
|
+ if (result?.id) {
|
|
|
+ pageNum.value = 1;
|
|
|
+ await loadList();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function openEdit(row: ExperienceVO) {
|
|
|
+ const result = await editApi
|
|
|
+ .setData(row)
|
|
|
+ .open<{ id?: string }>(Promise.withResolvers());
|
|
|
+ if (result?.id) {
|
|
|
+ await loadList();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function formatDate(value?: string) {
|
|
|
+ if (!value) return '';
|
|
|
+ return String(value).slice(0, 10);
|
|
|
+}
|
|
|
+
|
|
|
+function openView(row: ExperienceVO) {
|
|
|
+ if (row.fileUrl) {
|
|
|
+ window.open(row.fileUrl, '_blank');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const tags = parseExperienceTags(row.tags);
|
|
|
+ const meta = [
|
|
|
+ getExperienceCategoryLabel(row.category),
|
|
|
+ row.author ? `经验人:${row.author}` : '',
|
|
|
+ row.createdAt ? formatDate(row.createdAt) : '',
|
|
|
+ ]
|
|
|
+ .filter(Boolean)
|
|
|
+ .join(' · ');
|
|
|
+
|
|
|
+ Modal.info({
|
|
|
+ title: row.title,
|
|
|
+ width: 640,
|
|
|
+ okText: '关闭',
|
|
|
+ content: h('div', { class: 'pt-2' }, [
|
|
|
+ meta ? h('p', { class: 'mb-3 text-sm text-foreground/70' }, meta) : null,
|
|
|
+ tags.length > 0
|
|
|
+ ? h(
|
|
|
+ 'p',
|
|
|
+ { class: 'mb-4 text-sm text-foreground/70' },
|
|
|
+ `标签:${tags.join('、')}`,
|
|
|
+ )
|
|
|
+ : null,
|
|
|
+ h(
|
|
|
+ 'div',
|
|
|
+ { class: 'whitespace-pre-wrap text-sm leading-7 text-foreground/85' },
|
|
|
+ row.content || '暂无内容',
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function handleDelete(row: ExperienceVO) {
|
|
|
+ if (!row.id || isDeleting(row.id)) return;
|
|
|
+ setDeleting(row.id, true);
|
|
|
+ try {
|
|
|
+ await invokeMethod(deleteExperienceMethod(row), { force: true });
|
|
|
+ message.success('删除成功');
|
|
|
+ if (items.value.length <= 1 && pageNum.value > 1) {
|
|
|
+ pageNum.value -= 1;
|
|
|
+ }
|
|
|
+ await loadList();
|
|
|
+ } finally {
|
|
|
+ setDeleting(row.id, false);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function applySearch(value: string) {
|
|
|
+ const next = value.trim();
|
|
|
+ if (next === searchKeyword.value) return;
|
|
|
+ searchKeyword.value = next;
|
|
|
+ pageNum.value = 1;
|
|
|
+}
|
|
|
+
|
|
|
+watchDebounced(keyword, applySearch, { debounce: 300 });
|
|
|
+
|
|
|
+function onSearch() {
|
|
|
+ applySearch(keyword.value);
|
|
|
+}
|
|
|
+
|
|
|
+function onPageChange(page: number, size: number) {
|
|
|
+ pageNum.value = page;
|
|
|
+ pageSize.value = size;
|
|
|
+}
|
|
|
+
|
|
|
+watch(category, () => {
|
|
|
+ pageNum.value = 1;
|
|
|
+});
|
|
|
+
|
|
|
+watch(workroomId, () => {
|
|
|
+ pageNum.value = 1;
|
|
|
+});
|
|
|
+
|
|
|
+watch(
|
|
|
+ [pageNum, pageSize, searchKeyword, category, workroomId],
|
|
|
+ () => {
|
|
|
+ void loadList();
|
|
|
+ },
|
|
|
+ { immediate: true },
|
|
|
+);
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <Page auto-content-height title="经验管理">
|
|
|
+ <Edit />
|
|
|
+
|
|
|
+ <template #extra>
|
|
|
+ <Button type="primary" @click="openCreate()">
|
|
|
+ <Plus class="size-4" />
|
|
|
+ 添加经验
|
|
|
+ </Button>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div class="flex h-full flex-col gap-4">
|
|
|
+ <div class="flex flex-wrap items-center gap-3">
|
|
|
+ <Input.Search
|
|
|
+ v-model:value="keyword"
|
|
|
+ allow-clear
|
|
|
+ class="min-w-[240px] flex-1"
|
|
|
+ placeholder="搜索经验标题、标签或作者..."
|
|
|
+ @search="onSearch"
|
|
|
+ />
|
|
|
+ <Select
|
|
|
+ v-model:value="category"
|
|
|
+ :options="categoryOptions"
|
|
|
+ class="w-36"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Spin :spinning="loading">
|
|
|
+ <div class="min-h-48">
|
|
|
+ <Empty
|
|
|
+ v-if="!loading && items.length === 0"
|
|
|
+ :image="Empty.PRESENTED_IMAGE_SIMPLE"
|
|
|
+ description="暂无经验数据"
|
|
|
+ >
|
|
|
+ <Button type="primary" @click="openCreate()">添加经验</Button>
|
|
|
+ </Empty>
|
|
|
+
|
|
|
+ <div v-else class="flex flex-col gap-4">
|
|
|
+ <ExperienceCard
|
|
|
+ v-for="item in items"
|
|
|
+ :key="item.id"
|
|
|
+ :data="item"
|
|
|
+ :deleting="isDeleting(item.id)"
|
|
|
+ @edit="openEdit"
|
|
|
+ @view="openView"
|
|
|
+ @delete="handleDelete"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Spin>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="total > 0"
|
|
|
+ class="mt-auto flex flex-wrap items-center justify-between gap-3 border-t pt-4"
|
|
|
+ >
|
|
|
+ <span class="text-sm text-foreground/70">共 {{ total }} 条记录</span>
|
|
|
+ <Pagination
|
|
|
+ :current="pageNum"
|
|
|
+ :page-size="pageSize"
|
|
|
+ :total="total"
|
|
|
+ :show-size-changer="false"
|
|
|
+ show-less-items
|
|
|
+ @change="onPageChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Page>
|
|
|
+</template>
|