银川
1.API密钥管理页面开发联调
2.模型管理页面开发联调
3.知识库管理页面开发联调
4.通知公告页面开发联调
已添加5个文件
已修改11个文件
1070 ■■■■ 文件已修改
src/api/ai/knowledge/document/index.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ai/knowledge/segment/index.ts 46 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm/oa/notice/index.ts 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/knowledge/document/data.ts 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/knowledge/document/index.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/knowledge/knowledge/retrieval/index.vue 53 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/knowledge/segment/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/knowledge/segment/index.vue 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/model/model/data.ts 25 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/oa/notice/data.ts 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/oa/notice/detail.vue 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/oa/notice/index.vue 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/oa/notice/modules/form.vue 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/detail.vue 26 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/notify/message/data.ts 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/notify/my/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ai/knowledge/document/index.ts
@@ -7,11 +7,42 @@
    id: number; // ç¼–号
    knowledgeId: number; // çŸ¥è¯†åº“编号
    name: string; // æ–‡æ¡£åç§°
    url: string; // æ–‡æ¡£ URL
    contentLength: number; // å­—符数
    tokens: number; // token æ•°
    segmentMaxTokens: number; // åˆ†ç‰‡æœ€å¤§ token æ•°
    retrievalCount: number; // å¬å›žæ¬¡æ•°
    status: number; // æ˜¯å¦å¯ç”¨
  }
  export interface CreateDocumentParams {
    knowledgeId: number;
    name: string;
    url: string;
  }
  export interface CreateDocumentListParams {
    knowledgeId: number;
    segmentMaxTokens: number;
    list: Array<{ name: string; url: string }>;
  }
  export interface UpdateDocumentParams {
    id: number;
    name?: string;
    url?: string;
    segmentMaxTokens?: number;
  }
  export interface UpdateDocumentStatusParams {
    id: number;
    status: number;
  }
  export interface DocumentProgress {
    documentId: number;
    count: number;
    embeddingCount: number;
  }
}
@@ -24,26 +55,34 @@
/** æŸ¥è¯¢çŸ¥è¯†åº“文档详情 */
export function getKnowledgeDocument(id: number) {
  return requestClient.get(`/ai/knowledge/document/get?id=${id}`);
  return requestClient.get<AiKnowledgeDocumentApi.KnowledgeDocument>(
    `/ai/knowledge/document/get?id=${id}`,
  );
}
/** æ–°å¢žçŸ¥è¯†åº“文档(单个) */
export function createKnowledge(data: any) {
export function createKnowledge(data: AiKnowledgeDocumentApi.CreateDocumentParams) {
  return requestClient.post('/ai/knowledge/document/create', data);
}
/** æ–°å¢žçŸ¥è¯†åº“文档(多个) */
export function createKnowledgeDocumentList(data: any) {
export function createKnowledgeDocumentList(
  data: AiKnowledgeDocumentApi.CreateDocumentListParams,
) {
  return requestClient.post('/ai/knowledge/document/create-list', data);
}
/** ä¿®æ”¹çŸ¥è¯†åº“文档 */
export function updateKnowledgeDocument(data: any) {
export function updateKnowledgeDocument(
  data: AiKnowledgeDocumentApi.UpdateDocumentParams,
) {
  return requestClient.put('/ai/knowledge/document/update', data);
}
/** ä¿®æ”¹çŸ¥è¯†åº“文档状态 */
export function updateKnowledgeDocumentStatus(data: any) {
export function updateKnowledgeDocumentStatus(
  data: AiKnowledgeDocumentApi.UpdateDocumentStatusParams,
) {
  return requestClient.put('/ai/knowledge/document/update-status', data);
}
@@ -51,3 +90,16 @@
export function deleteKnowledgeDocument(id: number) {
  return requestClient.delete(`/ai/knowledge/document/delete?id=${id}`);
}
/** å¤„理文档(分段+向量化) */
export function processDocument(id: number) {
  return requestClient.post(`/ai/knowledge/document/process/${id}`);
}
/** æŸ¥è¯¢æ–‡æ¡£å¤„理进度 */
export function getDocumentProgress(documentIds: number[]) {
  return requestClient.get<AiKnowledgeDocumentApi.DocumentProgress[]>(
    '/ai/knowledge/document/progress',
    { params: { documentIds: documentIds.join(',') } },
  );
}
src/api/ai/knowledge/segment/index.ts
@@ -14,6 +14,27 @@
    retrievalCount: number; // å¬å›žæ¬¡æ•°
    status: number; // æ–‡æ¡£çŠ¶æ€
    createTime: number; // åˆ›å»ºæ—¶é—´
    score?: number; // ç›¸ä¼¼åº¦åˆ†æ•°ï¼ˆ0~1),仅搜索接口返回
    documentName?: string; // æ¥æºæ–‡æ¡£åç§°ï¼Œä»…搜索接口返回
  }
  export interface SearchParams {
    knowledgeId: number;
    content: string;
    topK?: number;
    similarityThreshold?: number;
  }
  export interface SplitResult {
    content: string;
    contentLength: number;
    tokens: number;
  }
  export interface ProcessInfo {
    documentId: number;
    count: number;
    embeddingCount: number;
  }
}
@@ -61,21 +82,30 @@
/** åˆ‡ç‰‡å†…容 */
export function splitContent(url: string, segmentMaxTokens: number) {
  return requestClient.get('/ai/knowledge/segment/split', {
  return requestClient.get<AiKnowledgeSegmentApi.SplitResult[]>(
    '/ai/knowledge/segment/split',
    {
    params: { url, segmentMaxTokens },
  });
    },
  );
}
/** èŽ·å–æ–‡æ¡£å¤„ç†åˆ—è¡¨ */
export function getKnowledgeSegmentProcessList(documentIds: number[]) {
  return requestClient.get('/ai/knowledge/segment/get-process-list', {
  return requestClient.get<AiKnowledgeSegmentApi.ProcessInfo[]>(
    '/ai/knowledge/segment/get-process-list',
    {
    params: { documentIds: documentIds.join(',') },
  });
    },
  );
}
/** æœç´¢çŸ¥è¯†åº“分段 */
export function searchKnowledgeSegment(params: any) {
  return requestClient.get('/ai/knowledge/segment/search', {
/** æœç´¢çŸ¥è¯†åº“分段(RAG å‘量检索) */
export function searchKnowledgeSegment(
  params: AiKnowledgeSegmentApi.SearchParams,
) {
  return requestClient.post<AiKnowledgeSegmentApi.KnowledgeSegment[]>(
    '/ai/knowledge/segment/search',
    params,
  });
  );
}
src/api/bpm/oa/notice/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace OaNoticeApi {
  export interface NoticeVO {
    id: number;
    title: string;
    content: string;
    type: number;
    status: number;
    sendScope: number;
    isTop: number;
    publishUserId: number;
    publishTime: string;
    createTime: string;
    userIds?: number[];
  }
  export interface NoticeSaveReqVO {
    id?: number;
    title: string;
    content: string;
    type: number;
    status: number;
    sendScope: number;
    isTop?: number;
    userIds?: number[];
  }
}
const BASE = '/bpm/oa/notice';
/** åˆ†é¡µæŸ¥è¯¢é€šçŸ¥å…¬å‘Š */
export function getNoticePage(params: PageParam) {
  return requestClient.get<PageResult<OaNoticeApi.NoticeVO>>(`${BASE}/page`, {
    params,
  });
}
/** æŸ¥è¯¢é€šçŸ¥å…¬å‘Šè¯¦æƒ…(调用即标记已读) */
export function getNotice(id: number) {
  return requestClient.get<OaNoticeApi.NoticeVO>(`${BASE}/get`, {
    params: { id },
  });
}
/** åˆ›å»ºé€šçŸ¥å…¬å‘Š */
export function createNotice(data: OaNoticeApi.NoticeSaveReqVO) {
  return requestClient.post(`${BASE}/create`, data);
}
/** ä¿®æ”¹é€šçŸ¥å…¬å‘Š */
export function updateNotice(data: OaNoticeApi.NoticeSaveReqVO) {
  return requestClient.put(`${BASE}/update`, data);
}
/** åˆ é™¤é€šçŸ¥å…¬å‘Š */
export function deleteNotice(id: number) {
  return requestClient.delete(`${BASE}/delete`, { params: { id } });
}
/** èŽ·å–æˆ‘çš„æœªè¯»é€šçŸ¥å…¬å‘Šæ•°é‡ */
export function getMyUnreadNoticeCount() {
  return requestClient.get<number>(`${BASE}/my-unread-count`);
}
src/views/ai/knowledge/document/data.ts
@@ -2,90 +2,8 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiKnowledgeDocumentApi } from '#/api/ai/knowledge/document';
import { AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getModelSimpleList } from '#/api/ai/model/model';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      component: 'Input',
      fieldName: 'name',
      label: '知识库名称',
      rules: 'required',
    },
    {
      fieldName: 'description',
      label: '知识库描述',
      component: 'Textarea',
      componentProps: {
        rows: 3,
        placeholder: '请输入知识库描述',
      },
    },
    {
      component: 'ApiSelect',
      fieldName: 'embeddingModelId',
      label: '向量模型',
      componentProps: {
        api: () => getModelSimpleList(AiModelTypeEnum.EMBEDDING),
        labelField: 'name',
        valueField: 'id',
        allowClear: true,
        placeholder: '请选择向量模型',
      },
      rules: 'required',
    },
    {
      fieldName: 'topK',
      label: '检索 topK',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        placeholder: '请输入检索 topK',
        min: 0,
        max: 10,
      },
      rules: 'required',
    },
    {
      fieldName: 'similarityThreshold',
      label: '检索相似度阈值',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        placeholder: '请输入检索相似度阈值',
        min: 0,
        max: 1,
        step: 0.01,
        precision: 2,
      },
      rules: 'required',
    },
    {
      fieldName: 'status',
      label: '是否启用',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: z.number().default(CommonStatusEnum.ENABLE),
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
src/views/ai/knowledge/document/index.vue
@@ -15,6 +15,7 @@
import {
  deleteKnowledgeDocument,
  getKnowledgeDocumentPage,
  processDocument,
  updateKnowledgeDocumentStatus,
} from '#/api/ai/knowledge/document';
import { $t } from '#/locales';
@@ -69,6 +70,23 @@
    name: 'AiKnowledgeSegment',
    query: { documentId: id },
  });
}
/** å¤„理文档(分段+向量化) */
async function handleProcess(row: AiKnowledgeDocumentApi.KnowledgeDocument) {
  const hideLoading = message.loading({
    content: `正在处理文档【${row.name}】...`,
    duration: 0,
  });
  try {
    await processDocument(row.id!);
    message.success(`文档【${row.name}】处理成功`);
    handleRefresh();
  } catch {
    message.error(`文档【${row.name}】处理失败`);
  } finally {
    hideLoading();
  }
}
/** æ›´æ–°æ–‡æ¡£çŠ¶æ€ */
@@ -137,6 +155,11 @@
<template>
  <Page auto-content-height>
    <div class="mb-2">
      <a-button type="default" @click="router.back()">
        è¿”回上一页
      </a-button>
    </div>
    <Grid table-title="知识库文档列表">
      <template #toolbar-tools>
        <TableAction
