ソースを参照

Merge branch 'sso' of shuzhihua/randd-vue into master

yanghao 1 ヶ月 前
コミット
fbb1b025b8

+ 1 - 1
.env.local

@@ -4,7 +4,7 @@ NODE_ENV=development
 VITE_DEV=true
 VITE_DEV=true
 
 
 # 请求路径
 # 请求路径
-VITE_BASE_URL='http://192.168.188.86:38080'
+VITE_BASE_URL='http://172.30.41.105:38080'
 
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server
 VITE_UPLOAD_TYPE=server

+ 30 - 0
src/api/technology‌/index.ts

@@ -29,3 +29,33 @@ export const deleteTechnology = (id: number) => {
 export const exportTechnology = (params) => {
 export const exportTechnology = (params) => {
   return request.get({ url: '/rd/core-technology/export', params, responseType: 'blob' })
   return request.get({ url: '/rd/core-technology/export', params, responseType: 'blob' })
 }
 }
+
+// 研究成果列表
+export const getResultList = (params) => {
+  return request.get({ url: '/rd/research-achievement/page', params })
+}
+
+// 新增研究成果
+export const createResult = (data: any) => {
+  return request.post({ url: '/rd/research-achievement/create', data })
+}
+
+// 修改研究成果
+export const updateResult = (data: any) => {
+  return request.put({ url: '/rd/research-achievement/update', data })
+}
+
+// 删除研究成果
+export const deleteResult = (id: number) => {
+  return request.delete({ url: `/rd/research-achievement/delete?id=${id}` })
+}
+
+// 获取研究成果详情
+export const getResultDetail = (id: number) => {
+  return request.get({ url: `/rd/research-achievement/get?id=${id}` })
+}
+
+// 获得研究成果 论文 专利 软著 荣誉证书 扩展属性 列表
+export const getResultExtendList = (type) => {
+  return request.get({ url: `/rd/research-achievement-attrs/attrs?achievementType=${type}` })
+}

+ 8 - 1
src/utils/dict.ts

@@ -262,5 +262,12 @@ export enum DICT_TYPE {
   RD_EXPERT_ECHELON = 'rd_expert_echelon', //专家梯队
   RD_EXPERT_ECHELON = 'rd_expert_echelon', //专家梯队
   RD_CORE_TECHNOLOGY_TYPE = 'rd_core_technology_type', // 核心技术类型
   RD_CORE_TECHNOLOGY_TYPE = 'rd_core_technology_type', // 核心技术类型
   RD_CORE_TECHNOLOGY_STATUS = 'rd_core_technology_status', // 核心技术状态
   RD_CORE_TECHNOLOGY_STATUS = 'rd_core_technology_status', // 核心技术状态
-  RD_CORE_TECHNOLOGY_LEVEL = 'rd_core_technology_level' // 核心技术级别
+  RD_CORE_TECHNOLOGY_LEVEL = 'rd_core_technology_level', // 核心技术级别
+
+  // 研究成果
+  ACHIEVEMENT_TYPE = 'rd_achievement_type', // 研究成果类型
+  PATENT_SUB_TYPE = 'rd_patent_sub_type', // 专利子类型
+  PAPER_SUB_TYPE = 'rd_paper_sub_type', // 论文子类型
+  COPYRIGHT_SUB_TYPE = 'rd_copyright_sub_type', // 软著子类型
+  HONOR_SUB_TYPE = 'rd_honor_sub_type' // 荣誉级别子类型
 }
 }

+ 146 - 0
src/views/findings/copyright/data.js

