소스 검색

feat(@six/wisdom-legacy): 成果管理 - 读书心得静态页面新增

Co-authored-by: Cursor <cursoragent@cursor.com>
cmj 2 일 전
부모
커밋
31cd0cb93f

+ 1 - 0
apps/wisdom-legacy/src/api/outcome/index.ts

@@ -4,6 +4,7 @@ export * from './medical-case-library.api';
 export * from './monograph.api';
 export * from './new-drug-certificate.api';
 export * from './paper.api';
+export * from './reading-note.api';
 export * from './research-report.api';
 export * from './treatment-plan.api';
 export * from './video.api';

+ 118 - 0
apps/wisdom-legacy/src/api/outcome/reading-note.api.ts

@@ -0,0 +1,118 @@
+import type { ReadingNoteSubmitVO, ReadingNoteVO } from './reading-note.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+
+import { getEnvelopeData, httpClient } from '#/request';
+import {
+  pageQueryArgsTransform,
+  paginateTransform,
+  transform,
+} from '#/request/schema';
+
+import {
+  mockDeleteReadingNoteMethod,
+  mockEditReadingNoteMethod,
+  mockGetReadingNoteMethod,
+  mockListReadingNoteMethod,
+  USE_READING_NOTE_MOCK,
+} from './reading-note.mock';
+import {
+  decodeReadingNote,
+  encodeReadingNote,
+  encodeReadingNoteQuery,
+} from './reading-note.schema';
+
+export { USE_READING_NOTE_MOCK } from './reading-note.mock';
+export type {
+  ReadingNoteQueryVO,
+  ReadingNoteSubmitVO,
+  ReadingNoteVO,
+} from './reading-note.schema';
+export { ReadingNoteVOSchema } from './reading-note.schema';
+
+/** 读书心得分页列表 */
+export function listReadingNoteMethod(...args: PageQueryMethodArgs) {
+  if (USE_READING_NOTE_MOCK) {
+    return mockListReadingNoteMethod(...args) as any;
+  }
+
+  const { params, data } = pageQueryArgsTransform(args, encodeReadingNoteQuery);
+  return httpClient.Post(
+    `/wis-pc/outcome/readingNoteManage/page`,
+    { ...params, ...data },
+    {
+      params,
+      hitSource: /^outcome-reading-note:(edit|delete)/,
+      transform: paginateTransform(decodeReadingNote),
+    },
+  );
+}
+
+/** 新增读书心得 */
+export function createReadingNoteMethod(vo: ReadingNoteSubmitVO) {
+  if (USE_READING_NOTE_MOCK) {
+    return mockEditReadingNoteMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/readingNoteManage/add`,
+    encodeReadingNote(vo),
+    {
+      name: 'outcome-reading-note:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 修改读书心得 */
+export function updateReadingNoteMethod(vo: ReadingNoteSubmitVO) {
+  if (USE_READING_NOTE_MOCK) {
+    return mockEditReadingNoteMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/readingNoteManage/update`,
+    encodeReadingNote(vo),
+    {
+      name: 'outcome-reading-note:edit',
+      transform: getEnvelopeData<null | string>,
+    },
+  );
+}
+
+/** 新增 / 修改读书心得 */
+export function editReadingNoteMethod(vo: ReadingNoteSubmitVO) {
+  return vo.id ? updateReadingNoteMethod(vo) : createReadingNoteMethod(vo);
+}
+
+/** 读书心得详情 */
+export function getReadingNoteMethod(vo: Partial<ReadingNoteVO>) {
+  if (USE_READING_NOTE_MOCK) {
+    return mockGetReadingNoteMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/readingNoteManage/detail/${vo.id}`,
+    {},
+    {
+      hitSource: /^outcome-reading-note:edit/,
+      transform: transform(decodeReadingNote),
+    },
+  );
+}
+
+/** 删除读书心得 */
+export function deleteReadingNoteMethod(vo: Pick<ReadingNoteVO, 'id'>) {
+  if (USE_READING_NOTE_MOCK) {
+    return mockDeleteReadingNoteMethod(vo) as any;
+  }
+
+  return httpClient.Post(
+    `/wis-pc/outcome/readingNoteManage/delete/${vo.id}`,
+    {},
+    {
+      name: 'outcome-reading-note:delete',
+      meta: { ignoreError: true },
+    },
+  );
+}

+ 235 - 0
apps/wisdom-legacy/src/api/outcome/reading-note.mock.ts

@@ -0,0 +1,235 @@
+import type {
+  ReadingNoteDTO,
+  ReadingNoteSubmitVO,
+  ReadingNoteVO,
+} from './reading-note.schema';
+
+import type { PageQueryMethodArgs } from '#/request/schema';
+import type { PageVO } from '#/request/schema/record';
+
+import { pageQueryArgsTransform } from '#/request/schema';
+
+import {
+  decodeReadingNote,
+  encodeReadingNote,
+  encodeReadingNoteQuery,
+} from './reading-note.schema';
+
+/** 后端接口就绪后改为 false */
+export const USE_READING_NOTE_MOCK = true;
+
+type MethodLike<T> = PromiseLike<T> & {
+  send?: (force?: boolean) => PromiseLike<T>;
+};
+
+const SEED_RECORDS: Omit<
+  ReadingNoteDTO,
+  'createTime' | 'id' | 'personalStudioId' | 'updateTime'
+>[] = [
+  {
+    insightTitle: '《黄帝内经》阴阳五行理论学习心得',
+    bookName: '《黄帝内经》',
+    authorName: '王医师',
+    noteDate: '2026-05-20',
+    noteContent:
+      '《黄帝内经》作为中医理论的奠基之作,其阴阳五行学说贯穿整个中医理论体系。通过深入学习,我对阴阳互根、五行生克有了更深刻的理解,这对临床辨证论治具有重要指导意义。',
+    agreeCount: 23,
+    createBy: '王医师',
+  },
+  {
+    insightTitle: '《伤寒论》六经辨证临床应用体会',
+    bookName: '《伤寒论》',
+    authorName: '李主任',
+    noteDate: '2026-05-18',
+    noteContent:
+      '六经辨证是《伤寒论》的核心内容。在临床实践中,太阳病与少阳病的鉴别尤为关键。结合具体病例,我总结了若干辨证要点,供同仁参考。',
+    agreeCount: 18,
+    createBy: '李主任',
+  },
+  {
+    insightTitle: '《金匮要略》杂病诊治思路梳理',
+    bookName: '《金匮要略》',
+    authorName: '张教授',
+    noteDate: '2026-05-15',
+    noteContent:
+      '《金匮要略》对杂病的论述系统而深入。本书心得重点梳理了虚劳、痰饮、水气等常见杂病的辨治思路,并结合现代临床进行了延伸思考。',
+    agreeCount: 31,
+    createBy: '张教授',
+  },
+  {
+    insightTitle: '《温病条辨》卫气营血辨证学习笔记',
+    bookName: '《温病条辨》',
+    authorName: '赵医师',
+    noteDate: '2026-05-12',
+    noteContent:
+      '温病学说是中医外感热病学的重要组成部分。吴鞠通的卫气营血辨证体系层次分明,对急性热病的诊治具有重要价值。',
+    agreeCount: 15,
+    createBy: '赵医师',
+  },
+  {
+    insightTitle: '《神农本草经》药物性味归经研读',
+    bookName: '《神农本草经》',
+    authorName: '陈教授',
+    noteDate: '2026-05-10',
+    noteContent:
+      '《神农本草经》是我国现存最早的本草学专著。通过系统研读,我对药物四气五味、升降浮沉及归经理论有了更系统的认识。',
+    agreeCount: 27,
+    createBy: '陈教授',
+  },
+  {
+    insightTitle: '《针灸大成》腧穴配伍心得',
+    bookName: '《针灸大成》',
+    authorName: '刘医师',
+    noteDate: '2026-05-08',
+    noteContent:
+      '《针灸大成》汇集历代针灸精华。本书心得重点记录了常用腧穴配伍规律及临床运用体会,对提高针灸疗效颇有帮助。',
+    agreeCount: 12,
+    createBy: '刘医师',
+  },
+];
+
+function createInitialStore(): ReadingNoteDTO[] {
+  const records: ReadingNoteDTO[] = [];
+  for (let index = 0; index < 36; index += 1) {
+    const seed = SEED_RECORDS[index % SEED_RECORDS.length] ?? SEED_RECORDS[0];
+    if (!seed) continue;
+    const day = String((index % 28) + 1).padStart(2, '0');
+    const month = String((index % 12) + 1).padStart(2, '0');
+    records.push({
+      ...seed,
+      id: String(index + 1),
+      personalStudioId: '327477138296832',
+      agreeCount: (seed.agreeCount ?? 0) + (index % 5),
+      createTime: `2026-${month}-${day}T10:00:00`,
+      updateTime: `2026-05-${day}T10:00:00`,
+    });
+  }
+  return records;
+}
+
+let nextId = 100;
+let store = createInitialStore();
+
+function delay<T>(runner: () => Promise<T> | T, ms = 120): MethodLike<T> {
+  const run = async () => {
+    await new Promise((resolve) => setTimeout(resolve, ms));
+    return runner();
+  };
+  const promise = run();
+  return Object.assign(promise, { send: run });
+}
+
+function matchKeyword(record: ReadingNoteDTO, keyword?: string) {
+  if (!keyword) return true;
+  const text = [
+    record.insightTitle,
+    record.bookName,
+    record.authorName,
+    record.createBy,
+  ]
+    .filter(Boolean)
+    .join(' ');
+  return text.includes(keyword);
+}
+
+function matchWorkroom(record: ReadingNoteDTO, workroomId?: string) {
+  if (!workroomId) return true;
+  return String(record.personalStudioId ?? '') === String(workroomId);
+}
+
+function sortRecords(records: ReadingNoteDTO[]) {
+  return records.toSorted((a, b) => {
+    const timeA = a.noteDate ? Date.parse(a.noteDate) : 0;
+    const timeB = b.noteDate ? Date.parse(b.noteDate) : 0;
+    return timeB - timeA;
+  });
+}
+
+function toVo(dto: ReadingNoteDTO): ReadingNoteVO {
+  return decodeReadingNote(dto);
+}
+
+export function mockListReadingNoteMethod(...args: PageQueryMethodArgs) {
+  const { params, data } = pageQueryArgsTransform(args, encodeReadingNoteQuery);
+  const pageNum = Number(params.pageNum ?? 1);
+  const pageSize = Number(params.pageSize ?? 20);
+  const keyword = data.mixture;
+  const workroomId = data.personalStudioId?.toString();
+
+  const filtered = sortRecords(
+    store.filter(
+      (record) =>
+        matchKeyword(record, keyword) && matchWorkroom(record, workroomId),
+    ),
+  );
+  const start = (pageNum - 1) * pageSize;
+  const items = filtered
+    .slice(start, start + pageSize)
+    .map((record) => toVo(record));
+
+  const result: PageVO<ReadingNoteVO> = {
+    total: filtered.length,
+    items,
+  };
+  return delay(() => result);
+}
+
+export function mockGetReadingNoteMethod(vo: Partial<ReadingNoteVO>) {
+  return delay(() => {
+    const record = store.find((item) => String(item.id) === String(vo.id));
+    if (!record) {
+      throw new Error('读书心得不存在');
+    }
+    return toVo(record);
+  });
+}
+
+export function mockEditReadingNoteMethod(vo: ReadingNoteSubmitVO) {
+  return delay(() => {
+    const dto = encodeReadingNote(vo);
+    const now = new Date().toISOString();
+
+    if (vo.id) {
+      const index = store.findIndex(
+        (item) => String(item.id) === String(vo.id),
+      );
+      if (index === -1) {
+        throw new Error('读书心得不存在');
+      }
+      store[index] = {
+        ...store[index],
+        ...dto,
+        updateTime: now,
+      };
+      return String(vo.id);
+    }
+
+    const id = String(nextId++);
+    store.unshift({
+      ...dto,
+      id,
+      agreeCount: 0,
+      createBy: dto.authorName ?? '当前用户',
+      createTime: now,
+      updateTime: now,
+    });
+    return id;
+  });
+}
+
+export function mockDeleteReadingNoteMethod(vo: Pick<ReadingNoteVO, 'id'>) {
+  return delay(() => {
+    const before = store.length;
+    store = store.filter((item) => String(item.id) !== String(vo.id));
+    if (store.length === before) {
+      throw new Error('读书心得不存在');
+    }
+    return null;
+  });
+}
+
+/** 仅用于本地调试,重置 mock 数据 */
+export function resetReadingNoteMockStore() {
+  nextId = 100;
+  store = createInitialStore();
+}

+ 109 - 0
apps/wisdom-legacy/src/api/outcome/reading-note.schema.ts

@@ -0,0 +1,109 @@
+import type {
+  AuditRecordDTO,
+  AuditRecordVO,
+} from '#/request/schema/audit-record';
+
+import { z } from '#/adapter/form';
+import { decodeList } from '#/request/schema';
+import { decodeAuditRecord } from '#/request/schema/audit-record';
+
+// ---------------------------------------------------------------------------
+// DTO
+// ---------------------------------------------------------------------------
+
+export interface ReadingNoteDTO extends AuditRecordDTO {
+  id?: number | string;
+  personalStudioId?: number | string;
+  insightTitle?: string;
+  bookName?: string;
+  authorName?: string;
+  noteDate?: string;
+  noteContent?: string;
+  agreeCount?: number;
+}
+
+export interface ReadingNoteQueryDTO {
+  mixture?: string;
+  personalStudioId?: number | string;
+  pageNum?: number;
+  pageSize?: number;
+}
+
+// ---------------------------------------------------------------------------
+// VO
+// ---------------------------------------------------------------------------
+
+export interface ReadingNoteVO extends AuditRecordVO {
+  id?: string;
+  workroomId: string;
+  title: string;
+  bookName: string;
+  author: string;
+  noteDate: string;
+  content: string;
+  likeCount?: number;
+}
+
+export type ReadingNoteSubmitVO = ReadingNoteVO;
+
+export interface ReadingNoteQueryVO {
+  keyword?: string;
+  workroomId?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+export const ReadingNoteVOSchema = z.object({
+  id: z.string().optional(),
+  workroomId: z.string().min(1, '工作室不能为空'),
+  title: z.string().min(1, '请输入心得标题'),
+  bookName: z.string().min(1, '请输入书名'),
+  author: z.string().min(1, '请输入心得作者'),
+  noteDate: z.string().min(1, '请选择日期'),
+  content: z.string().min(1, '请输入心得'),
+});
+
+// ---------------------------------------------------------------------------
+// 编解码
+// ---------------------------------------------------------------------------
+
+export function decodeReadingNote(dto: ReadingNoteDTO): ReadingNoteVO {
+  return {
+    ...decodeAuditRecord(dto),
+    id: dto.id?.toString(),
+    workroomId: dto.personalStudioId?.toString() ?? '',
+    title: dto.insightTitle ?? '',
+    bookName: dto.bookName ?? '',
+    author: dto.authorName ?? '',
+    noteDate: dto.noteDate ?? '',
+    content: dto.noteContent ?? '',
+    likeCount: dto.agreeCount ?? 0,
+  };
+}
+
+export function encodeReadingNoteQuery(
+  query: Partial<ReadingNoteQueryVO>,
+): ReadingNoteQueryDTO {
+  return {
+    mixture: query.keyword,
+    personalStudioId: query.workroomId,
+  };
+}
+
+export function encodeReadingNote(vo: ReadingNoteSubmitVO): ReadingNoteDTO {
+  return {
+    id: vo.id,
+    personalStudioId: vo.workroomId,
+    insightTitle: vo.title,
+    bookName: vo.bookName,
+    authorName: vo.author,
+    noteDate: vo.noteDate,
+    noteContent: vo.content,
+  };
+}
+
+export function decodeReadingNoteList(dto: ReadingNoteDTO[]) {
+  return decodeList(dto, decodeReadingNote);
+}

+ 2 - 1
apps/wisdom-legacy/src/router/routes/modules/outcome.route.ts

@@ -14,6 +14,7 @@ const hospitalPreparation = () =>
 const newDrugCertificate = () =>
   import('#/views/outcome/NewDrugCertificateList.vue');
 const video = () => import('#/views/outcome/VideoList.vue');
+const readingNote = () => import('#/views/outcome/ReadingNoteList.vue');
 
 const routes: RouteRecordRaw[] = [
   {
@@ -123,7 +124,7 @@ const routes: RouteRecordRaw[] = [
           icon: 'carbon:user',
           title: '读书心得',
         },
-        component: placeholder,
+        component: readingNote,
       },
       {
         path: '/outcome/experience',

+ 223 - 0
apps/wisdom-legacy/src/views/outcome/ReadingNoteList.vue

@@ -0,0 +1,223 @@
+<script setup lang="ts">
+import type { ReadingNoteVO } from '#/api/outcome';
+
+import { computed, h, ref, shallowRef, triggerRef, watch } from 'vue';
+
+import { Page } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import {
+  Button,
+  Empty,
+  Input,
+  message,
+  Modal,
+  Pagination,
+  Spin,
+} from 'ant-design-vue';
+import { storeToRefs } from 'pinia';
+
+import { useShell } from '#/adapter/shell';
+import { invokeMethod } from '#/adapter/vxe-table/proxy/invoke-method';
+import { deleteReadingNoteMethod, listReadingNoteMethod } from '#/api/outcome';
+import { useWorkroomStore } from '#/stores';
+
+import ReadingNoteCard from './components/ReadingNoteCard.vue';
+import ReadingNoteEdit from './modules/ReadingNoteEdit.vue';
+
+const workroomStore = useWorkroomStore();
+const { workroomId } = storeToRefs(workroomStore);
+
+const keyword = ref('');
+const searchKeyword = ref('');
+const pageNum = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const pageData = ref<{ items: ReadingNoteVO[]; total: number }>({
+  total: 0,
+  items: [],
+});
+
+const deletingIds = shallowRef(new Set<string>());
+
+async function loadList() {
+  if (!workroomId.value) {
+    pageData.value = { total: 0, items: [] };
+    loading.value = false;
+    return;
+  }
+
+  loading.value = true;
+  try {
+    pageData.value = await invokeMethod(
+      listReadingNoteMethod(pageNum.value, pageSize.value, {
+        $filters: [],
+        $sorts: [],
+        keyword: searchKeyword.value || undefined,
+        workroomId: workroomId.value,
+      }),
+      { force: true },
+    );
+  } finally {
+    loading.value = false;
+  }
+}
+
+const items = computed(() => pageData.value.items);
+const total = computed(() => pageData.value.total);
+
+const [Edit, editApi] = useShell('modal', {
+  connectedComponent: ReadingNoteEdit,
+});
+
+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 } as ReadingNoteVO)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    pageNum.value = 1;
+    await loadList();
+  }
+}
+
+async function openEdit(row: ReadingNoteVO) {
+  const result = await editApi
+    .setData(row)
+    .open<{ id?: string }>(Promise.withResolvers());
+  if (result?.id) {
+    await loadList();
+  }
+}
+
+function openView(row: ReadingNoteVO) {
+  Modal.info({
+    title: row.title,
+    width: 640,
+    okText: '关闭',
+    content: h('div', { class: 'pt-2' }, [
+      h(
+        'p',
+        { class: 'mb-4 text-sm text-foreground/70' },
+        `书名:${row.bookName} · 作者:${row.author} · ${row.noteDate}`,
+      ),
+      h(
+        'div',
+        { class: 'whitespace-pre-wrap text-sm leading-7 text-foreground/85' },
+        row.content || '暂无内容',
+      ),
+    ]),
+  });
+}
+
+async function handleDelete(row: ReadingNoteVO) {
+  if (!row.id || isDeleting(row.id)) return;
+  setDeleting(row.id, true);
+  try {
+    await invokeMethod(deleteReadingNoteMethod(row), { force: true });
+    message.success('删除成功');
+    if (items.value.length <= 1 && pageNum.value > 1) {
+      pageNum.value -= 1;
+    }
+    await loadList();
+  } finally {
+    setDeleting(row.id, false);
+  }
+}
+
+function onSearch() {
+  searchKeyword.value = keyword.value.trim();
+  pageNum.value = 1;
+}
+
+function onPageChange(page: number, size: number) {
+  pageNum.value = page;
+  pageSize.value = size;
+}
+
+watch(workroomId, () => {
+  pageNum.value = 1;
+});
+
+watch(
+  [pageNum, pageSize, searchKeyword, 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">
+      <Input.Search
+        v-model:value="keyword"
+        allow-clear
+        class="w-full"
+        placeholder="搜索心得标题、书名或作者..."
+        @search="onSearch"
+      />
+
+      <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">
+            <ReadingNoteCard
+              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>

+ 104 - 0
apps/wisdom-legacy/src/views/outcome/components/ReadingNoteCard.vue

@@ -0,0 +1,104 @@
+<script setup lang="ts">
+import type { ReadingNoteVO } from '#/api/outcome';
+
+import {
+  BookOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  EyeOutlined,
+  HeartOutlined,
+} from '@ant-design/icons-vue';
+import { Button, Card, Popconfirm } from 'ant-design-vue';
+
+const { data, deleting } = defineProps<{
+  data: ReadingNoteVO;
+  deleting?: boolean;
+}>();
+
+const emit = defineEmits<{
+  delete: [ReadingNoteVO];
+  edit: [ReadingNoteVO];
+  view: [ReadingNoteVO];
+}>();
+</script>
+
+<template>
+  <Card class="reading-note-card" :bordered="true">
+    <div class="flex gap-4">
+      <div
+        class="flex size-14 shrink-0 items-center justify-center rounded-lg bg-violet-100"
+      >
+        <BookOutlined class="text-2xl text-violet-600" />
+      </div>
+
+      <div class="min-w-0 flex-1">
+        <h3
+          class="mb-2 text-base font-semibold leading-6 text-foreground"
+          :title="data.title"
+        >
+          {{ data.title }}
+        </h3>
+
+        <p class="mb-2 text-sm text-foreground/70">
+          书名:{{ data.bookName }}
+          <span class="mx-1 text-foreground/40">·</span>
+          作者:{{ data.author }}
+          <span class="mx-1 text-foreground/40">·</span>
+          {{ data.noteDate }}
+        </p>
+
+        <p
+          v-if="data.content"
+          class="mb-3 line-clamp-2 text-sm text-foreground/55"
+        >
+          {{ data.content }}
+        </p>
+
+        <div
+          class="flex flex-wrap items-center justify-between gap-2 border-t pt-3"
+        >
+          <span
+            class="inline-flex items-center gap-1 text-sm text-foreground/55"
+          >
+            <HeartOutlined />
+            {{ data.likeCount ?? 0 }} 人赞同
+          </span>
+
+          <div class="flex flex-wrap items-center gap-1">
+            <Button type="link" class="!px-2" @click="emit('view', data)">
+              <EyeOutlined />
+              查看全文
+            </Button>
+            <Button
+              type="link"
+              class="!px-2 !text-primary"
+              @click="emit('edit', data)"
+            >
+              <EditOutlined />
+              编辑
+            </Button>
+            <Popconfirm
+              title="确定删除该读书心得吗?"
+              ok-text="确定"
+              cancel-text="取消"
+              :ok-button-props="{ loading: deleting, danger: true }"
+              :cancel-button-props="{ disabled: deleting }"
+              @confirm="emit('delete', data)"
+            >
+              <Button type="link" danger class="!px-2" :disabled="deleting">
+                <DeleteOutlined />
+                删除
+              </Button>
+            </Popconfirm>
+          </div>
+        </div>
+      </div>
+    </div>
+  </Card>
+</template>
+
+<style scoped>
+.reading-note-card :deep(.ant-card-body) {
+  padding: 20px 24px;
+}
+</style>

+ 37 - 0
apps/wisdom-legacy/src/views/outcome/modules/ReadingNoteEdit.vue

@@ -0,0 +1,37 @@
+<script setup lang="ts">
+import type { ReadingNoteSubmitVO } from '#/api/outcome';
+
+import { message } from 'ant-design-vue';
+
+import { useEditShell } from '#/adapter/shell';
+import { useWorkroomStore } from '#/stores';
+
+import { readingNoteForm } from '../reading-note.data';
+
+const workroomStore = useWorkroomStore();
+
+const { Form, Shell, api } = useEditShell<ReadingNoteSubmitVO>(
+  readingNoteForm,
+  {
+    onLoaded(model) {
+      api.shell.setState({
+        title: model.id ? '编辑读书心得' : '发布读书心得',
+      });
+    },
+    handleSubmit(values) {
+      const workroomId = values.workroomId || workroomStore.workroomId;
+      if (!workroomId) {
+        message.error('请先选择工作室');
+        throw new Error('workroom required');
+      }
+      return { ...values, workroomId };
+    },
+  },
+);
+</script>
+
+<template>
+  <Shell>
+    <Form class="mx-4" />
+  </Shell>
+</template>

+ 88 - 0
apps/wisdom-legacy/src/views/outcome/reading-note.data.ts

@@ -0,0 +1,88 @@
+import type { ReadingNoteVO } from '#/api/outcome';
+
+import { getPopupContainer } from '@vben/utils';
+
+import { defineEditShell } from '#/adapter/shell/edit';
+import {
+  editReadingNoteMethod,
+  getReadingNoteMethod,
+  ReadingNoteVOSchema,
+} from '#/api/outcome';
+
+export const readingNoteForm = defineEditShell<ReadingNoteVO>({
+  scope: 'outcome.readingNote',
+  title: '读书心得',
+  submit: editReadingNoteMethod,
+  load: getReadingNoteMethod,
+  shell: {
+    type: 'modal',
+    class: '!w-[640px]',
+    confirmText: '确定',
+  },
+  form: {
+    layout: 'vertical',
+    wrapperClass: 'grid-cols-1',
+  },
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'title',
+      label: '心得标题',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ReadingNoteVOSchema.shape.title,
+    },
+    {
+      component: 'Input',
+      fieldName: 'bookName',
+      label: '书名',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ReadingNoteVOSchema.shape.bookName,
+    },
+    {
+      component: 'Input',
+      fieldName: 'author',
+      label: '心得作者',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      rules: ReadingNoteVOSchema.shape.author,
+    },
+    {
+      component: 'DatePicker',
+      fieldName: 'noteDate',
+      label: '日期',
+      componentProps: {
+        class: 'w-full',
+        format: 'YYYY-MM-DD',
+        placeholder: '年/月/日',
+        valueFormat: 'YYYY-MM-DD',
+        getPopupContainer,
+      },
+      rules: ReadingNoteVOSchema.shape.noteDate,
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'content',
+      label: '心得',
+      componentProps: {
+        placeholder: '请输入',
+        rows: 6,
+        showCount: false,
+      },
+      rules: ReadingNoteVOSchema.shape.content,
+    },
+    {
+      component: 'Input',
+      fieldName: 'workroomId',
+      dependencies: {
+        show: false,
+        triggerFields: ['workroomId'],
+      },
+      rules: ReadingNoteVOSchema.shape.workroomId,
+    },
+  ],
+});