@@ -169,6 +192,13 @@
              onClick: handleSegment.bind(null, row.id),
            },
            {
              label: '处理',
              type: 'link',
              icon: ACTION_ICON.REFRESH,
              auth: ['ai:knowledge:update'],
              onClick: handleProcess.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
src/views/ai/knowledge/knowledge/retrieval/index.vue
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { AiKnowledgeSegmentApi } from '#/api/ai/knowledge/segment';
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -17,15 +19,24 @@
import { getKnowledge } from '#/api/ai/knowledge/knowledge';
import { searchKnowledgeSegment } from '#/api/ai/knowledge/segment';
type RetrievalSegment = AiKnowledgeSegmentApi.KnowledgeSegment & {
  expanded?: boolean;
};
/** çŸ¥è¯†åº“文档召回测试 */
defineOptions({ name: 'KnowledgeDocumentRetrieval' });
const route = useRoute();
const router = useRouter();
const loading = ref(false); // åŠ è½½çŠ¶æ€
const segments = ref<any[]>([]); // å¬å›žç»“æžœ
const queryParams = reactive({
const loading = ref(false);
const segments = ref<RetrievalSegment[]>([]);
const queryParams = reactive<{
  id: number | undefined;
  content: string;
  topK: number;
  similarityThreshold: number;
}>({
  id: undefined,
  content: '',
  topK: 10,
@@ -38,6 +49,7 @@
    message.warning('请输入查询文本');
    return;
  }
  if (queryParams.id == null) return;
  loading.value = true;
  segments.value = [];
@@ -49,15 +61,22 @@
      topK: queryParams.topK,
      similarityThreshold: queryParams.similarityThreshold,
    });
    segments.value = data || [];
    segments.value = (data || []) as RetrievalSegment[];
  } finally {
    loading.value = false;
  }
}
/** åˆ‡æ¢æ®µè½å±•开状态 */
function toggleExpand(segment: any) {
function toggleExpand(segment: RetrievalSegment) {
  segment.expanded = !segment.expanded;
}
/** èŽ·å–ç›¸ä¼¼åº¦åˆ†æ•°çš„æ ·å¼ç±»å */
function getScoreClass(score: number) {
  if (score > 0.8) return 'bg-green-50 text-green-600';
  if (score >= 0.6) return 'bg-yellow-50 text-yellow-600';
  return 'bg-gray-100 text-gray-400';
}
/** èŽ·å–çŸ¥è¯†åº“é…ç½®ä¿¡æ¯ */
@@ -69,25 +88,30 @@
      queryParams.similarityThreshold =
        knowledge.similarityThreshold || queryParams.similarityThreshold;
    }
  } catch {}
  } catch {
    // èŽ·å–çŸ¥è¯†åº“é…ç½®å¤±è´¥ï¼Œä½¿ç”¨é»˜è®¤å€¼
  }
}
/** åˆå§‹åŒ– */
onMounted(() => {
  // å¦‚果知识库 ID ä¸å­˜åœ¨ï¼Œæ˜¾ç¤ºé”™è¯¯æç¤ºå¹¶å…³é—­é¡µé¢
  if (!route.query.id) {
    message.error('知识库 ID ä¸å­˜åœ¨ï¼Œæ— æ³•进行召回测试');
    router.back();
    return;
  }
  queryParams.id = route.query.id as any;
  // èŽ·å–çŸ¥è¯†åº“ä¿¡æ¯å¹¶è®¾ç½®é»˜è®¤å€¼
  getKnowledgeInfo(queryParams.id as any);
  const id = Number(route.query.id);
  queryParams.id = id;
  getKnowledgeInfo(id);
});
</script>
<template>
  <Page auto-content-height>
    <div class="mb-2">
      <a-button type="default" @click="router.back()">
        è¿”回上一页
      </a-button>
    </div>
    <div class="flex w-full gap-4">
      <Card class="w-3/4 flex-1">
        <div class="mb-15">