@@ -0,0 +1,146 @@
+;[
+  {
+    name: '发明人',
+    identifier: 'jointInventors',
+    dataType: 'text',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '0',
+    sort: 1,
+    actualValue: '张三',
+    dropdownList: null
+  },
+  {
+    name: '是否有出质',
+    identifier: 'pledged',
+    dataType: 'dropdown',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '',
+    sort: 1,
+    actualValue: '是',
+    dropdownList: [
+      {
+        name: '是',
+        value: '是'
+      },
+      {
+        name: '否',
+        value: '否'
+      }
+    ]
+  },
+  {
+    name: '是否自主申报',
+    identifier: 'selfDeclared',
+    dataType: 'dropdown',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '',
+    sort: 1,
+    actualValue: '否',
+    dropdownList: [
+      {
+        name: '是',
+        value: '是'
+      },
+      {
+        name: '否',
+        value: '否'
+      }
+    ]
+  },
+  {
+    name: '下次缴费截止日期',
+    identifier: 'nextPaymentDate',
+    dataType: 'date',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '0',
+    sort: 1,
+    actualValue: '1787383290000',
+    dropdownList: null
+  },
+  {
+    name: '最近缴费日期',
+    identifier: 'lastPaymentDate',
+    dataType: 'date',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '0',
+    sort: 1,
+    actualValue: '1784704890000',
+    dropdownList: null
+  },
+  {
+    name: '有效期限',
+    identifier: 'validUntil',
+    dataType: 'date',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '0',
+    sort: 1,
+    actualValue: '2100324090000',
+    dropdownList: null
+  },
+  {
+    name: '授权公告日',
+    identifier: 'grantDate',
+    dataType: 'date',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '0',
+    sort: 1,
+    actualValue: '1784704890000',
+    dropdownList: null
+  },
+  {
+    name: '申请日',
+    identifier: 'applicationDate',
+    dataType: 'date',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '0',
+    sort: 1,
+    actualValue: '1779434490000',
+    dropdownList: null
+  },
+  {
+    name: '专利号',
+    identifier: 'patentNo',
+    dataType: 'text',
+    required: 0,
+    unit: '',
+    accessMode: 'rw',
+    defaultValue: '',
+    maxValue: '',
+    minValue: '0',
+    sort: 1,
+    actualValue: 'YFZL20260823',
+    dropdownList: null
+  }
+]

+ 976 - 0
src/views/findings/copyright/index.vue