@@ -106,7 +130,7 @@
              placeholder="请输入文本"
            />
            <div class="absolute bottom-2 right-2 text-sm text-gray-400">
              {{ queryParams.content?.length }} / 200
              {{ queryParams.content?.length || 0 }} å­—符
            </div>
          </div>
          <div class="m-2 flex items-center">
@@ -165,9 +189,10 @@
                  {{ segment.tokens }} Token
                </span>
                <span
                  class="whitespace-nowrap rounded-full bg-blue-50 px-2 py-1 text-sm text-blue-500"
                  class="whitespace-nowrap rounded-full px-2 py-1 text-sm"
                  :class="getScoreClass(segment.score ?? 0)"
                >
                  score: {{ segment.score }}
                  score: {{ segment.score?.toFixed(4) }}
                </span>
              </div>
              <div
src/views/ai/knowledge/segment/data.ts
@@ -96,6 +96,11 @@
      minWidth: 120,
    },
    {
      field: 'vectorId',
      title: '向量ID',
      minWidth: 150,
    },
    {
      field: 'retrievalCount',
      title: '召回次数',
      minWidth: 100,
src/views/ai/knowledge/segment/index.vue
@@ -3,7 +3,7 @@
import type { AiKnowledgeSegmentApi } from '#/api/ai/knowledge/segment';
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
@@ -23,6 +23,7 @@
import Form from './modules/form.vue';
const route = useRoute();
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -117,6 +118,11 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <div class="mb-2">
      <a-button type="default" @click="router.back()">
        è¿”回上一页
      </a-button>
    </div>
    <Grid table-title="分段列表">
      <template #toolbar-tools>
        <TableAction
src/views/ai/model/model/data.ts
@@ -1,16 +1,10 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelApiKeyApi } from '#/api/ai/model/apiKey';
import { AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getApiKeySimpleList } from '#/api/ai/model/apiKey';
/** å…³è”数据 */
let apiKeyList: AiModelApiKeyApi.ApiKey[] = [];
getApiKeySimpleList().then((data) => (apiKeyList = data));
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -50,14 +44,12 @@
    },
    {
      fieldName: 'keyId',
      label: 'API ç§˜é’¥',
      component: 'ApiSelect',
      label: 'API ç§˜é’¥ ID',
      component: 'InputNumber',
      componentProps: {
        placeholder: '请选择 API ç§˜é’¥',
        api: getApiKeySimpleList,
        labelField: 'name',
        valueField: 'id',
        allowClear: true,
        class: '!w-full',
        placeholder: '请输入 API ç§˜é’¥ ID',
        min: 0,
      },
      rules: 'required',
    },
@@ -222,13 +214,8 @@
      minWidth: 180,
    },
    {
      title: 'API ç§˜é’¥',
      title: 'API ç§˜é’¥ ID',
      field: 'keyId',
      formatter: ({ cellValue }) => {
        return (
          apiKeyList.find((apiKey) => apiKey.id === cellValue)?.name || '-'
        );
      },
      minWidth: 140,
    },
    {
src/views/bpm/oa/notice/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,248 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
// ========== æœ¬åœ°æžšä¸¾å¸¸é‡ ==========
/** é€šçŸ¥ç±»åž‹ï¼š1=通知 2=公告 */
export const NOTICE_TYPE = {
  NOTICE: 1,
  ANNOUNCEMENT: 2,
} as const;
export const NOTICE_TYPE_OPTIONS = [
  { label: '通知', value: 1 },
  { label: '公告', value: 2 },
];
export const NOTICE_TYPE_TAG: Record<number, string> = {
  1: 'blue',
  2: 'orange',
};
/** é€šçŸ¥çŠ¶æ€ï¼š0=草稿 1=已发布 2=已撤销 */
export const NOTICE_STATUS = {
  DRAFT: 0,
  PUBLISHED: 1,
  REVOKED: 2,
} as const;
export const NOTICE_STATUS_OPTIONS = [
  { label: '草稿', value: 0 },
  { label: '已发布', value: 1 },
  { label: '已撤销', value: 2 },
];
export const NOTICE_STATUS_TAG: Record<number, string> = {
  0: 'default',
  1: 'green',
  2: 'red',
};
/** å‘送范围:1=通知所有人 2=指定人 */
export const SEND_SCOPE = {
  ALL: 1,
  SPECIFIC: 2,
} as const;
export const SEND_SCOPE_OPTIONS = [
  { label: '通知所有人', value: 1 },
  { label: '指定人', value: 2 },
];
export const SEND_SCOPE_MAP: Record<number, string> = {
  1: '通知所有人',
  2: '指定人',
};
// ========== è¡¨å• Schema ==========
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      component: 'Input',
      fieldName: 'title',
      label: '标题',
      rules: 'required',
      componentProps: {
        placeholder: '请输入标题',
        maxlength: 50,
        showCount: true,
      },
    },
    {
      fieldName: 'type',
      label: '类型',
      component: 'RadioGroup',
      componentProps: {
        options: NOTICE_TYPE_OPTIONS,
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: 'required',
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: {
        options: NOTICE_STATUS_OPTIONS,
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: z.number().default(NOTICE_STATUS.DRAFT),
    },
    {
      fieldName: 'sendScope',
      label: '发送范围',
      component: 'RadioGroup',
      componentProps: {
        options: SEND_SCOPE_OPTIONS,
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: 'required',
    },
    {
      fieldName: 'isTop',
      label: '是否置顶',
      component: 'RadioGroup',
      componentProps: {
        options: [
          { label: '否', value: 0 },
          { label: '是', value: 1 },
        ],
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: z.number().default(0),
    },
    {
      fieldName: 'content',
      label: '内容',
      component: 'Textarea',
      rules: 'required',
      componentProps: {
        rows: 6,
        placeholder: '请输入内容',
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'title',
      label: '标题',
      component: 'Input',
      componentProps: {
        placeholder: '请输入标题',
        allowClear: true,
      },
    },
    {
      fieldName: 'type',
      label: '类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择类型',
        allowClear: true,
        options: NOTICE_TYPE_OPTIONS,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        placeholder: '请选择状态',
        allowClear: true,
        options: NOTICE_STATUS_OPTIONS,
      },
    },
    {
      fieldName: 'sendScope',
      label: '发送范围',
      component: 'Select',
      componentProps: {
        placeholder: '请选择发送范围',
        allowClear: true,
        options: SEND_SCOPE_OPTIONS,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'id',
      title: '编号',
      minWidth: 80,
    },
    {
      field: 'isTop',
      title: '置顶',
      minWidth: 70,
      align: 'center',
      slots: { default: 'isTop' },
    },
    {
      field: 'title',
      title: '标题',
      minWidth: 220,
    },
    {
      field: 'type',
      title: '类型',
      minWidth: 80,
      align: 'center',
      slots: { default: 'type' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 90,
      align: 'center',
      slots: { default: 'status' },
    },
    {
      field: 'sendScope',
      title: '发送范围',
      minWidth: 110,
      align: 'center',
      slots: { default: 'sendScope' },
    },
    {
      field: 'publishTime',
      title: '发布时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/bpm/oa/notice/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,114 @@
<script lang="ts" setup>
import type { OaNoticeApi } from '#/api/bpm/oa/notice';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Card, Descriptions, message, Spin, Tag } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
import { getNotice } from '#/api/bpm/oa/notice';
import {
  NOTICE_STATUS_OPTIONS,
  NOTICE_STATUS_TAG,
  NOTICE_TYPE_OPTIONS,
  NOTICE_TYPE_TAG,
  SEND_SCOPE_MAP,
} from './data';
defineOptions({ name: 'OANoticeDetail' });
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const formData = ref<OaNoticeApi.NoticeVO>();
function getStatusLabel(status: number) {
  return NOTICE_STATUS_OPTIONS.find((o) => o.value === status)?.label || '-';
}
function getTypeLabel(type: number) {
  return NOTICE_TYPE_OPTIONS.find((o) => o.value === type)?.label || '-';
}
const noticeId = computed(() => Number(route.query.id));
async function loadDetail() {
  if (!noticeId.value) {
    message.error('通知公告 ID ä¸å­˜åœ¨');
    router.back();
    return;
  }
  loading.value = true;
  try {
    formData.value = await getNotice(noticeId.value);
  } finally {
    loading.value = false;
  }
}
onMounted(() => {
  loadDetail();
});
</script>
<template>
  <Page auto-content-height>
    <Spin :spinning="loading" tip="加载中...">
      <template v-if="formData">
        <div class="flex items-center gap-2 px-4 pt-3 pb-2">
          <a-button type="text" size="small" @click="router.back()">
            <template #icon>
              <IconifyIcon icon="ant-design:arrow-left-outlined" />
            </template>
          </a-button>
          <span class="text-lg font-bold">{{ formData.title }}</span>
          <Tag :color="NOTICE_TYPE_TAG[formData.type]">
            {{ getTypeLabel(formData.type) }}
          </Tag>
          <Tag :color="NOTICE_STATUS_TAG[formData.status]">
            {{ getStatusLabel(formData.status) }}
          </Tag>
        </div>
        <div class="px-4 pb-4">
          <Card size="small" class="mb-3">
            <Descriptions :column="2" bordered size="small">
              <Descriptions.Item label="发送范围">
                {{ SEND_SCOPE_MAP[formData.sendScope] || '-' }}
              </Descriptions.Item>
              <Descriptions.Item label="是否置顶">
                <Tag v-if="formData.isTop === 1" color="red">置顶</Tag>
                <span v-else>否</span>
              </Descriptions.Item>
              <Descriptions.Item label="发布时间">
                {{ formData.publishTime || '-' }}
              </Descriptions.Item>
              <Descriptions.Item label="创建时间">
                {{ formData.createTime || '-' }}
              </Descriptions.Item>
              <Descriptions.Item
                v-if="formData.sendScope === 2 && formData.userIds?.length"
                label="指定人员"
                :span="2"
              >
                {{ formData.userIds?.join(', ') }}
              </Descriptions.Item>
            </Descriptions>
          </Card>
          <Card size="small" title="内容">
            <div
              class="min-h-40 rounded bg-gray-50 p-4"
              v-dompurify-html="formData.content"
            />
          </Card>
        </div>
      </template>
    </Spin>
  </Page>
</template>
src/views/bpm/oa/notice/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,168 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { OaNoticeApi } from '#/api/bpm/oa/notice';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteNotice, getNoticePage } from '#/api/bpm/oa/notice';
import { $t } from '#/locales';
import { router } from '#/router';
import {
  NOTICE_STATUS_TAG,
  NOTICE_STATUS_OPTIONS,
  NOTICE_TYPE_TAG,
  NOTICE_TYPE_OPTIONS,
  SEND_SCOPE_MAP,
  useGridColumns,
  useGridFormSchema,
} from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData(null).open();
}
function handleEdit(row: OaNoticeApi.NoticeVO) {
  formModalApi.setData(row).open();
}
async function handleDelete(row: OaNoticeApi.NoticeVO) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.title]),
    duration: 0,
  });
  try {
    await deleteNotice(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.title]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
function handleDetail(row: OaNoticeApi.NoticeVO) {
  router.push({
    name: 'OANoticeDetail',
    query: { id: row.id },
  });
}
function getStatusLabel(status: number) {
  return NOTICE_STATUS_OPTIONS.find((o) => o.value === status)?.label || '-';
}
function getTypeLabel(type: number) {
  return NOTICE_TYPE_OPTIONS.find((o) => o.value === type)?.label || '-';
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getNoticePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<OaNoticeApi.NoticeVO>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="通知公告列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['通知公告']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['bpm:oa-notice:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #isTop="{ row }">
        <Tag v-if="row.isTop === 1" color="red">置顶</Tag>
      </template>
      <template #type="{ row }">
        <Tag :color="NOTICE_TYPE_TAG[row.type]">
          {{ getTypeLabel(row.type) }}
        </Tag>
      </template>
      <template #status="{ row }">
        <Tag :color="NOTICE_STATUS_TAG[row.status]">
          {{ getStatusLabel(row.status) }}
        </Tag>
      </template>
      <template #sendScope="{ row }">
        {{ SEND_SCOPE_MAP[row.sendScope] || '-' }}
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['bpm:oa-notice:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['bpm:oa-notice:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['bpm:oa-notice:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.title]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/bpm/oa/notice/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,108 @@
<script lang="ts" setup>
import type { OaNoticeApi } from '#/api/bpm/oa/notice';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createNotice, getNotice, updateNotice } from '#/api/bpm/oa/notice';
import { $t } from '#/locales';
import UserSelect from '#/views/system/user/components/select.vue';
import { SEND_SCOPE, useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<OaNoticeApi.NoticeVO>();
const selectedUserIds = ref<number[]>([]);
const currentSendScope = ref<number>(SEND_SCOPE.ALL);
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['通知公告'])
    : $t('ui.actionTitle.create', ['通知公告']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-2',
    labelWidth: 80,
  },
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
  handleValuesChange: (values) => {
    currentSendScope.value = (values.sendScope as number) ?? SEND_SCOPE.ALL;
  },
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    // å‘送范围为指定人时校验
    if (currentSendScope.value === SEND_SCOPE.SPECIFIC && selectedUserIds.value.length === 0) {
      message.warning('请选择指定人员');
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as OaNoticeApi.NoticeSaveReqVO;
    // åˆå¹¶æŒ‡å®šäººå‘˜
    if (currentSendScope.value === SEND_SCOPE.SPECIFIC) {
      data.userIds = selectedUserIds.value;
    }
    try {
      await (formData.value?.id ? updateNotice(data) : createNotice(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      selectedUserIds.value = [];
      currentSendScope.value = SEND_SCOPE.ALL;
      return;
    }
    const data = modalApi.getData<OaNoticeApi.NoticeVO>();
    if (!data || !data.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getNotice(data.id);
      await formApi.setValues(formData.value);
      selectedUserIds.value = formData.value.userIds ?? [];
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-1/2">
    <Form class="mx-4" />
    <div
      v-if="currentSendScope === SEND_SCOPE.SPECIFIC"
      class="mx-4 mt-4"
    >
      <div class="mb-2 text-sm text-gray-700">指定人员</div>
      <UserSelect
        v-model:value="selectedUserIds"
        :multiple="true"
        placeholder="请选择指定人员"
      />
    </div>
  </Modal>
</template>
src/views/srm/tender/detail.vue
@@ -452,7 +452,7 @@
              </template>
              <div class="flex items-center justify-between mb-3">
                <span class="text-xs text-gray-400">共 <b class="text-gray-600">{{ bidCount }}</b> ä¸ªæŠ•æ ‡</span>
                <a-button type="primary" size="small" @click="bidForm = { tenderProjectId: projectId, supplierId: 0, bidNo: '' }; bidFormVisible = true">
                <a-button v-if="!isAwarded" type="primary" size="small" @click="bidForm = { tenderProjectId: projectId, supplierId: 0, bidNo: '' }; bidFormVisible = true">
                  <template #icon><IconifyIcon icon="ant-design:plus-outlined" /></template>
                  æ–°å¢žæŠ•æ ‡
                </a-button>
@@ -470,7 +470,7 @@
                  <template v-if="column.key === 'actions'">
                    <a-space>
                      <a-button size="small" type="link" @click="loadQuotes(record.id); activeTab = 'quote'"><IconifyIcon icon="ant-design:dollar-outlined" />报价</a-button>
                      <a-popconfirm v-if="record.bidStatus === BID_STATUS.REGISTERED" title="确认撤标?" @confirm="handleWithdrawBid(record.id)">
                      <a-popconfirm v-if="!isAwarded && record.bidStatus === BID_STATUS.REGISTERED" title="确认撤标?" @confirm="handleWithdrawBid(record.id)">
                        <a-button size="small" type="link" danger><IconifyIcon icon="ant-design:close-circle-outlined" />撤标</a-button>
                      </a-popconfirm>
                    </a-space>
@@ -493,7 +493,7 @@
                  <a-button size="small" type="link" class="ml-auto" @click="activeTab = 'bid'">切换投标</a-button>
                </div>
                <div class="text-right mb-3">
                  <a-button v-if="selectedBid.bidStatus !== BID_STATUS.WITHDRAWN" type="primary" size="small" @click="handleAddQuote">
                  <a-button v-if="!isAwarded && selectedBid.bidStatus !== BID_STATUS.WITHDRAWN" type="primary" size="small" @click="handleAddQuote">
                    <template #icon><IconifyIcon icon="ant-design:plus-outlined" /></template>
                    æ–°å¢žæŠ¥ä»·
                  </a-button>
@@ -513,7 +513,7 @@
                      {{ record.warrantyPeriod != null ? `${record.warrantyPeriod} ä¸ªæœˆ` : '-' }}
                    </template>
                    <template v-if="column.key === 'actions'">
                      <a-button v-if="selectedBid?.bidStatus !== BID_STATUS.WITHDRAWN" size="small" type="link" @click="handleEditQuote(record)"><IconifyIcon icon="ant-design:edit-outlined" />编辑</a-button>
                      <a-button v-if="!isAwarded && selectedBid?.bidStatus !== BID_STATUS.WITHDRAWN" size="small" type="link" @click="handleEditQuote(record)"><IconifyIcon icon="ant-design:edit-outlined" />编辑</a-button>
                    </template>
                  </template>
                </a-table>
@@ -543,7 +543,7 @@
                <div class="text-center py-8">
                  <a-result status="info" title="尚未开标" sub-title="执行开标后项目进入投标阶段,供应商可正式投标" />
                  <br />
                  <a-button type="primary" @click="handleCreateBidOpen">
                  <a-button v-if="!isAwarded" type="primary" @click="handleCreateBidOpen">
                    <template #icon><IconifyIcon icon="ant-design:thunderbolt-outlined" /></template>
                    æ‰§è¡Œå¼€æ ‡
                  </a-button>
@@ -558,7 +558,7 @@
              </template>
              <div class="flex items-center justify-between mb-3">
                <span class="text-xs text-gray-400">共 <b class="text-gray-600">{{ evalCount }}</b> æ¡ï¼Œå·²æŽ’名 <b class="text-green-500">{{ rankedCount }}</b> æ¡</span>
                <a-space>
                <a-space v-if="!isAwarded">
                  <a-button size="small" @click="handleCalculateRanking">
                    <template #icon><IconifyIcon icon="ant-design:bar-chart-outlined" /></template>
                    è®¡ç®—排名
@@ -590,7 +590,7 @@
                    <span v-else class="text-gray-300">-</span>
                  </template>
                  <template v-if="column.key === 'actions'">
                    <a-button size="small" type="link" @click="evalForm = { ...record }; evalFormVisible = true"><IconifyIcon icon="ant-design:edit-outlined" />编辑</a-button>
                    <a-button v-if="!isAwarded" size="small" type="link" @click="evalForm = { ...record }; evalFormVisible = true"><IconifyIcon icon="ant-design:edit-outlined" />编辑</a-button>
                  </template>
                </template>
              </a-table>
@@ -631,7 +631,7 @@
                            <span class="text-sm">采购订单 ID: {{ award.purchaseOrderId }}</span>
                          </template>
                        </a-result>
                        <a-popconfirm title="确定删除该定标记录?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteAward">
                        <a-popconfirm v-if="!isAwarded" title="确定删除该定标记录?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteAward">
                          <a-button type="link" danger size="small" class="mt-2">删除定标</a-button>
                        </a-popconfirm>
                      </div>
@@ -639,11 +639,11 @@
                    <template v-else-if="award.awardStatus === AWARD_STATUS.AWARDED">
                      <div class="text-center">
                        <a-result status="success" title="定标完成" class="p-0 mb-3" />
                        <a-button type="primary" block @click="handleGeneratePurchaseOrder">
                        <a-button v-if="!isAwarded" type="primary" block @click="handleGeneratePurchaseOrder">
                          <template #icon><IconifyIcon icon="ant-design:shopping-cart-outlined" /></template>
                          ç”Ÿæˆé‡‡è´­è®¢å•
                        </a-button>
                        <a-popconfirm title="确定删除该定标记录?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteAward">
                        <a-popconfirm v-if="!isAwarded" title="确定删除该定标记录?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteAward">
                          <a-button type="link" danger size="small" class="mt-2">删除定标</a-button>
                        </a-popconfirm>
                      </div>
@@ -651,11 +651,11 @@
                    <template v-else>
                      <div class="text-center">
                        <div class="text-gray-500 text-sm mb-3">定标信息已创建,请审批</div>
                        <a-button type="primary" block @click="handleApproveAward" style="background: #52c41a; border-color: #52c41a;">
                        <a-button v-if="!isAwarded" type="primary" block @click="handleApproveAward" style="background: #52c41a; border-color: #52c41a;">
                          <template #icon><IconifyIcon icon="ant-design:check-outlined" /></template>
                          å®¡æ‰¹é€šè¿‡
                        </a-button>
                        <a-popconfirm title="确定删除该定标记录?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteAward">
                        <a-popconfirm v-if="!isAwarded" title="确定删除该定标记录?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteAward">
                          <a-button type="link" danger size="small" class="mt-2">删除定标</a-button>
                        </a-popconfirm>
                      </div>
@@ -667,7 +667,7 @@
                <div class="text-center py-8">
                  <a-result status="info" title="尚未定标" sub-title="基于评标排名选择中标供应商并创建定标" />
                  <br />
                  <a-button type="primary" @click="awardForm = { tenderProjectId: projectId, supplierId: 0, bidId: 0, awardNo: '' }; awardFormVisible = true">
                  <a-button v-if="!isAwarded" type="primary" @click="awardForm = { tenderProjectId: projectId, supplierId: 0, bidId: 0, awardNo: '' }; awardFormVisible = true">
                    <template #icon><IconifyIcon icon="ant-design:plus-outlined" /></template>
                    åˆ›å»ºå®šæ ‡
                  </a-button>
src/views/system/notify/message/data.ts
@@ -197,9 +197,20 @@
      label: '模版参数',
      render: (val) => {
        try {
          return JSON.stringify(val);
          const params = typeof val === 'string' ? JSON.parse(val) : val;
          if (!params || typeof params !== 'object') return '-';
          return h(
            'div',
            { class: 'space-y-1' },
            Object.entries(params).map(([key, value]) =>
              h('div', { class: 'flex gap-2' }, [
                h('span', { class: 'font-medium text-gray-600' }, `${key}:`),
                h('span', { class: 'text-gray-900' }, String(value)),
              ]),
            ),
          );
        } catch {
          return '';
          return String(val);
        }
      },
    },
src/views/system/notify/my/data.ts
@@ -131,7 +131,19 @@
    },
    {
      field: 'templateContent',
      label: '主题',
    },
    {
      field: 'templateParams',
      label: '消息内容',
      render: (val) => {
        try {
          const params = typeof val === 'string' ? JSON.parse(val) : val;
          return params?.content || '-';
        } catch {
          return String(val);
        }
      },
    },
  ];
}