@@ -0,0 +1,976 @@
+<template>
+  <div
+    class="grid grid-cols-[auto_1fr] grid-rows-[auto_minmax(0,1fr)] gap-0 gap-x-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+  >
+    <DeptTreeSelect
+      class="row-span-2"
+      :top-id="rootDeptId"
+      :deptId="deptId"
+      v-model="queryParams.deptId"
+      :init-select="false"
+      :show-title="false"
+      request-api="getSimpleDeptList"
+      @node-click="handleDeptNodeClick"
+    />
+
+    <div class="mb-1">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        :inline="true"
+        @submit.prevent
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 pt-4 flex items-center flex-wrap min-w-0"
+      >
+        <el-form-item label="成果名称" prop="achievementName">
+          <el-input
+            v-model="queryParams.achievementName"
+            placeholder="请输入成果名称"
+            clearable
+            style="width: 180px"
+          />
+        </el-form-item>
+        <el-form-item label="成果类型" prop="achievementType">
+          <el-select
+            v-model="queryParams.achievementType"
+            placeholder="请选择成果类型"
+            clearable
+            style="width: 150px"
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.ACHIEVEMENT_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
+          <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
+          <el-button type="primary" plain @click="openForm('create')">
+            <Icon icon="ep:plus" class="mr-5px" />新增
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-2 pt-4 min-w-0">
+      <div class="flex-1 relative min-h-0">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <zm-table :loading="loading" :data="list" :width="width" :height="height">
+              <zm-table-column label="序号" width="70" align="center">
+                <template #default="scope">{{ scope.$index + 1 }}</template>
+              </zm-table-column>
+              <zm-table-column
+                label="成果名称"
+                prop="achievementName"
+                min-width="180"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column
+                label="成果类型"
+                prop="achievementType"
+                min-width="130"
+                align="center"
+              >
+                <template #default="scope">
+                  <dict-tag :type="DICT_TYPE.ACHIEVEMENT_TYPE" :value="scope.row.achievementType" />
+                </template>
+              </zm-table-column>
+              <zm-table-column label="子类型" prop="subType" min-width="140" align="center">
+                <template #default="scope">
+                  <dict-tag
+                    :type="getSubTypeDict(scope.row.achievementType)"
+                    :value="scope.row.subType"
+                  />
+                </template>
+              </zm-table-column>
+              <zm-table-column label="部门" prop="deptId" min-width="150" align="center">
+                <template #default="{ row }">{{ getDeptName(row.deptId) }}</template>
+              </zm-table-column>
+              <zm-table-column
+                label="概述"
+                prop="summary"
+                min-width="260"
+                align="center"
+                show-overflow-tooltip
+              />
+              <zm-table-column label="操作" align="center" fixed="right" width="205" action>
+                <template #default="scope">
+                  <el-button link type="primary" @click="openDetail(scope.row.id)">
+                    <Icon icon="ep:view" class="mr-1" />详情
+                  </el-button>
+                  <el-button link type="primary" @click="openForm('update', scope.row.id)"
+                    >编辑</el-button
+                  >
+                  <el-button link type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
+                </template>
+              </zm-table-column>
+            </zm-table>
+          </template>
+        </el-auto-resizer>
+      </div>
+      <div class="h-8 mt-2 flex items-center justify-end">
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </div>
+    </div>
+  </div>
+
+  <Dialog
+    :title="dialogTitle"
+    v-model="dialogVisible"
+    width="680px"
+    :close-on-click-modal="false"
+    destroy-on-close
+  >
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="部门" prop="deptId">
+        <el-tree-select
+          v-model="formData.deptId"
+          :data="deptList"
+          :props="defaultProps"
+          node-key="id"
+          filterable
+          clearable
+          placeholder="请选择部门"
+          class="w-full"
+        />
+      </el-form-item>
+      <el-form-item label="成果名称" prop="achievementName">
+        <el-input v-model="formData.achievementName" placeholder="请输入成果名称" />
+      </el-form-item>
+      <el-form-item label="成果类型" prop="achievementType">
+        <el-select
+          v-model="formData.achievementType"
+          placeholder="请选择成果类型"
+          clearable
+          class="w-full"
+          @change="handleAchievementTypeChange"
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.ACHIEVEMENT_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="子类型" prop="subType">
+        <el-select
+          v-model="formData.subType"
+          placeholder="请先选择成果类型"
+          clearable
+          class="w-full"
+          :disabled="!formData.achievementType"
+        >
+          <el-option
+            v-for="dict in subTypeOptions"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="概述" prop="summary">
+        <el-input
+          v-model="formData.summary"
+          type="textarea"
+          :rows="5"
+          placeholder="请输入成果概述"
+        />
+      </el-form-item>
+      <el-divider v-if="formData.extAttributes.length" content-position="center"
+        >详细信息</el-divider
+      >
+      <div v-loading="extendLoading">
+        <el-form-item
+          v-for="(attribute, index) in formData.extAttributes"
+          :key="attribute.identifier || index"
+          :label="attribute.name"
+          :prop="`extAttributes.${index}.actualValue`"
+          :rules="getAttributeRules(attribute)"
+          label-width="130px"
+        >
+          <el-select
+            v-if="attribute.dataType === 'dropdown'"
+            v-model="attribute.actualValue"
+            placeholder="请选择"
+            clearable
+            class="w-full"
+          >
+            <el-option
+              v-for="option in attribute.dropdownList || []"
+              :key="option.value"
+              :label="option.name"
+              :value="option.value"
+            />
+          </el-select>
+          <el-date-picker
+            v-else-if="attribute.dataType === 'date'"
+            v-model="attribute.dateValue"
+            type="date"
+            placeholder="请选择日期"
+            class="w-full"
+          />
+          <el-input
+            v-else
+            v-model="attribute.actualValue"
+            :placeholder="`请输入${attribute.name}`"
+          />
+        </el-form-item>
+      </div>
+      <el-divider v-if="attachmentGroups.length" content-position="center">附件资料</el-divider>
+      <el-form-item v-for="group in attachmentGroups" :key="group.type" :label="group.label">
+        <UploadFile
+          v-model="attachmentFiles[group.type]"
+          :file-type="['pdf', 'png', 'jpg', 'jpeg']"
+          :return-object="true"
+          :limit="10"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button type="primary" :loading="formLoading" @click="submitForm">确定</el-button>
+      <el-button @click="dialogVisible = false">取消</el-button>
+    </template>
+  </Dialog>
+
+  <Dialog
+    v-model="detailVisible"
+    title="研究成果详情"
+    width="820px"
+    :close-on-click-modal="false"
+    destroy-on-close
+  >
+    <div v-loading="detailLoading" class="achievement-detail">
+      <div class="detail-hero">
+        <div class="detail-hero-icon"><Icon icon="ep:trophy" /></div>
+        <div class="detail-hero-content">
+          <div class="detail-hero-label">RESEARCH ACHIEVEMENT</div>
+          <h2>{{ detailData.achievementName || '未命名成果' }}</h2>
+          <div class="detail-hero-meta">
+            <dict-tag :type="DICT_TYPE.ACHIEVEMENT_TYPE" :value="detailData.achievementType" />
+            <dict-tag
+              :type="getSubTypeDict(detailData.achievementType)"
+              :value="detailData.subType"
+            />
+          </div>
+        </div>
+      </div>
+
+      <div class="detail-section">
+        <div class="detail-section-title"><Icon icon="ep:info-filled" />基本信息</div>
+        <div class="detail-info-grid">
+          <div class="detail-info-item">
+            <span>所属部门</span>
+            <strong>{{ getDeptName(detailData.deptId) }}</strong>
+          </div>
+          <div class="detail-info-item">
+            <span>成果类型</span>
+            <dict-tag :type="DICT_TYPE.ACHIEVEMENT_TYPE" :value="detailData.achievementType" />
+          </div>
+          <div class="detail-info-item">
+            <span>子类型</span>
+            <dict-tag
+              :type="getSubTypeDict(detailData.achievementType)"
+              :value="detailData.subType"
+            />
+          </div>
+        </div>
+      </div>
+
+      <div class="detail-section detail-text-section">
+        <div class="detail-section-title"><Icon icon="ep:document" />成果概述</div>
+        <div class="detail-text-block">
+          <p>{{ detailData.summary || '暂无内容' }}</p>
+        </div>
+      </div>
+
+      <div v-if="detailExtendAttributes.length" class="detail-section">
+        <div class="detail-section-title"><Icon icon="ep:collection-tag" />详细信息</div>
+        <div class="detail-attribute-grid">
+          <div
+            v-for="attribute in detailExtendAttributes"
+            :key="attribute.identifier"
+            class="detail-attribute-item"
+          >
+            <span class="pr-4">{{ attribute.name }}</span>
+            <strong>{{ formatAttributeValue(attribute) }}</strong>
+          </div>
+        </div>
+      </div>
+
+      <div v-if="detailAttachmentGroups.length" class="detail-section">
+        <div class="detail-section-title"><Icon icon="ep:paperclip" />相关附件</div>
+        <div class="detail-attachments">
+          <div v-for="group in detailAttachmentGroups" :key="group.type" class="attachment-group">
+            <span class="attachment-label">{{ group.label }}</span>
+            <div v-if="group.files.length" class="attachment-list">
+              <div
+                v-for="(file, index) in group.files"
+                :key="file.filePath || index"
+                class="attachment-item"
+              >
+                <span
+                  class="attachment-name"
+                  :title="file.filename || file.fileName || file.filePath"
+                >
+                  <Icon icon="ep:document" class="attachment-file-icon" />
+                  {{ file.filename || file.fileName || file.filePath }}
+                </span>
+                <span class="attachment-actions">
+                  <el-button
+                    link
+                    type="primary"
+                    title="预览文件"
+                    @click="viewFileInfo(file.filePath)"
+                  >
+                    <Icon icon="ep:view" />
+                  </el-button>
+                  <el-button
+                    link
+                    type="primary"
+                    title="下载文件"
+                    @click="downloadFile(file.filePath, file.filename || file.fileName)"
+                  >
+                    <Icon icon="ep:download" />
+                  </el-button>
+                </span>
+              </div>
+            </div>
+            <span v-else class="empty-value">暂无文件</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue'
+import {
+  createResult,
+  deleteResult,
+  getResultDetail,
+  getResultList,
+  updateResult,
+  getResultExtendList
+} from '@/api/technology‌'
+import * as DeptApi from '@/api/system/dept'
+import { useUserStore } from '@/store/modules/user'
+import { defaultProps } from '@/utils/tree'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+
+const Base64 = {
+  encode: (value: string) =>
+    window.btoa(
+      encodeURIComponent(value).replace(/%([0-9A-F]{2})/g, (_, code) =>
+        String.fromCharCode(Number.parseInt(code, 16))
+      )
+    )
+}
+
+defineOptions({ name: 'Copyright' })
+
+const rootDeptId = 156
+const deptId = useUserStore().getUser.deptId || rootDeptId
+const message = useMessage()
+const loading = ref(false)
+const formLoading = ref(false)
+const extendLoading = ref(false)
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formType = ref<'create' | 'update'>('create')
+const list = ref<any[]>([])
+const total = ref(0)
+const deptList = ref<Tree[]>([])
+const formRef = ref()
+const queryFormRef = ref()
+const attachmentFiles = reactive<Record<string, any[]>>({})
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const detailData = ref<any>({ extAttributes: [], attachments: [] })
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deptId: undefined as number | undefined,
+  achievementName: undefined as string | undefined,
+  achievementType: undefined as string | undefined
+})
+
+const createDefaultForm = () => ({
+  id: undefined,
+  deptId: undefined as number | undefined,
+  achievementName: '',
+  achievementType: '',
+  subType: '',
+  summary: '',
+  extAttributes: [] as ExtendAttribute[]
+})
+
+const formData = ref(createDefaultForm())
+const formRules = {
+  deptId: [{ required: true, message: '请选择部门', trigger: 'change' }],
+  achievementName: [{ required: true, message: '请输入成果名称', trigger: 'blur' }],
+  achievementType: [{ required: true, message: '请选择成果类型', trigger: 'change' }],
+  subType: [{ required: true, message: '请选择子类型', trigger: 'change' }]
+}
+
+interface ExtendAttribute {
+  name: string
+  identifier: string
+  dataType: string
+  required?: number
+  unit?: string
+  defaultValue?: string
+  actualValue?: string | number | null
+  dateValue?: Date | null
+  dropdownList?: Array<{ name: string; value: string | number }>
+}
+
+interface AttachmentGroup {
+  type: string
+  label: string
+}
+
+const getSubTypeDict = (achievementType: string | number) => {
+  const dictMap: Record<string, string> = {
+    '1': DICT_TYPE.PAPER_SUB_TYPE,
+    '2': DICT_TYPE.PATENT_SUB_TYPE,
+    '3': DICT_TYPE.COPYRIGHT_SUB_TYPE,
+    '4': DICT_TYPE.HONOR_SUB_TYPE
+  }
+  return dictMap[String(achievementType)] || DICT_TYPE.PAPER_SUB_TYPE
+}
+
+const subTypeOptions = computed(() =>
+  formData.value.achievementType
+    ? getStrDictOptions(getSubTypeDict(formData.value.achievementType))
+    : []
+)
+
+const getAttachmentGroups = (achievementType: string | number): AttachmentGroup[] => {
+  const type = String(achievementType)
+  if (type === '1') return [{ type: 'PAPER', label: '论文附件' }]
+  if (type === '2') {
+    return [
+      { type: 'AUTH_NOTICE', label: '授权通知书' },
+      { type: 'CERTIFICATE', label: '证书' },
+      { type: 'REGISTER_COPY', label: '登记副本' },
+      { type: 'OTHER_MATERIAL', label: '其他' }
+    ]
+  }
+  if (type === '3') return [{ type: 'COPYRIGHT', label: '软著附件' }]
+  if (type === '4') return [{ type: 'HONOR_CERTIFICATE', label: '荣誉证书附件' }]
+  return []
+}
+
+const attachmentGroups = computed(() => getAttachmentGroups(formData.value.achievementType))
+
+const getDeptName = (id?: number) => {
+  if (!id) return '-'
+  const find = (items: Tree[]): string | undefined => {
+    for (const item of items) {
+      if (item.id === id) return item.name
+      const name = item.children ? find(item.children) : undefined
+      if (name) return name
+    }
+  }
+  return find(deptList.value) || String(id)
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await getResultList(queryParams)
+    list.value = data.list || []
+    total.value = data.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleDeptNodeClick = (row: { id: number }) => {
+  queryParams.deptId = row.id
+  queryParams.pageNo = 1
+  getList()
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryParams.pageNo = 1
+  queryParams.deptId = undefined
+  queryParams.achievementName = undefined
+  queryParams.achievementType = undefined
+  queryFormRef.value?.resetFields()
+  getList()
+}
+
+const handleAchievementTypeChange = () => {
+  formData.value.subType = ''
+  formData.value.extAttributes = []
+  clearAttachmentFiles()
+  if (formData.value.achievementType) {
+    loadExtendAttributes(formData.value.achievementType)
+  }
+}
+
+const clearAttachmentFiles = () => {
+  Object.keys(attachmentFiles).forEach((type) => delete attachmentFiles[type])
+}
+
+const normalizeAttachments = (attachments: unknown): any[] => {
+  if (!Array.isArray(attachments)) return []
+  return attachments.filter((attachment) => attachment && typeof attachment === 'object')
+}
+
+const prepareAttachmentFiles = (attachments: unknown) => {
+  clearAttachmentFiles()
+  normalizeAttachments(attachments).forEach((attachment) => {
+    const type = attachment.type
+    if (!type) return
+    if (!attachmentFiles[type]) attachmentFiles[type] = []
+    attachmentFiles[type].push({
+      ...attachment,
+      name: attachment.filename || attachment.fileName || attachment.filePath,
+      fileName: attachment.filename || attachment.fileName || attachment.filePath,
+      url: attachment.filePath,
+      attachment: {
+        ...attachment,
+        fileName: attachment.filename || attachment.fileName,
+        filePath: attachment.filePath
+      }
+    })
+  })
+}
+
+const getAttachments = () =>
+  attachmentGroups.value.flatMap((group) =>
+    (attachmentFiles[group.type] || []).map((file) => {
+      const metadata = file.attachment || file
+      return {
+        category: 'ACHIEVEMENT',
+        type: group.type,
+        filename: metadata.filename || metadata.fileName || file.name || '',
+        fileType: metadata.fileType || file.raw?.type || '',
+        filePath: metadata.filePath || file.url || '',
+        fileSize: metadata.fileSize || file.raw?.size || 0
+      }
+    })
+  )
+
+const detailExtendAttributes = computed(() =>
+  normalizeExtendAttributes(detailData.value.extAttributes)
+)
+
+const detailAttachmentGroups = computed(() => {
+  const groups = getAttachmentGroups(detailData.value.achievementType).map((group) => ({
+    ...group,
+    files: [] as any[]
+  }))
+  const attachments = normalizeAttachments(detailData.value.attachments)
+  attachments.forEach((attachment) => {
+    const group = groups.find((item) => item.type === attachment.type)
+    if (group) group.files.push(attachment)
+  })
+  return groups
+})
+
+const normalizeExtendAttributes = (data: unknown): ExtendAttribute[] => {
+  if (Array.isArray(data)) return data
+  if (data && typeof data === 'object' && Array.isArray((data as any).data)) {
+    return (data as any).data
+  }
+  return []
+}
+
+const toDateValue = (value: unknown) => {
+  if (value === undefined || value === null || value === '') return null
+  const timestamp = Number(value)
+  if (!Number.isNaN(timestamp)) {
+    const date = new Date(timestamp)
+    if (!Number.isNaN(date.getTime())) return date
+  }
+  const date = new Date(String(value))
+  return Number.isNaN(date.getTime()) ? null : date
+}
+
+const prepareExtendAttributes = (attributes: ExtendAttribute[]) =>
+  attributes.map((attribute) => ({
+    ...attribute,
+    dateValue:
+      attribute.dataType === 'date'
+        ? toDateValue(attribute.actualValue ?? attribute.defaultValue)
+        : undefined,
+    actualValue: attribute.actualValue ?? attribute.defaultValue ?? ''
+  }))
+
+const loadExtendAttributes = async (achievementType: string | number) => {
+  extendLoading.value = true
+  try {
+    const data = await getResultExtendList(achievementType)
+    formData.value.extAttributes = prepareExtendAttributes(normalizeExtendAttributes(data))
+  } finally {
+    extendLoading.value = false
+  }
+}
+
+const getAttributeRules = (attribute: ExtendAttribute) => {
+  return attribute.required
+    ? [{ required: true, message: `请输入${attribute.name}`, trigger: 'change' }]
+    : []
+}
+
+const formatAttributeValue = (attribute: ExtendAttribute) => {
+  const value = attribute.actualValue
+  if (value === undefined || value === null || value === '') return '-'
+  if (attribute.dataType === 'date') {
+    const date = toDateValue(value)
+    return date ? date.toLocaleDateString('zh-CN') : String(value)
+  }
+  if (attribute.dataType === 'dropdown') {
+    return (
+      attribute.dropdownList?.find((option) => String(option.value) === String(value))?.name ||
+      String(value)
+    )
+  }
+  return String(value)
+}
+
+const openForm = async (type: 'create' | 'update', id?: number) => {
+  formType.value = type
+  dialogTitle.value = type === 'create' ? '新增研究成果' : '编辑研究成果'
+  formData.value = createDefaultForm()
+  clearAttachmentFiles()
+  if (type === 'update') {
+    formLoading.value = true
+    try {
+      const data = await getResultDetail(id!)
+      formData.value = {
+        ...createDefaultForm(),
+        ...data,
+        extAttributes: prepareExtendAttributes(normalizeExtendAttributes(data.extAttributes))
+      }
+      prepareAttachmentFiles(data.attachments)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  dialogVisible.value = true
+}
+
+const openDetail = async (id: number) => {
+  detailVisible.value = true
+  detailLoading.value = true
+  try {
+    detailData.value = {
+      extAttributes: [],
+      attachments: [],
+      ...(await getResultDetail(id))
+    }
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+const viewFileInfo = (file: string) => {
+  window.open(
+    'http://doc.deepoil.cc:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(file))
+  )
+}
+
+const downloadFile = (file: string, fileName?: string) => {
+  const link = document.createElement('a')
+  link.href = file
+  link.download = fileName || file.split('/').pop() || '下载文件'
+  link.target = '_blank'
+  link.click()
+}
+
+const submitForm = async () => {
+  const valid = await formRef.value?.validate()
+  if (!valid) return
+  formLoading.value = true
+  try {
+    const payload = {
+      ...formData.value,
+      attachments: getAttachments(),
+      extAttributes: formData.value.extAttributes.map(({ dateValue, ...attribute }) => ({
+        ...attribute,
+        actualValue:
+          attribute.dataType === 'date'
+            ? dateValue
+              ? dateValue.getTime()
+              : ''
+            : attribute.actualValue
+      }))
+    }
+    if (formType.value === 'create') {
+      await createResult(payload)
+      message.success('新增成功')
+    } else {
+      await updateResult(payload)
+      message.success('更新成功')
+    }
+    dialogVisible.value = false
+    getList()
+  } finally {
+    formLoading.value = false
+  }
+}
+
+const handleDelete = async (id: number) => {
+  try {
+    await message.delConfirm()
+    await deleteResult(id)
+    message.success('删除成功')
+    getList()
+  } catch {}
+}
+
+onMounted(async () => {
+  getList()
+  deptList.value = await DeptApi.getSimpleDeptList()
+})
+</script>
+
+<style scoped lang="scss">
+.achievement-detail {
+  max-height: 68vh;
+  overflow-y: auto;
+  padding: 0 4px 8px;
+}
+
+.detail-hero {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 20px 22px;
+  border-radius: 10px;
+  background: linear-gradient(135deg, var(--el-color-primary-light-9), var(--el-fill-color-light));
+}
+
+.detail-hero-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 48px;
+  height: 48px;
+  flex: 0 0 48px;
+  border-radius: 12px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-8);
+  font-size: 24px;
+}
+
+.detail-hero-label {
+  color: var(--el-color-primary);
+  font-size: 11px;
+  letter-spacing: 1.5px;
+}
+
+.detail-hero h2 {
+  margin: 4px 0 8px;
+  color: var(--el-text-color-primary);
+  font-size: 20px;
+}
+
+.detail-hero-meta,
+.detail-info-item,
+.attachment-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.detail-hero-meta {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+
+.detail-section {
+  margin-top: 22px;
+}
+
+.detail-section-title {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  margin-bottom: 13px;
+  color: var(--el-text-color-primary);
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.detail-section-title :deep(.iconify) {
+  color: var(--el-color-primary);
+}
+
+.detail-info-grid,
+.detail-attribute-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 1px;
+  overflow: hidden;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  background: var(--el-border-color-lighter);
+}
+
+.detail-info-item,
+.detail-attribute-item {
+  min-height: 48px;
+  padding: 0 16px;
+  background: var(--el-bg-color);
+}
+
+.detail-info-item > span,
+.detail-attribute-item > span,
+.detail-text-block > span,
+.attachment-label {
+  color: var(--el-text-color-secondary);
+  font-size: 13px;
+}
+
+.detail-info-item > span,
+.detail-attribute-item > span {
+  width: 80px;
+  flex: 0 0 80px;
+}
+
+.detail-info-item strong,
+.detail-attribute-item strong {
+  overflow: hidden;
+  color: var(--el-text-color-primary);
+  font-size: 13px;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.detail-attribute-item {
+  display: flex;
+  align-items: center;
+}
+
+.detail-text-block {
+  padding: 12px 14px;
+  border-left: 3px solid var(--el-color-primary-light-5);
+  background: var(--el-fill-color-lighter);
+}
+
+.detail-text-block p {
+  margin: 0;
+  color: var(--el-text-color-primary);
+  font-size: 13px;
+  line-height: 1.7;
+  white-space: pre-wrap;
+}
+
+.detail-attachments {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding: 13px 16px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.attachment-label {
+  width: 100px;
+  flex: 0 0 100px;
+}
+
+.attachment-list {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.attachment-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+  width: 100%;
+}
+
+.attachment-name {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  color: var(--el-color-primary);
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.attachment-file-icon {
+  flex: 0 0 auto;
+  margin-right: 4px;
+}
+
+.attachment-actions {
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  gap: 2px;
+}
+
+.attachment-actions :deep(.el-button) {
+  width: 26px;
+  height: 26px;
+  margin-left: 0;
+  padding: 0;
+}
+
+.empty-value {
+  color: var(--el-text-color-placeholder);
+  font-size: 13px;
+}
+
+:deep(.el-empty) {
+  padding: 12px 0 4px;
+}
+
+@media (max-width: 640px) {
+  .detail-info-grid,
+  .detail-attribute-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .detail-hero {
+    padding: 16px;
+  }
+
+  .detail-hero h2 {
+    font-size: 18px;
+  }
+
+  .attachment-group {
+    align-items: flex-start;
+  }
+}
+</style>