Browse Source

feature:(重庆)移动端完成经费和项目部分开发

liuzhenlin 3 weeks ago
parent
commit
e2a023f86f
38 changed files with 1811 additions and 639 deletions
  1. 6 4
      .env.development
  2. 3 3
      .env.production
  3. 2 2
      App.vue
  4. 4 0
      api/document/index.ts
  5. 8 0
      api/project/index.ts
  6. 4 3
      api/system/login.ts
  7. 43 8
      constants/index.ts
  8. 6 6
      pages.json
  9. 302 35
      pages/login/index.vue
  10. 143 0
      pages/login/index1.vue
  11. 0 399
      pages/login/index_his.vue
  12. 3 3
      pages/project/components/HorizontalProject.vue
  13. 168 0
      pages/project/components/SafetyProject.vue
  14. 3 3
      pages/project/components/SpontaneityProject.vue
  15. 3 3
      pages/project/components/VerticalProject.vue
  16. 2 0
      pages/project/components/detail/InspDetailPopup.vue
  17. 163 22
      pages/project/components/detail/ProjectAchievements.vue
  18. 2 0
      pages/project/components/detail/ProjectApproval.vue
  19. 2 1
      pages/project/components/detail/ProjectClosing.vue
  20. 10 10
      pages/project/components/detail/ProjectCooperates.vue
  21. 3 3
      pages/project/components/detail/ProjectEthical.vue
  22. 212 35
      pages/project/components/detail/ProjectFunding.vue
  23. 4 0
      pages/project/components/detail/ProjectInspection.vue
  24. 17 5
      pages/project/components/detail/ProjectMembers.vue
  25. 3 0
      pages/project/components/detail/ProjectSetup.vue
  26. 72 24
      pages/project/components/detail/ProjectSetupHorizontal.vue
  27. 67 0
      pages/project/components/detail/ProjectSetupSafety.vue
  28. 26 25
      pages/project/components/detail/ProjectSetupSpontaneity.vue
  29. 32 23
      pages/project/components/detail/ProjectSetupVertical.vue
  30. 11 14
      pages/project/detail.vue
  31. 22 6
      pages/project/index.vue
  32. 28 0
      pages/todo/components/DocumentInfoDisplay.vue
  33. 303 0
      pages/todo/components/document/SafetyForm.vue
  34. 63 1
      store/modules/project.ts
  35. 26 0
      store/modules/user.ts
  36. 2 0
      types/project.ts
  37. 2 1
      utils/micro_request.js
  38. 41 0
      utils/wechat.ts

+ 6 - 4
.env.development

@@ -2,17 +2,19 @@
 VITE_APP_ENV=development
 
 # 基础接口地址
-VITE_API_URL = http://192.168.0.216:9991/
+VITE_API_URL = http://192.168.0.216:9989/
 
 # 网关/微服务前缀配置
-VITE_ADMIN = dashoo.labsop.admin-55000
-VITE_SCIENTIFIC = dashoo.labsop.scientific-55000
-VITE_WORKFLOW = dashoo.labsop.workflow-55000
+VITE_ADMIN = dashoo.labsop.admin-54000
+VITE_SCIENTIFIC = dashoo.labsop.scientific-54000
+VITE_WORKFLOW = dashoo.labsop.workflow-54000
 
 # 钉钉corpId
 VITE_DINGTALK_CORPID=dinga8b316209f5ee42435c2f4657eb6378f
 # 钉钉clientId (AppKey)
 VITE_DINGTALK_CLIENTID=
 
+VITE_SECRET = def456
+
 # 租户ID 
 VITE_TENANT=default

+ 3 - 3
.env.production

@@ -5,9 +5,9 @@ VITE_APP_ENV=development
 VITE_API_URL = /api/
 
 # 网关/微服务前缀配置
-VITE_ADMIN = dashoo.labsop.admin-55000
-VITE_SCIENTIFIC = dashoo.labsop.scientific-55000
-VITE_WORKFLOW = dashoo.labsop.workflow-55000
+VITE_ADMIN = dashoo.labsop.admin-54000
+VITE_SCIENTIFIC = dashoo.labsop.scientific-54000
+VITE_WORKFLOW = dashoo.labsop.workflow-54000
 
 # 钉钉配置
 VITE_DINGTALK_CORPID = dinga8b316209f5ee42435c2f4657eb6378f

+ 2 - 2
App.vue

@@ -25,14 +25,14 @@ onLaunch(async (options) => {
       } catch (err) {
         console.error('DingTalk Auto Login Failed:', err);
         // 如果免登失败(通常是没找到用户),则跳转注册页面
-        uni.reLaunch({ url: '/pages/login/register' });
+        uni.reLaunch({ url: '/pages/login/index' });
       } finally {
         userStore.setRequestLoading('isLogining', false);
       }
     }
   } else if (!userStore.token) {
     // 不在钉钉环境下且没登录,也引导去注册或者登录跳转(暂统一引导至注册或默认逻辑)
-    uni.reLaunch({ url: '/pages/login/register' });
+    uni.reLaunch({ url: '/pages/login/index' });
   }
 });
 

+ 4 - 0
api/document/index.ts

@@ -207,6 +207,10 @@ export function useDocumentApi() {
     // 预申报 (sci_project_declaration_list)
     getDeclarationById: (id: string | number) => {
       return microRequest.postRequest(sciPath, 'SciProjectDeclarationList', 'GetEntityById', { id: Number(id) });
+    },
+    // 安评项目 (sci_project_safety)
+    getSafetyByCode: (code: string) => {
+      return microRequest.postRequest(sciPath, 'SciProjectSafety', 'GetEntityByCode', { projectCode: code });
     }
   };
 }

+ 8 - 0
api/project/index.ts

@@ -32,6 +32,14 @@ export function useProjectApi() {
     getSpontaneityEntityById: (query?: object) => {
       return microRequest.postRequest(sciPath, 'SciProjectSpontaneity', 'GetSciProjectSpontaneityById', query);
     },
+    // 安评项目
+    getSafetyList: (query?: object) => {
+      return microRequest.postRequest(sciPath, 'SciProjectSafety', 'GetList', query);
+    },
+    // 安评项目详情
+    getSafetyEntityById: (query?: object) => {
+      return microRequest.postRequest(sciPath, 'SciProjectSafety', 'GetEntityById', query);
+    },
     // 项目变更信息列表
     getProjectDetailsList: (query?: object) => {
       return microRequest.postRequest(sciPath, 'SciProjectChange', 'GetProjectDetailsList', query);

+ 4 - 3
api/system/login.ts

@@ -44,9 +44,10 @@ export function useLoginApi() {
       return microRequest.postRequest(basePath, 'Personnel', 'GeneratePassword');
     },
 
-
-
-
+    // 钉钉免登接口
+    oAuthLogin(query: { code: string }) {
+      return microRequest.postRequest(basePath, 'System', 'QWechatLogin', query)
+    },
 
   }
 

+ 43 - 8
constants/index.ts

@@ -17,15 +17,48 @@ export const PAGE_CONFIG = {
   DEFAULT_PAGE_SIZE: 10
 };
 
-// 项目状态选项
-export const projectStatusOptions = [
+// 内部项目状态选项
+export const spontaneityProjectStatusOptions = [
   { dictLabel: '全部状态', dictValue: '' },
-  { dictLabel: '待立项', dictValue: '06', type: 'info' },
+  { dictLabel: '待立项', dictValue: '05', type: 'primary' },
   { dictLabel: '立项', dictValue: '10', type: 'primary' },
-  { dictLabel: '在研', dictValue: '20', type: 'success' },
-  { dictLabel: '临近结题', dictValue: '25', type: 'warning' },
-  { dictLabel: '结题验收', dictValue: '30', type: 'warning' },
-  { dictLabel: '中止', dictValue: '40', type: 'error' }
+  { dictLabel: '在研', dictValue: '20', type: 'primary' },
+  { dictLabel: '结题验收', dictValue: '30', type: 'success' },
+  { dictLabel: '中止', dictValue: '40', type: 'error' },
+  { dictLabel: '临近结题', dictValue: '50', type: 'warning' },
+];
+
+// 纵向项目状态
+export const verticalProjectStatusOptions = [
+  { dictLabel: '全部状态', dictValue: '' },
+  { dictLabel: '待立项', dictValue: '06', type: 'primary' },
+  { dictLabel: '立项', dictValue: '10', type: 'primary' },
+  { dictLabel: '在研', dictValue: '20', type: 'primary' },
+  { dictLabel: '结题验收', dictValue: '30', type: 'success' },
+  { dictLabel: '中止', dictValue: '40', type: 'error' },
+  { dictLabel: '临近结题', dictValue: '50', type: 'warning' },
+];
+
+// 横向项目状态选项
+export const horizontalProjectStatusOptions = [
+  { dictLabel: '全部状态', dictValue: '' },
+  { dictLabel: '待立项', dictValue: '06', type: 'primary' },
+  { dictLabel: '立项', dictValue: '10', type: 'primary' },
+  { dictLabel: '在研', dictValue: '20', type: 'primary' },
+  { dictLabel: '结题验收', dictValue: '30', type: 'success' },
+  { dictLabel: '中止', dictValue: '40', type: 'error' },
+  { dictLabel: '临近结题', dictValue: '50', type: 'warning' },
+];
+
+// 安评项目状态选项
+export const safetyProjectStatusOptions = [
+  { dictLabel: '全部状态', dictValue: '' },
+  { dictLabel: '待立项', dictValue: '06', type: 'primary' },
+  { dictLabel: '立项', dictValue: '10', type: 'primary' },
+  { dictLabel: '在研', dictValue: '20', type: 'primary' },
+  { dictLabel: '结题验收', dictValue: '30', type: 'success' },
+  { dictLabel: '中止', dictValue: '40', type: 'error' },
+  { dictLabel: '临近结题', dictValue: '50', type: 'warning' },
 ];
 
 // 变更类型选项
@@ -124,9 +157,11 @@ export const getProjectTypeName = (type: string) => {
     'vertical': '纵向项目',
     'horizontal': '横向项目',
     'spontaneity': '校园项目',
+    'safety': '安评项目',
     '10': '纵向项目',
     '20': '横向项目',
-    '30': '校园项目'
+    '30': '校园项目',
+    '70': '安评项目'
   };
   return map[type] || '未知项目';
 };

+ 6 - 6
pages.json

@@ -12,12 +12,12 @@
         "navigationBarTitleText": "登录"
       }
     },
-      {
-      "path": "pages/login/index_his",
-      "style": {
-        "navigationBarTitleText": "登录"
-      }
-    },
+    //   {
+    //   "path": "pages/login/index_his",
+    //   "style": {
+    //     "navigationBarTitleText": "登录"
+    //   }
+    // },
     {
       "path": "pages/login/register",
       "style": {

+ 302 - 35
pages/login/index.vue

@@ -3,9 +3,80 @@
     <view class="bg-shape shadow"></view>
     <view class="bg-shape2 shadow"></view>
 
-    <view class="loading-box">
-      <uv-loading-icon color="#3b82f6" size="30"></uv-loading-icon>
-      <text class="loading-text">正在自动登录,请稍候...</text>
+    <view class="login-box">
+      <view class="header">
+        <text class="title">欢迎登录</text>
+        <text class="subtitle">登录科研微信平台</text>
+      </view>
+
+      <view class="form-group">
+        <text class="label">账号</text>
+        <uv-input v-model="loginForm.userName" placeholder="请输入您的账号" placeholderClass="placeholder-style" border="none"
+          shape="circle" clearable
+          customStyle="background: #f1f5f9; padding: 20rpx 30rpx; height: 60rpx; border: 2rpx solid transparent; transition: all 0.3s ease; border-radius: 20rpx;">
+          <template #prefix>
+            <uv-icon name="account" size="22" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+          </template>
+        </uv-input>
+      </view>
+
+      <view class="form-group">
+        <text class="label">密码</text>
+        <uv-input v-model="loginForm.password" :type="showPassword ? 'text' : 'password'" placeholder="请输入密码"
+          placeholderClass="placeholder-style" border="none" shape="circle"
+          customStyle="background: #f1f5f9; padding: 20rpx 30rpx; height: 60rpx; border: 2rpx solid transparent; transition: all 0.3s ease; border-radius: 20rpx;">
+          <template #prefix>
+            <uv-icon name="lock" size="22" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+          </template>
+          <template #suffix>
+            <uv-icon :name="showPassword ? 'eye-fill' : 'eye-off'" size="22" color="#94a3b8"
+              @click="showPassword = !showPassword" customStyle="margin-left: 16rpx; cursor: pointer"></uv-icon>
+          </template>
+        </uv-input>
+      </view>
+
+      <view class="form-group" v-if="configSetting.isCaptcha === '10'">
+        <text class="label">验证码</text>
+        <view class="captcha-group">
+          <uv-input v-model="loginForm.idValueC" placeholder="请输入验证码" placeholderClass="placeholder-style" border="none"
+            shape="circle" clearable
+            customStyle="flex: 1; background: #f1f5f9; padding: 20rpx 30rpx; height: 60rpx; border: 2rpx solid transparent; transition: all 0.3s ease; border-radius: 20rpx;">
+            <template #prefix>
+              <uv-icon name="photo" size="22" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+            </template>
+          </uv-input>
+          <image v-if="codeUrl" :src="codeUrl" class="captcha-img" @click="getCaptchaImg" mode="aspectFit"></image>
+        </view>
+      </view>
+
+      <view class="options">
+        <uv-checkbox-group v-model="rememberMeArr">
+          <uv-checkbox label="记住密码" name="remember" shape="square" activeColor="#3b82f6" labelSize="13" iconSize="14"
+            size="16" labelColor="#475569"></uv-checkbox>
+        </uv-checkbox-group>
+
+        <!-- <text class="forgot">忘记密码?</text> -->
+      </view>
+
+      <uv-button text="登 录" @click="handleLogin" :loading="loading" shape="circle"
+        color="linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)"
+        customStyle="height: 100rpx; font-size: 32rpx; font-weight: 600; letter-spacing: 2rpx; box-shadow: 0 10rpx 20rpx rgba(37, 99, 235, 0.3); border: none; margin-top: 20rpx;" />
+
+      <!-- 企业微信一键登录 -->
+      <view style="margin-top: 30rpx;">
+        <uv-button text="企业微信登录" @click="handleEnterpriseLoginClick" :loading="loading" shape="circle" plain
+          color="#07c160"
+          customStyle="height: 100rpx; font-size: 30rpx; font-weight: 500; border: 2rpx solid #07c160; color: #07c160;">
+          <template #prefix>
+            <uv-icon name="weixin-fill" size="24" color="#07c160" customStyle="margin-right: 12rpx"></uv-icon>
+          </template>
+        </uv-button>
+      </view>
+
+      <view class="footer">
+        <text class="footer-text">还没有账号?</text>
+        <text class="footer-link" @click="goToRegister">立即注册</text>
+      </view>
     </view>
 
     <uv-toast ref="toastRef"></uv-toast>
@@ -13,51 +84,192 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue';
+import { ref, reactive, onMounted } from 'vue';
+import { storeToRefs } from 'pinia';
 import { useUserStore } from '@/store/modules/user';
+import type { LoginParams } from '@/types/user';
+import { CACHE_KEY } from '@/constants/index';
+// @ts-ignore
+import { Local } from '@/utils/storage';
+import { useLoginApi } from '@/api/system/login';
+import { encryptWithBackendConfig } from '@/utils/aesCrypto';
 import { getDingTalkAuthCode } from '@/utils/dingtalk';
 import * as dd from 'dingtalk-jsapi';
 
 const userStore = useUserStore();
+
+const { configSetting } = storeToRefs(userStore);
+
+const { checkCaptcha, login, dingTalkLogin } = userStore;
+
+const loginApi = useLoginApi();
+
+const loginForm = reactive<LoginParams>({
+  userName: '',
+  password: '',
+  idValueC: '',
+  idKeyC: '',
+  saltValue: '',
+});
+
+const codeUrl = ref('');
+const loading = ref(false);
+const showPassword = ref(false);
+const rememberMeArr = ref<string[]>([]);
 const toastRef = ref<any>(null);
+// 是否在钉钉环境中
+const isInDingTalk = ref(dd.env.platform !== 'notInDingTalk');
 
 onMounted(async () => {
-  // --- 逻辑拦截 ---
+  // --- 新增:登录拦截 ---
   // 如果缓存里已经有 Token,说明当前处于已登录状态
-  if (userStore.token) {
-    uni.switchTab({ url: '/pages/home/index' }).catch(() => {
+  // 不应该停留在当前登录大厅,直接跳走回首页
+  const existingToken = Local.get(CACHE_KEY.TOKEN);
+  if (existingToken) {
+    uni.switchTab({ url: '/pages/home/index' }).catch((err) => {
+      console.warn('switchTab failed, trying reLaunch:', err);
       uni.reLaunch({ url: '/pages/home/index' });
     });
+    return; // 不要继续执行后面的加载验证码之类
+  }
+
+  // 检查是否有记住的密码
+  const savedUser = Local.get(CACHE_KEY.REMEMBER_USER);
+  if (savedUser) {
+    // 可能是对象也可能是 JSON
+    const user = typeof savedUser === 'string' ? JSON.parse(savedUser) : savedUser;
+    loginForm.userName = user.userName || user.username;
+    loginForm.password = user.password;
+    rememberMeArr.value = ['remember'];
+  }
+
+  // 检测 URL 是否携带 code (企业微信免登)
+  const options = uni.getLaunchOptionsSync()?.query;
+  const urlCode = options?.code;
+  if (urlCode) {
+    handleEnterpriseLogin(urlCode);
     return;
   }
 
-  // 仅在钉钉环境下尝试免登
-  if (dd.env.platform !== 'notInDingTalk') {
-    try {
-      const corpId = uni.getLaunchOptionsSync()?.query?.corpId || import.meta.env.VITE_DINGTALK_CORPID;
-      const code = await getDingTalkAuthCode(corpId);
-      if (code) {
-        await userStore.dingTalkLogin(code);
-        toastRef.value.show({ message: '登录成功', type: 'success' });
-        // 成功,跳转首页
-        setTimeout(() => {
-          uni.switchTab({ url: '/pages/home/index' }).catch(() => {
-            uni.reLaunch({ url: '/pages/home/index' });
-          });
-        }, 800);
-      } else {
-        throw new Error('未获取到授权码');
-      }
-    } catch (error) {
-      console.error('DingTalk Login Failure in Login Page:', error);
-      // 免登失败(或账号未绑定),引导去注册
-      uni.reLaunch({ url: '/pages/login/register' });
-    }
-  } else {
-    // 不在钉钉环境下,直接跳转到注册页(因为不需要登录页)
-    uni.reLaunch({ url: '/pages/login/register' });
+  await checkCaptcha();
+  if (configSetting.value.isCaptcha === '10') {
+    getCaptchaImg();
   }
 });
+
+const getCaptchaImg = async () => {
+  try {
+    const res = await loginApi.getCaptchaImg();
+    codeUrl.value = res?.data?.base64stringC || '';
+    loginForm.idKeyC = res?.data?.idKeyC || '';
+  } catch (error) {
+    console.error('获取验证码失败', error);
+  }
+};
+
+const handleLogin = async () => {
+  if (!loginForm.userName) {
+    toastRef.value.show({ message: '请输入账号', type: 'error' });
+    return;
+  }
+  if (!loginForm.password) {
+    toastRef.value.show({ message: '请输入密码', type: 'error' });
+    return;
+  }
+
+  if (configSetting.value.isCaptcha === '10' && !loginForm.idValueC) {
+    toastRef.value.show({ message: '请输入验证码', type: 'error' });
+    return;
+  }
+
+  loading.value = true;
+
+  try {
+    // 使用与后端完全匹配的加密函数加密密码
+    const encryptedPassword = encryptWithBackendConfig(loginForm.password);
+    loginForm.saltValue = encryptedPassword; // 后端AES IV常量 登录的时候 增加saltValue
+
+    // 调用 store 处理真实请求和持久化
+    await login(loginForm);
+
+    // 记住密码逻辑独立于全局token的持久化
+    if (rememberMeArr.value.includes('remember')) {
+      Local.set(CACHE_KEY.REMEMBER_USER, {
+        userName: loginForm.userName,
+        password: loginForm.password
+      });
+    } else {
+      Local.remove(CACHE_KEY.REMEMBER_USER);
+    }
+
+    toastRef.value.show({ message: '登录成功', type: 'success' });
+
+    // 登录成功跳转首页
+    setTimeout(() => {
+      uni.switchTab({ url: '/pages/home/index' }).catch((err) => {
+        console.warn('switchTab failed, trying reLaunch:', err);
+        uni.reLaunch({ url: '/pages/home/index' });
+      });
+    }, 1000);
+
+  } catch (error) {
+    console.error('Login Failed', error);
+    // 登录失败若有验证码,刷新验证码
+    if (configSetting.value.isCaptcha === '10') {
+      loginForm.idValueC = '';
+      getCaptchaImg();
+    }
+  } finally {
+    loading.value = false;
+  }
+};
+
+const goToRegister = () => {
+  uni.navigateTo({ url: '/pages/login/register' });
+};
+
+const handleEnterpriseLogin = async (code: string) => {
+  try {
+    loading.value = true;
+    await userStore.oAuthLogin(code);
+    toastRef.value.show({ message: '登录成功', type: 'success' });
+    
+    // 成功跳转首页
+    setTimeout(() => {
+      uni.switchTab({ url: '/pages/home/index' }).catch(() => {
+        uni.reLaunch({ url: '/pages/home/index' });
+      });
+    }, 800);
+  } catch (error: any) {
+    console.error('Enterprise Login Failure:', error);
+    toastRef.value.show({ 
+      message: error.message || '企业快捷登录失败', 
+      type: 'error' 
+    });
+    // 如果免登失败,正常加载验证码进入手动登录模式
+    await checkCaptcha();
+    if (configSetting.value.isCaptcha === '10') {
+      getCaptchaImg();
+    }
+  } finally {
+    loading.value = false;
+  }
+};
+
+/**
+ * 企业微信登录点击 (重定向获取 code)
+ */
+const handleEnterpriseLoginClick = () => {
+  // 注意:实际 AppID 需要根据后台配置,此处假设通过环境变量或逻辑获取
+  // 如果是免登,通常是直接进入这个页面时 URL 就已经带了 code
+  // 如果需要手动点击跳转获取,则执行如下逻辑:
+  const appid = import.meta.env.VITE_WECOM_APPID || ''; 
+  const redirectUri = encodeURIComponent(window.location.href.split('?')[0]);
+  const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect`;
+  
+  // H5 环境下跳转
+  window.location.href = url;
+};
 </script>
 
 <style scoped>
@@ -96,11 +308,66 @@ onMounted(async () => {
   filter: blur(50rpx);
 }
 
-.loading-box {
+.login-box {
+  width: 100%;
+  max-width: 600rpx;
+  background: rgba(255, 255, 255, 0.9);
+  backdrop-filter: blur(20px);
+  border-radius: 40rpx;
+  padding: 60rpx 40rpx;
+  box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, 0.05);
+  position: relative;
+  z-index: 10;
+  border: 2rpx solid rgba(255, 255, 255, 0.5);
+}
+
+.header {
+  margin-bottom: 60rpx;
+}
+
+.title {
+  font-size: 56rpx;
+  font-weight: 800;
+  color: #1e293b;
+  display: block;
+  margin-bottom: 16rpx;
+  font-family: 'Helvetica Neue', Arial, sans-serif;
+}
+
+.subtitle {
+  font-size: 28rpx;
+  color: #64748b;
+  display: block;
+}
+
+.form-group {
+  margin-bottom: 40rpx;
+}
+
+.label {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #334155;
+  margin-bottom: 16rpx;
+  display: block;
+}
+
+/* 移除原有的 input-container 和 checkbox 样式,采用 uvui 内置属性或者 customStyle 修改样式 */
+
+:deep(.placeholder-style) {
+  color: #94a3b8 !important;
+  font-size: 28rpx;
+}
+
+.captcha-group {
   display: flex;
-  flex-direction: column;
   align-items: center;
-  z-index: 10;
+  gap: 20rpx;
+}
+
+.captcha-img {
+  width: 200rpx;
+  height: 104rpx;
   /* 与左侧输入框 60(height) + 40(padding) + 4(border) 高度保持一致 */
   border-radius: 20rpx;
   background: #f1f5f9;

+ 143 - 0
pages/login/index1.vue

@@ -0,0 +1,143 @@
+<template>
+  <view class="container">
+    <view class="bg-shape shadow"></view>
+    <view class="bg-shape2 shadow"></view>
+
+    <view class="loading-box">
+      <uv-loading-icon color="#3b82f6" size="30"></uv-loading-icon>
+      <text class="loading-text">正在自动登录,请稍候...</text>
+    </view>
+
+    <uv-toast ref="toastRef"></uv-toast>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { useUserStore } from '@/store/modules/user';
+import { getDingTalkAuthCode } from '@/utils/dingtalk';
+import * as dd from 'dingtalk-jsapi';
+
+const userStore = useUserStore();
+const toastRef = ref<any>(null);
+
+onMounted(async () => {
+  // --- 逻辑拦截 ---
+  // 如果缓存里已经有 Token,说明当前处于已登录状态
+  if (userStore.token) {
+    uni.switchTab({ url: '/pages/home/index' }).catch(() => {
+      uni.reLaunch({ url: '/pages/home/index' });
+    });
+    return;
+  }
+
+  // 仅在钉钉环境下尝试免登
+  if (dd.env.platform !== 'notInDingTalk') {
+    try {
+      const corpId = uni.getLaunchOptionsSync()?.query?.corpId || import.meta.env.VITE_DINGTALK_CORPID;
+      const code = await getDingTalkAuthCode(corpId);
+      if (code) {
+        await userStore.dingTalkLogin(code);
+        toastRef.value.show({ message: '登录成功', type: 'success' });
+        // 成功,跳转首页
+        setTimeout(() => {
+          uni.switchTab({ url: '/pages/home/index' }).catch(() => {
+            uni.reLaunch({ url: '/pages/home/index' });
+          });
+        }, 800);
+      } else {
+        throw new Error('未获取到授权码');
+      }
+    } catch (error) {
+      console.error('DingTalk Login Failure in Login Page:', error);
+      // 免登失败(或账号未绑定),引导去注册
+      uni.reLaunch({ url: '/pages/login/register' });
+    }
+  } else {
+    // 不在钉钉环境下,直接跳转到注册页(因为不需要登录页)
+    uni.reLaunch({ url: '/pages/login/register' });
+  }
+});
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  position: relative;
+  background: #f8fafc;
+  overflow: hidden;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 0 40rpx;
+}
+
+.bg-shape {
+  position: absolute;
+  top: -100rpx;
+  right: -100rpx;
+  width: 500rpx;
+  height: 500rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #3b82f6 0%, #2dd4bf 100%);
+  opacity: 0.15;
+  filter: blur(40rpx);
+}
+
+.bg-shape2 {
+  position: absolute;
+  bottom: -150rpx;
+  left: -100rpx;
+  width: 600rpx;
+  height: 600rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
+  opacity: 0.15;
+  filter: blur(50rpx);
+}
+
+.loading-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  z-index: 10;
+  /* 与左侧输入框 60(height) + 40(padding) + 4(border) 高度保持一致 */
+  border-radius: 20rpx;
+  background: #f1f5f9;
+  flex-shrink: 0;
+  cursor: pointer;
+}
+
+.options {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 50rpx;
+}
+
+.forgot {
+  font-size: 26rpx;
+  color: #3b82f6;
+  font-weight: 500;
+  padding: 10rpx 0;
+}
+
+.footer {
+  margin-top: 40rpx;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.footer-text {
+  font-size: 26rpx;
+  color: #64748b;
+}
+
+.footer-link {
+  font-size: 26rpx;
+  color: #3b82f6;
+  font-weight: 600;
+  margin-left: 10rpx;
+}
+</style>

+ 0 - 399
pages/login/index_his.vue

@@ -1,399 +0,0 @@
-<template>
-  <view class="container">
-    <view class="bg-shape shadow"></view>
-    <view class="bg-shape2 shadow"></view>
-
-    <view class="login-box">
-      <view class="header">
-        <text class="title">欢迎登录</text>
-        <text class="subtitle">登录科研钉钉平台</text>
-      </view>
-
-      <view class="form-group">
-        <text class="label">账号</text>
-        <uv-input v-model="loginForm.userName" placeholder="请输入您的账号" placeholderClass="placeholder-style" border="none"
-          shape="circle" clearable
-          customStyle="background: #f1f5f9; padding: 20rpx 30rpx; height: 60rpx; border: 2rpx solid transparent; transition: all 0.3s ease; border-radius: 20rpx;">
-          <template #prefix>
-            <uv-icon name="account" size="22" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
-          </template>
-        </uv-input>
-      </view>
-
-      <view class="form-group">
-        <text class="label">密码</text>
-        <uv-input v-model="loginForm.password" :type="showPassword ? 'text' : 'password'" placeholder="请输入密码"
-          placeholderClass="placeholder-style" border="none" shape="circle"
-          customStyle="background: #f1f5f9; padding: 20rpx 30rpx; height: 60rpx; border: 2rpx solid transparent; transition: all 0.3s ease; border-radius: 20rpx;">
-          <template #prefix>
-            <uv-icon name="lock" size="22" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
-          </template>
-          <template #suffix>
-            <uv-icon :name="showPassword ? 'eye-fill' : 'eye-off'" size="22" color="#94a3b8"
-              @click="showPassword = !showPassword" customStyle="margin-left: 16rpx; cursor: pointer"></uv-icon>
-          </template>
-        </uv-input>
-      </view>
-
-      <view class="form-group" v-if="configSetting.isCaptcha === '10'">
-        <text class="label">验证码</text>
-        <view class="captcha-group">
-          <uv-input v-model="loginForm.idValueC" placeholder="请输入验证码" placeholderClass="placeholder-style" border="none"
-            shape="circle" clearable
-            customStyle="flex: 1; background: #f1f5f9; padding: 20rpx 30rpx; height: 60rpx; border: 2rpx solid transparent; transition: all 0.3s ease; border-radius: 20rpx;">
-            <template #prefix>
-              <uv-icon name="photo" size="22" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
-            </template>
-          </uv-input>
-          <image v-if="codeUrl" :src="codeUrl" class="captcha-img" @click="getCaptchaImg" mode="aspectFit"></image>
-        </view>
-      </view>
-
-      <view class="options">
-        <uv-checkbox-group v-model="rememberMeArr">
-          <uv-checkbox label="记住密码" name="remember" shape="square" activeColor="#3b82f6" labelSize="13" iconSize="14"
-            size="16" labelColor="#475569"></uv-checkbox>
-        </uv-checkbox-group>
-
-        <!-- <text class="forgot">忘记密码?</text> -->
-      </view>
-
-      <uv-button text="登 录" @click="handleLogin" :loading="loading" shape="circle"
-        color="linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)"
-        customStyle="height: 100rpx; font-size: 32rpx; font-weight: 600; letter-spacing: 2rpx; box-shadow: 0 10rpx 20rpx rgba(37, 99, 235, 0.3); border: none; margin-top: 20rpx;" />
-
-      <!-- 钉钉免登按钮 (仅在钉钉环境下显示) -->
-      <view v-if="isInDingTalk" style="margin-top: 30rpx;">
-        <uv-button text="钉钉一键登录" @click="handleDingTalkLogin" :loading="dingTalkLoading" shape="circle" plain
-          color="#0089ff"
-          customStyle="height: 100rpx; font-size: 30rpx; font-weight: 500; border: 2rpx solid #0089ff; color: #0089ff;">
-          <template #prefix>
-            <uv-icon name="dingtalk" size="24" color="#0089ff" customStyle="margin-right: 12rpx"></uv-icon>
-          </template>
-        </uv-button>
-      </view>
-
-      <view class="footer">
-        <text class="footer-text">还没有账号?</text>
-        <text class="footer-link" @click="goToRegister">立即注册</text>
-      </view>
-    </view>
-
-    <uv-toast ref="toastRef"></uv-toast>
-  </view>
-</template>
-
-<script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue';
-import { storeToRefs } from 'pinia';
-import { useUserStore } from '@/store/modules/user';
-import type { LoginParams } from '@/types/user';
-import { CACHE_KEY } from '@/constants/index';
-// @ts-ignore
-import { Local } from '@/utils/storage';
-import { useLoginApi } from '@/api/system/login';
-import { encryptWithBackendConfig } from '@/utils/aesCrypto';
-import { getDingTalkAuthCode } from '@/utils/dingtalk';
-import * as dd from 'dingtalk-jsapi';
-
-const userStore = useUserStore();
-
-const { configSetting } = storeToRefs(userStore);
-
-const { checkCaptcha, login, dingTalkLogin } = userStore;
-
-const loginApi = useLoginApi();
-
-const loginForm = reactive<LoginParams>({
-  userName: '',
-  password: '',
-  idValueC: '',
-  idKeyC: '',
-  saltValue: '',
-});
-
-const codeUrl = ref('');
-const loading = ref(false);
-const dingTalkLoading = ref(false);
-const showPassword = ref(false);
-const rememberMeArr = ref<string[]>([]);
-const toastRef = ref<any>(null);
-
-// 是否在钉钉环境中
-const isInDingTalk = ref(dd.env.platform !== 'notInDingTalk');
-
-onMounted(async () => {
-  // --- 新增:登录拦截 ---
-  // 如果缓存里已经有 Token,说明当前处于已登录状态
-  // 不应该停留在当前登录大厅,直接跳走回首页
-  const existingToken = Local.get(CACHE_KEY.TOKEN);
-  if (existingToken) {
-    uni.switchTab({ url: '/pages/home/index' }).catch((err) => {
-      console.warn('switchTab failed, trying reLaunch:', err);
-      uni.reLaunch({ url: '/pages/home/index' });
-    });
-    return; // 不要继续执行后面的加载验证码之类
-  }
-
-  // 检查是否有记住的密码
-  const savedUser = Local.get(CACHE_KEY.REMEMBER_USER);
-  if (savedUser) {
-    // 可能是对象也可能是 JSON
-    const user = typeof savedUser === 'string' ? JSON.parse(savedUser) : savedUser;
-    loginForm.userName = user.userName || user.username;
-    loginForm.password = user.password;
-    rememberMeArr.value = ['remember'];
-  }
-
-  await checkCaptcha();
-  if (configSetting.value.isCaptcha === '10') {
-    getCaptchaImg();
-  }
-});
-
-const getCaptchaImg = async () => {
-  try {
-    const res = await loginApi.getCaptchaImg();
-    codeUrl.value = res?.data?.base64stringC || '';
-    loginForm.idKeyC = res?.data?.idKeyC || '';
-  } catch (error) {
-    console.error('获取验证码失败', error);
-  }
-};
-
-const handleLogin = async () => {
-  if (!loginForm.userName) {
-    toastRef.value.show({ message: '请输入账号', type: 'error' });
-    return;
-  }
-  if (!loginForm.password) {
-    toastRef.value.show({ message: '请输入密码', type: 'error' });
-    return;
-  }
-
-  if (configSetting.value.isCaptcha === '10' && !loginForm.idValueC) {
-    toastRef.value.show({ message: '请输入验证码', type: 'error' });
-    return;
-  }
-
-  loading.value = true;
-
-  try {
-    // 使用与后端完全匹配的加密函数加密密码
-    const encryptedPassword = encryptWithBackendConfig(loginForm.password);
-    loginForm.saltValue = encryptedPassword; // 后端AES IV常量 登录的时候 增加saltValue
-
-    // 调用 store 处理真实请求和持久化
-    await login(loginForm);
-
-    // 记住密码逻辑独立于全局token的持久化
-    if (rememberMeArr.value.includes('remember')) {
-      Local.set(CACHE_KEY.REMEMBER_USER, {
-        userName: loginForm.userName,
-        password: loginForm.password
-      });
-    } else {
-      Local.remove(CACHE_KEY.REMEMBER_USER);
-    }
-
-    toastRef.value.show({ message: '登录成功', type: 'success' });
-
-    // 登录成功跳转首页
-    setTimeout(() => {
-      uni.switchTab({ url: '/pages/home/index' }).catch((err) => {
-        console.warn('switchTab failed, trying reLaunch:', err);
-        uni.reLaunch({ url: '/pages/home/index' });
-      });
-    }, 1000);
-
-  } catch (error) {
-    console.error('Login Failed', error);
-    // 登录失败若有验证码,刷新验证码
-    if (configSetting.value.isCaptcha === '10') {
-      loginForm.idValueC = '';
-      getCaptchaImg();
-    }
-  } finally {
-    loading.value = false;
-  }
-};
-
-const goToRegister = () => {
-  uni.navigateTo({ url: '/pages/login/register' });
-};
-
-/**
- * 钉钉免登逻辑
- */
-const handleDingTalkLogin = async () => {
-  dingTalkLoading.value = true;
-  try {
-    // 尝试从当前 URL 获取 corpId,或者使用配置
-    // 注意:如果是通过 HBuilderX 运行到浏览器的,通常需要手动传入 corpId 调试
-    const corpId = uni.getLaunchOptionsSync()?.query?.corpId || import.meta.env.VITE_DINGTALK_CORPID;
-
-    const code = await getDingTalkAuthCode(corpId);
-
-    if (!code) {
-      throw new Error('获取授权码失败');
-    }
-
-    await dingTalkLogin(code);
-
-    toastRef.value.show({ message: '钉钉免登成功', type: 'success' });
-
-    // 跳转首页
-    setTimeout(() => {
-      uni.switchTab({ url: '/pages/home/index' }).catch((err) => {
-        uni.reLaunch({ url: '/pages/home/index' });
-      });
-    }, 1000);
-
-  } catch (error: any) {
-    console.error('DingTalk Login Error:', error);
-    toastRef.value.show({
-      message: error.message || error.errorMessage || '钉钉免登失败',
-      type: 'error'
-    });
-  } finally {
-    dingTalkLoading.value = false;
-  }
-};
-</script>
-
-<style scoped>
-.container {
-  min-height: 100vh;
-  position: relative;
-  background: #f8fafc;
-  overflow: hidden;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  padding: 0 40rpx;
-}
-
-.bg-shape {
-  position: absolute;
-  top: -100rpx;
-  right: -100rpx;
-  width: 500rpx;
-  height: 500rpx;
-  border-radius: 50%;
-  background: linear-gradient(135deg, #3b82f6 0%, #2dd4bf 100%);
-  opacity: 0.15;
-  filter: blur(40rpx);
-}
-
-.bg-shape2 {
-  position: absolute;
-  bottom: -150rpx;
-  left: -100rpx;
-  width: 600rpx;
-  height: 600rpx;
-  border-radius: 50%;
-  background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
-  opacity: 0.15;
-  filter: blur(50rpx);
-}
-
-.login-box {
-  width: 100%;
-  max-width: 600rpx;
-  background: rgba(255, 255, 255, 0.9);
-  backdrop-filter: blur(20px);
-  border-radius: 40rpx;
-  padding: 60rpx 40rpx;
-  box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, 0.05);
-  position: relative;
-  z-index: 10;
-  border: 2rpx solid rgba(255, 255, 255, 0.5);
-}
-
-.header {
-  margin-bottom: 60rpx;
-}
-
-.title {
-  font-size: 56rpx;
-  font-weight: 800;
-  color: #1e293b;
-  display: block;
-  margin-bottom: 16rpx;
-  font-family: 'Helvetica Neue', Arial, sans-serif;
-}
-
-.subtitle {
-  font-size: 28rpx;
-  color: #64748b;
-  display: block;
-}
-
-.form-group {
-  margin-bottom: 40rpx;
-}
-
-.label {
-  font-size: 26rpx;
-  font-weight: 600;
-  color: #334155;
-  margin-bottom: 16rpx;
-  display: block;
-}
-
-/* 移除原有的 input-container 和 checkbox 样式,采用 uvui 内置属性或者 customStyle 修改样式 */
-
-:deep(.placeholder-style) {
-  color: #94a3b8 !important;
-  font-size: 28rpx;
-}
-
-.captcha-group {
-  display: flex;
-  align-items: center;
-  gap: 20rpx;
-}
-
-.captcha-img {
-  width: 200rpx;
-  height: 104rpx;
-  /* 与左侧输入框 60(height) + 40(padding) + 4(border) 高度保持一致 */
-  border-radius: 20rpx;
-  background: #f1f5f9;
-  flex-shrink: 0;
-  cursor: pointer;
-}
-
-.options {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 50rpx;
-}
-
-.forgot {
-  font-size: 26rpx;
-  color: #3b82f6;
-  font-weight: 500;
-  padding: 10rpx 0;
-}
-
-.footer {
-  margin-top: 40rpx;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-}
-
-.footer-text {
-  font-size: 26rpx;
-  color: #64748b;
-}
-
-.footer-link {
-  font-size: 26rpx;
-  color: #3b82f6;
-  font-weight: 600;
-  margin-left: 10rpx;
-}
-</style>

+ 3 - 3
pages/project/components/HorizontalProject.vue

@@ -55,7 +55,7 @@ import { storeToRefs } from 'pinia';
 import { debounce } from 'lodash-es';
 import { useProjectStore } from '@/store/modules/project';
 import { formatDate } from '@/utils/date';
-import { projectStatusOptions } from '@/constants/index';
+import { horizontalProjectStatusOptions } from '@/constants/index';
 
 const props = defineProps({
   queryParams: {
@@ -96,12 +96,12 @@ const onRefresh = async () => {
 };
 
 const getStatusName = (code: string) => {
-  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  const item = horizontalProjectStatusOptions.find(opt => opt.dictValue === code);
   return item ? item.dictLabel : code || '未知状态';
 };
 
 const getStatusType = (code: string) => {
-  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  const item = horizontalProjectStatusOptions.find(opt => opt.dictValue === code);
   return item ? item.type : 'info';
 };
 

+ 168 - 0
pages/project/components/SafetyProject.vue

@@ -0,0 +1,168 @@
+<template>
+  <view class="project-list">
+    <scroll-view 
+      scroll-y 
+      class="scroll-list" 
+      :scroll-top="scrollTopValue"
+      scroll-with-animation
+      refresher-enabled
+      :refresher-triggered="isTriggered"
+      @refresherrefresh="onRefresh"
+      @scroll="onScroll"
+      @scrolltolower="loadMore"
+      :show-scrollbar="false"
+    >
+      <!-- uv-empty 用于查无数据时的展示 -->
+      <uv-empty v-if="!safetyList.length && loadStatus !== 'loading'" mode="list"></uv-empty>
+
+      <view 
+        class="common-list-card" 
+        v-for="item in safetyList" 
+        :key="item.id" 
+        @click="onRowClick(item)"
+      >
+        <view class="card-header">
+          <text class="title">{{ item.projectName || '未命名' }}</text>
+          <uv-tags :text="getStatusName(item.projectStatus)" :type="getStatusType(item.projectStatus)" size="mini" plain></uv-tags>
+        </view>
+        <view class="card-body">
+          <view class="info-item">
+            <text class="label">签订日期:</text>
+            <view class="value" style="display: flex; justify-content: space-between;">
+              <text>{{ formatDate(item.signDate || item.approvalDate) }}</text>
+              <text style="color: #585858;">{{ item.projectLeaderName || '-' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <!-- 加载更多 -->
+      <uv-load-more v-if="safetyList.length > 0 || loadStatus === 'loading'" :status="loadStatus" @loadmore="loadMore"></uv-load-more>
+    </scroll-view>
+
+    <!-- 返回顶部按钮 -->
+    <uv-back-top 
+      :scrollTop="currentScrollTop" 
+      :bottom="100" 
+      :right="30"
+      @click="backToTop"
+    ></uv-back-top>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, nextTick } from 'vue';
+import { storeToRefs } from 'pinia';
+import { debounce } from 'lodash-es';
+import { useProjectStore } from '@/store/modules/project';
+import { formatDate } from '@/utils/date';
+import { safetyProjectStatusOptions } from '@/constants/index';
+
+const props = defineProps({
+  queryParams: {
+    type: Object,
+    default: () => ({})
+  }
+});
+
+const emit = defineEmits(['goDetail']);
+const projectStore = useProjectStore();
+const { safetyList, safetyPagination, fetchSafetyLoading } = storeToRefs(projectStore);
+
+const loadStatus = ref('loadmore'); // loadmore, loading, nomore
+
+// 滚动距离状态
+const scrollTopValue = ref(0);
+const currentScrollTop = ref(0); // 监听当前滚动的准确高度用于渲染显隐
+
+const onScroll = (e: any) => {
+  currentScrollTop.value = e.detail.scrollTop;
+};
+
+const backToTop = () => {
+  scrollTopValue.value = currentScrollTop.value;
+  nextTick(() => {
+    scrollTopValue.value = 0;
+  });
+};
+
+// ----------------- 下拉刷新功能 -----------------
+const isTriggered = ref(false);
+
+const onRefresh = async () => {
+  isTriggered.value = true;
+  await fetchData(true);
+  isTriggered.value = false;
+};
+
+const getStatusName = (code: string) => {
+  const item = safetyProjectStatusOptions.find(opt => opt.dictValue === code);
+  return item ? item.dictLabel : code || '未知状态';
+};
+
+const getStatusType = (code: string) => {
+  const item = safetyProjectStatusOptions.find(opt => opt.dictValue === code);
+  return item ? item.type : 'info';
+};
+
+const fetchData = async (reset = false) => {
+  loadStatus.value = 'loading';
+  
+  const { success, rows, total } = await projectStore.fetchSafetyProjects(props.queryParams, reset);
+  
+  if (success) {
+    if (safetyList.value.length >= total || rows.length < safetyPagination.value.pageSize) {
+      loadStatus.value = 'nomore';
+    } else {
+      loadStatus.value = 'loadmore';
+    }
+  } else {
+    loadStatus.value = 'nomore';
+  }
+};
+
+const loadMore = async () => {
+  if (loadStatus.value === 'nomore' || fetchSafetyLoading.value) return;
+  
+  loadStatus.value = 'loading';
+  const { success, rows, total } = await projectStore.loadMoreSafetyProjects(props.queryParams);
+  
+  if (success) {
+    if (safetyList.value.length >= total || rows.length < safetyPagination.value.pageSize) {
+      loadStatus.value = 'nomore';
+    } else {
+      loadStatus.value = 'loadmore';
+    }
+  } else {
+    loadStatus.value = 'nomore';
+  }
+};
+
+const debouncedFetchData = debounce(() => {
+  fetchData(true);
+}, 500);
+
+watch(() => props.queryParams, () => {
+  debouncedFetchData();
+}, { deep: true });
+
+onMounted(() => {
+  fetchData(true);
+});
+
+const onRowClick = (item: any) => {
+  emit('goDetail', { ...item, _type: 'safety' });
+};
+</script>
+
+<style lang="scss" scoped>
+.project-list {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.scroll-list {
+  flex: 1;
+  height: 100%;
+}
+</style>

+ 3 - 3
pages/project/components/SpontaneityProject.vue

@@ -55,7 +55,7 @@ import { storeToRefs } from 'pinia';
 import { debounce } from 'lodash-es';
 import { useProjectStore } from '@/store/modules/project';
 import { formatDate } from '@/utils/date';
-import { projectStatusOptions } from '@/constants/index';
+import { spontaneityProjectStatusOptions } from '@/constants/index';
 
 const props = defineProps({
   queryParams: {
@@ -96,12 +96,12 @@ const onRefresh = async () => {
 };
 
 const getStatusName = (code: string) => {
-  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  const item = spontaneityProjectStatusOptions.find(opt => opt.dictValue === code);
   return item ? item.dictLabel : code || '未知状态';
 };
 
 const getStatusType = (code: string) => {
-  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  const item = spontaneityProjectStatusOptions.find(opt => opt.dictValue === code);
   return item ? item.type : 'info';
 };
 

+ 3 - 3
pages/project/components/VerticalProject.vue

@@ -55,7 +55,7 @@ import { storeToRefs } from 'pinia';
 import { debounce } from 'lodash-es';
 import { useProjectStore } from '@/store/modules/project';
 import { formatDate } from '@/utils/date';
-import { projectStatusOptions } from '@/constants/index';
+import { verticalProjectStatusOptions } from '@/constants/index';
 
 const props = defineProps({
   queryParams: {
@@ -97,12 +97,12 @@ const onRefresh = async () => {
 };
 
 const getStatusName = (code: string) => {
-  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  const item = verticalProjectStatusOptions.find(opt => opt.dictValue === code);
   return item ? item.dictLabel : code || '未知状态';
 };
 
 const getStatusType = (code: string) => {
-  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  const item = verticalProjectStatusOptions.find(opt => opt.dictValue === code);
   return item ? item.type : 'info';
 };
 

+ 2 - 0
pages/project/components/detail/InspDetailPopup.vue

@@ -14,6 +14,8 @@
           <!-- 基本信息 -->
           <CommonSection title="基本信息">
             <CommonInfoRow label="中检批次名称" :value="projectStore.inspDetailData.batchName" />
+            <CommonInfoRow label="项目名称" :value="projectStore.inspDetailData.projectTitle || projectStore.inspDetailData.projectName" />
+            <CommonInfoRow label="负责人" :value="projectStore.inspDetailData.projectHeadName" />
             <CommonInfoRow 
               label="中检日期" 
               :value="(projectStore.inspDetailData.batchStartDate ? formatDate(projectStore.inspDetailData.batchStartDate) : '--') + ' ~ ' + (projectStore.inspDetailData.batchEndDate ? formatDate(projectStore.inspDetailData.batchEndDate) : '--')" 

+ 163 - 22
pages/project/components/detail/ProjectAchievements.vue

@@ -19,11 +19,10 @@
             v-for="(item, index) in projectStore.paperData" 
             :key="index" 
             :title="(index + 1) + '. ' + (item.paperName || '未知论文')"
+            @click="handleItemClick(item, 0)"
           >
             <CommonInfoRow label="论文编号" :value="item.paperCode" />
-            <CommonInfoRow label="发表/出版时间" :value="item.publicationDate ? formatDate(item.publicationDate) : '--'" />
-            <CommonInfoRow label="发表/刊物论文集" :value="item.publicationName" />
-            <CommonInfoRow label="论文类型" :value="getLabel(paperTypeOptions, item.paperType)" noBorder />
+            <CommonInfoRow label="发表时间" :value="item.publicationDate ? formatDate(item.publicationDate) : '--'" noBorder />
           </CommonListCard>
         </template>
       </view>
@@ -36,12 +35,10 @@
             v-for="(item, index) in projectStore.workData" 
             :key="index" 
             :title="(index + 1) + '. ' + (item.workName || '未知著作')"
+            @click="handleItemClick(item, 1)"
           >
             <CommonInfoRow label="著作编号" :value="item.workCode" />
-            <CommonInfoRow label="著作类别" :value="getLabel(workClassOptions, item.workClass)" />
-            <CommonInfoRow label="出版单位" :value="item.workPublisher" />
-            <CommonInfoRow label="出版时间" :value="item.workPublicationDate ? formatDate(item.workPublicationDate) : '--'" />
-            <CommonInfoRow label="所属单位" :value="item.deptName" noBorder />
+            <CommonInfoRow label="出版时间" :value="item.workPublicationDate ? formatDate(item.workPublicationDate) : '--'" noBorder />
           </CommonListCard>
         </template>
       </view>
@@ -54,12 +51,10 @@
             v-for="(item, index) in projectStore.patentData" 
             :key="index" 
             :title="(index + 1) + '. ' + (item.patentName || '未知专利')"
+            @click="handleItemClick(item, 2)"
           >
             <CommonInfoRow label="专利编号" :value="item.patentCode" />
-            <CommonInfoRow label="所属单位" :value="item.deptName" />
-            <CommonInfoRow label="专利类型" :value="getLabel(patentClassOptions, item.patentClass)" />
-            <CommonInfoRow label="专利简介" :value="item.patentDesc" />
-            <CommonInfoRow label="申请人" :value="item.applicantName" noBorder />
+            <CommonInfoRow label="专利类型" :value="getLabel(patentClassOptions, item.patentClass)" noBorder />
           </CommonListCard>
         </template>
       </view>
@@ -72,24 +67,35 @@
             v-for="(item, index) in projectStore.awardData" 
             :key="index" 
             :title="(index + 1) + '. ' + (item.awardName || '未知奖项')"
+            @click="handleItemClick(item, 3)"
           >
             <CommonInfoRow label="获奖编号" :value="item.awardCode" />
-            <CommonInfoRow label="成果名称" :value="item.resultName" />
-            <CommonInfoRow label="奖励类型" :value="getLabel(awardTypeOptions, item.awardType)" />
-            <CommonInfoRow label="发证机关" :value="item.awardIssueAuthority" />
-            <CommonInfoRow label="获奖级别" :value="getLabel(awardLevelOptions, item.awardLevel)" />
-            <CommonInfoRow label="获奖等级" :value="getLabel(awardGradeOptions, item.awardGrade)" />
-            <CommonInfoRow label="奖励类别" :value="getLabel(awardClassOptions, item.awardClass)" />
-            <CommonInfoRow label="获奖日期" :value="item.awardDate ? formatDate(item.awardDate) : '--'" />
-            <CommonInfoRow label="成果形式" :value="item.resultForm" />
-            <CommonInfoRow label="所属单位" :value="item.deptName" />
-            <CommonInfoRow label="合作类型" :value="getLabel(cooperationTypeOptions, item.cooperationType)" />
-            <CommonInfoRow label="项目来源" :value="item.projectSource" noBorder />
+            <CommonInfoRow label="获奖日期" :value="item.awardDate ? formatDate(item.awardDate) : '--'" noBorder />
           </CommonListCard>
         </template>
       </view>
 
     </view>
+
+    <!-- 详情弹窗 -->
+    <uv-popup ref="detailPopup" mode="bottom" round="20" bgColor="#f8f9fc">
+      <view class="detail-popup-content">
+        <view class="popup-header">
+          <text class="title">业绩详情</text>
+          <uv-icon name="close" @click="closePopup" color="#999" size="20"></uv-icon>
+        </view>
+        <scroll-view scroll-y class="detail-scroll">
+          <view class="detail-list">
+            <view class="detail-item" v-for="(field, idx) in currentFields" :key="idx">
+              <text class="label">{{ field.label }}</text>
+              <view class="value-wrap">
+                <text class="value">{{ formatFieldValue(field) }}</text>
+              </view>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </uv-popup>
   </view>
 </template>
 
@@ -126,6 +132,80 @@ const projectStore = useProjectStore();
 
 // 当前激活的子 Tab 索引 (学术论文/著作/专利/奖项)
 const currentTab = ref(0);
+const detailPopup = ref();
+const activeItem = ref<any>(null);
+const activeTabType = ref(0);
+
+// 详情字段配置
+const fieldConfigs = {
+  paper: [
+    { label: '论文名称', key: 'paperName' },
+    { label: '论文编号', key: 'paperCode' },
+    { label: '发表/出版时间', key: 'publicationDate', isDate: true },
+    { label: '发表/刊物论文集', key: 'publicationName' },
+    { label: '论文类型', key: 'paperType', options: paperTypeOptions },
+  ],
+  work: [
+    { label: '著作名称', key: 'workName' },
+    { label: '著作编号', key: 'workCode' },
+    { label: '著作类别', key: 'workClass', options: workClassOptions },
+    { label: '出版单位', key: 'workPublisher' },
+    { label: '出版时间', key: 'workPublicationDate', isDate: true },
+    { label: '所属单位', key: 'deptName' },
+  ],
+  patent: [
+    { label: '专利名称', key: 'patentName' },
+    { label: '专利编号', key: 'patentCode' },
+    { label: '所属单位', key: 'deptName' },
+    { label: '专利类型', key: 'patentClass', options: patentClassOptions },
+    { label: '专利简介', key: 'patentDesc' },
+    { label: '申请人', key: 'applicantName' },
+  ],
+  award: [
+    { label: '奖励名称', key: 'awardName' },
+    { label: '获奖编号', key: 'awardCode' },
+    { label: '成果名称', key: 'resultName' },
+    { label: '奖励类型', key: 'awardType', options: awardTypeOptions },
+    { label: '发证机关', key: 'awardIssueAuthority' },
+    { label: '获奖级别', key: 'awardLevel', options: awardLevelOptions },
+    { label: '获奖等级', key: 'awardGrade', options: awardGradeOptions },
+    { label: '奖励类别', key: 'awardClass', options: awardClassOptions },
+    { label: '获奖日期', key: 'awardDate', isDate: true },
+    { label: '成果形式', key: 'resultForm' },
+    { label: '所属单位', key: 'deptName' },
+    { label: '合作类型', key: 'cooperationType', options: cooperationTypeOptions },
+    { label: '项目来源', key: 'projectSource' },
+  ]
+};
+
+const currentFields = computed(() => {
+  if (activeTabType.value === 0) return fieldConfigs.paper;
+  if (activeTabType.value === 1) return fieldConfigs.work;
+  if (activeTabType.value === 2) return fieldConfigs.patent;
+  return fieldConfigs.award;
+});
+
+const formatFieldValue = (field: any) => {
+  if (!activeItem.value) return '--';
+  const val = activeItem.value[field.key];
+  if (field.isDate) return val ? formatDate(val) : '--';
+  if (field.options) return getLabel(field.options, val);
+  return val || '--';
+};
+
+/**
+ * 详情点击显示
+ */
+const handleItemClick = (item: any, type: number) => {
+  activeItem.value = item;
+  activeTabType.value = type;
+  detailPopup.value.open();
+};
+
+const closePopup = () => {
+  detailPopup.value.close();
+};
+
 const achievementTabs = ref([
   { name: '学术论文' },
   { name: '学术著作' },
@@ -202,4 +282,65 @@ const getLabel = (options: any[], value: string | number) => {
   flex-direction: column;
   gap: 20rpx;
 }
+
+.detail-popup-content {
+  padding: 40rpx 30rpx;
+  max-height: 85vh;
+  display: flex;
+  flex-direction: column;
+
+  .popup-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 40rpx;
+    padding: 0 10rpx;
+
+    .title {
+      font-size: 34rpx;
+      font-weight: bold;
+      color: #333;
+    }
+  }
+
+  .detail-scroll {
+    flex: 1;
+    overflow: hidden;
+  }
+
+  .detail-list {
+    display: flex;
+    flex-direction: column;
+    gap: 30rpx;
+
+    .detail-item {
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-start;
+      padding-bottom: 24rpx;
+      border-bottom: 1rpx solid #efefef;
+
+      .label {
+        font-size: 28rpx;
+        color: #909399;
+        flex-shrink: 0;
+        margin-right: 30rpx;
+      }
+
+      .value-wrap {
+        display: flex;
+        align-items: baseline;
+        justify-content: flex-end;
+        flex: 1;
+
+        .value {
+          font-size: 28rpx;
+          color: #333;
+          text-align: right;
+          word-break: break-all;
+        }
+      }
+    }
+  }
+}
 </style>

+ 2 - 0
pages/project/components/detail/ProjectApproval.vue

@@ -26,6 +26,8 @@ const computedDefCode = computed(() => {
     baseCode = 'sci_project_horizontal';
   } else if (props.projectType === 'spontaneity' || props.projectType === '30') {
     baseCode = 'sci_project_spontaneity';
+  } else if (props.projectType === 'safety' || props.projectType === '70') {
+    baseCode = 'sci_project_safety';
   }
 
   return prefix + baseCode;

+ 2 - 1
pages/project/components/detail/ProjectClosing.vue

@@ -12,9 +12,10 @@
         :statusLabel="getStatusLabel(item.approvalStatus)"
         :statusType="getStatusType(item.approvalStatus)"
       >
+        <CommonInfoRow label="项目名称" :value="item.projectTitle || '--'" />
+        <CommonInfoRow label="负责人" :value="item.projectHeadName || '--'" />
         <CommonInfoRow label="开始日期" :value="item.projectStartDate ? formatDate(item.projectStartDate) : '--'" />
         <CommonInfoRow label="结束日期" :value="item.projectEndDate ? formatDate(item.projectEndDate) : '--'" noBorder />
-        <CommonInfoRow label="项目中检日期" :value="item.projectEndDate ? formatDate(item.projectEndDate) : '--'" />
       </CommonListCard>
     </view>
   </view>

+ 10 - 10
pages/project/components/detail/ProjectCooperates.vue

@@ -1,15 +1,15 @@
 <template>
   <view class="module-container">
     <template v-if="projectData?.companyList && projectData.companyList.length > 0">
-      <view v-for="(company, index) in projectData.companyList" :key="index" class="party-card">
-        <view class="party-title">合作单位 {{ Number(index) + 1 }}</view>
-        <CommonInfoRow label="单位编号" :value="company.compCode" />
-        <CommonInfoRow label="单位名称" :value="company.compName" />
-        <CommonInfoRow label="单位类别" :value="getDictLabel('compType', company.compType)" />
-        <CommonInfoRow label="合同方" :value="getDictLabel('project_company_type', company.contractParty)" />
-        <CommonInfoRow label="单位地址" :value="company.compLocalArea" />
-        <CommonInfoRow label="联系人" :value="company.compContact" />
-        <CommonInfoRow label="联系电话" :value="company.compPhoneNum" noBorder />
+      <view v-for="(item, index) in projectData.companyList" :key="index" class="party-card">
+        <view class="party-title">单位 {{ Number(index) + 1 }}</view>
+        <CommonInfoRow label="单位编号" :value="item.compCode || '-'" />
+        <CommonInfoRow label="单位名称" :value="item.compName || '-'" />
+        <CommonInfoRow label="单位类别" :value="getDictLabel('sci_tripartite_type', item.compType)" />
+        <CommonInfoRow v-if="projectType !== 'vertical'" label="合同方" :value="getDictLabel('project_company_type', item.contractParty)" />
+        <CommonInfoRow label="单位地址" :value="item.compLocalArea || '-'" />
+        <CommonInfoRow label="联系人" :value="item.compContact || '-'" />
+        <CommonInfoRow label="联系电话" :value="item.compPhoneNum || '-'" noBorder />
       </view>
     </template>
     <uv-empty v-else mode="data" text="暂无数据"></uv-empty>
@@ -26,7 +26,7 @@ const props = defineProps<{
   projectData: any;
 }>();
 
-const { getDictLabel } = useDict('compType', 'project_company_type');
+const { getDictLabel } = useDict('sci_tripartite_type', 'project_company_type');
 </script>
 
 <style lang="scss" scoped>

+ 3 - 3
pages/project/components/detail/ProjectEthical.vue

@@ -20,7 +20,7 @@
 
         <CommonInfoRow label="审查类型" :value="getDictLabel('sci_review_type', item.reviewType)" />
         <CommonInfoRow label="审查方式" :value="item.reviewMethod == '10' ? '简易审查' : (item.reviewMethod == '20' ? '会议审查' : '--')" />
-        <CommonInfoRow label="承担科室" :value="item.deptName" />
+        <CommonInfoRow label="承担部门" :value="item.deptName" />
         <CommonInfoRow label="申请人" :value="item.createdName" />
         <CommonInfoRow label="申请日期" :value="item.createdTime ? formatDate(item.createdTime) : '--'" noBorder />
       </CommonListCard>
@@ -85,9 +85,9 @@ const getReviewStatusText = (status: string | number) => {
 
 const getReviewStatusType = (status: string | number) => {
   if (status == 10) return 'info';
-  if (status == 20) return 'primary';
+  if (status == 20) return 'warning';
   if (status == 30) return 'error';
-  if (status == 40) return 'warning';
+  if (status == 40) return 'primary';
   if (status == 50) return 'success';
   if (status == 60) return 'error';
   return 'info';

+ 212 - 35
pages/project/components/detail/ProjectFunding.vue

@@ -41,14 +41,11 @@
             :key="'in'+index"
             :title="item.allotNo || '入账记录'"
             statusType="primary"
+            @click="handleItemClick(item, 0)"
           >
-            <CommonInfoRow label="认领金额(元)" :value="amountUnitFormatter(item.amount)" isAmount />
-            <CommonInfoRow label="外拨金额(元)" :value="amountUnitFormatter(item.externalAmount)" />
-            <CommonInfoRow label="留校金额(元)" :value="amountUnitFormatter(item.internalAmount)" />
+            <CommonInfoRow label="认领金额" :value="amountUnitFormatter(item.amount)" isAmount />
             <CommonInfoRow label="认领时间" :value="item.applyTime ? formatDate(item.applyTime) : '--'" />
-            <CommonInfoRow label="摘要" :value="item.remark" />
-            <CommonInfoRow label="创建人" :value="item.createBy || item.createName" />
-            <CommonInfoRow label="创建时间" :value="item.createTime ? formatDate(item.createTime) : '--'" noBorder />
+            <CommonInfoRow label="负责人" :value="item.projectIncharge" noBorder />
           </CommonListCard>
         </template>
       </view>
@@ -62,15 +59,11 @@
             :key="'out'+index"
             :title="item.externalAllotNo || '外拨记录'"
             statusType="warning"
+            @click="handleItemClick(item, 1)"
           >
-            <CommonInfoRow label="项目编号" :value="item.projectNo" />
-            <CommonInfoRow label="外拨总额(元)" :value="amountUnitFormatter(item.amount)" isAmount />
-            <CommonInfoRow label="已拨金额(元)" :value="amountUnitFormatter(item.allottedAmount)" />
-            <CommonInfoRow label="未拨金额(元)" :value="amountUnitFormatter(item.notAllottedAmount)" />
+            <CommonInfoRow label="外拨总额" :value="amountUnitFormatter(item.amount)" isAmount />
             <CommonInfoRow label="申请日期" :value="item.applyTime ? formatDate(item.applyTime) : '--'" />
-            <CommonInfoRow label="摘要" :value="item.remark" />
-            <CommonInfoRow label="创建人" :value="item.createBy || item.createName" />
-            <CommonInfoRow label="创建时间" :value="item.createTime ? formatDate(item.createTime) : '--'" noBorder />
+            <CommonInfoRow label="负责人" :value="item.projectIncharge" noBorder />
           </CommonListCard>
         </template>
       </view>
@@ -85,28 +78,49 @@
             :title="item.expenseNo || '支出记录'"
             :statusLabel="getExpenseStatusLabel(item.status)"
             :statusType="getExpenseStatusType(item.status)"
+            @click="handleItemClick(item, 2)"
           >
-            <CommonInfoRow label="支出金额(元)" :value="amountUnitFormatter(item.amount)" isAmount />
+            <CommonInfoRow label="支出金额" :value="amountUnitFormatter(item.amount)" isAmount />
             <CommonInfoRow label="支出科目" :value="item.subject" />
-            <CommonInfoRow label="收款人" :value="item.receiver" />
-            <CommonInfoRow label="支出日期" :value="item.expendTime ? formatDate(item.expendTime) : '--'" />
-            <CommonInfoRow label="摘要" :value="item.remark" />
-            <CommonInfoRow label="创建人" :value="item.createBy || item.createName" />
-            <CommonInfoRow label="创建时间" :value="item.createTime ? formatDate(item.createTime) : '--'" :noBorder="!(item.fileName && item.fileUrl)" />
-            
-            <view v-if="item.fileName && item.fileUrl" style="margin-top: 20rpx;">
-              <CommonFileList :files="[{ fileName: item.fileName, fileUrl: item.fileUrl }]" />
-            </view>
+            <CommonInfoRow label="支出日期" :value="item.expendTime ? formatDate(item.expendTime) : '--'" noBorder />
           </CommonListCard>
         </template>
       </view>
 
     </view>
+
+    <!-- 详情弹窗 -->
+    <uv-popup ref="detailPopup" mode="bottom" round="20" bgColor="#f8f9fc">
+      <view class="detail-popup-content">
+        <view class="popup-header">
+          <text class="title">详情记录</text>
+          <uv-icon name="close" @click="closePopup" color="#999" size="20"></uv-icon>
+        </view>
+        <scroll-view scroll-y class="detail-scroll">
+          <view class="detail-list">
+            <view class="detail-item" v-for="(field, idx) in currentFields" :key="idx">
+              <text class="label">{{ field.label }}</text>
+              <view class="value-wrap">
+                <text class="value" :class="{ 'amount': field.isAmount }">
+                  {{ formatFieldValue(field) }}
+                </text>
+                <text class="unit" v-if="field.isAmount">元</text>
+              </view>
+            </view>
+            
+            <view v-if="activeItem?.fileName" class="attachment-section">
+              <text class="label">附件信息</text>
+              <CommonFileList :files="[{ fileName: activeItem.fileName, fileUrl: activeItem.fileUrl }]" />
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </uv-popup>
   </view>
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue';
+import { ref, watch, computed } from 'vue';
 import { useProjectStore } from '@/store/modules/project';
 import { formatDate } from '@/utils/date';
 import { formatWithComma } from '@/utils/format';
@@ -129,6 +143,83 @@ const projectStore = useProjectStore();
 
 // 当前激活的子 Tab 索引
 const currentTab = ref(0);
+const detailPopup = ref();
+const activeItem = ref<any>(null);
+const activeTabType = ref(0);
+
+// 详情字段配置
+const fieldConfigs = {
+  inbound: [
+    { label: '认领编号', key: 'allotNo' },
+    { label: '项目负责人', key: 'projectIncharge' },
+    { label: '项目类型', key: 'projectType', isType: true },
+    { label: '认领金额', key: 'amount', isAmount: true },
+    { label: '外拨金额', key: 'externalAmount', isAmount: true },
+    { label: '留校金额', key: 'internalAmount', isAmount: true },
+    { label: '认领时间', key: 'applyTime', isDate: true },
+    { label: '摘要', key: 'remark' },
+    { label: '创建人', key: 'createdName' },
+    { label: '创建时间', key: 'createdTime', isDate: true },
+  ],
+  outbound: [
+    { label: '外拨编号', key: 'externalAllotNo' },
+    { label: '项目编号', key: 'projectNo' },
+    { label: '项目负责人', key: 'projectIncharge' },
+    { label: '外拨总额', key: 'amount', isAmount: true },
+    { label: '已拨金额', key: 'allottedAmount', isAmount: true },
+    { label: '未拨金额', key: 'notAllottedAmount', isAmount: true },
+    { label: '申请日期', key: 'applyTime', isDate: true },
+    { label: '摘要', key: 'remark' },
+    { label: '创建人', key: 'createdName' },
+    { label: '创建时间', key: 'createdTime', isDate: true },
+  ],
+  expense: [
+    { label: '支出编号', key: 'expenseNo' },
+    { label: '项目类型', key: 'projectType', isType: true },
+    { label: '项目名称', key: 'projectName' },
+    { label: '支出金额', key: 'amount', isAmount: true },
+    { label: '支出科目', key: 'subject' },
+    { label: '支出状态', key: 'status', isStatus: true },
+    { label: '合同金额', key: 'contractAmount', isAmount: true },
+    { label: '间接/配套经费支出', key: 'otherAmount', isAmount: true },
+    { label: '财政拨款支出', key: 'projectAmount', isAmount: true },
+    { label: '自筹经费支出', key: 'raiseAmount', isAmount: true },
+    { label: '收款人', key: 'receiver' },
+    { label: '支出日期', key: 'expendTime', isDate: true },
+    { label: '摘要', key: 'remark' },
+    { label: '创建人', key: 'createdName' },
+    { label: '创建时间', key: 'createdTime', isDate: true },
+  ]
+};
+
+const currentFields = computed(() => {
+  if (activeTabType.value === 0) return fieldConfigs.inbound;
+  if (activeTabType.value === 1) return fieldConfigs.outbound;
+  return fieldConfigs.expense;
+});
+
+const formatFieldValue = (field: any) => {
+  if (!activeItem.value) return '--';
+  const val = activeItem.value[field.key];
+  if (field.isAmount) return amountUnitFormatter(val);
+  if (field.isDate) return val ? formatDate(val) : '--';
+  if (field.isType) return getProjectType(val);
+  if (field.isStatus) return getExpenseStatusLabel(val);
+  return val || '--';
+};
+
+/**
+ * 详情点击显示
+ */
+const handleItemClick = (item: any, type: number) => {
+  activeItem.value = item;
+  activeTabType.value = type;
+  detailPopup.value.open();
+};
+
+const closePopup = () => {
+  detailPopup.value.close();
+};
 
 // 经费模块内部的子 Tab 配置
 const fundingTabs = ref([
@@ -221,13 +312,13 @@ const handleDownload = (file: any) => {
 }
 
 .summary-card {
-  background: #fff;
-  border-radius: 16rpx;
+  background: linear-gradient(135deg, #ffffff 0%, #f6faff 100%);
+  border-radius: 20rpx;
   padding: 30rpx;
-  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.03);
+  box-shadow: 0 8rpx 24rpx rgba(28, 155, 253, 0.08);
   display: flex;
   flex-direction: column;
-  gap: 20rpx;
+  gap: 30rpx;
 
   .sum-row {
     display: flex;
@@ -240,20 +331,22 @@ const handleDownload = (file: any) => {
       align-items: center;
 
       .sum-label {
-        font-size: 26rpx;
-        color: #888;
+        font-size: 24rpx;
+        color: #909399;
         margin-bottom: 12rpx;
       }
 
       .sum-value {
-        font-size: 36rpx;
-        font-weight: bold;
+        font-size: 38rpx;
+        font-weight: 700;
         font-family: din;
+        letter-spacing: 0.5px;
         
         .unit {
-          font-size: 24rpx;
-          margin-left: 4rpx;
-          font-weight: normal;
+          font-size: 22rpx;
+          margin-left: 6rpx;
+          font-weight: 400;
+          color: #999;
         }
       }
 
@@ -265,6 +358,90 @@ const handleDownload = (file: any) => {
   }
 }
 
+.detail-popup-content {
+  padding: 40rpx 30rpx;
+  max-height: 85vh;
+  display: flex;
+  flex-direction: column;
+
+  .popup-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 40rpx;
+    padding: 0 10rpx;
+
+    .title {
+      font-size: 34rpx;
+      font-weight: bold;
+      color: #333;
+    }
+  }
+
+  .detail-scroll {
+    flex: 1;
+    overflow: hidden;
+  }
+
+  .detail-list {
+    display: flex;
+    flex-direction: column;
+    gap: 30rpx;
+
+    .detail-item {
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-start;
+      padding-bottom: 24rpx;
+      border-bottom: 1rpx solid #efefef;
+
+      .label {
+        font-size: 28rpx;
+        color: #909399;
+        flex-shrink: 0;
+        margin-right: 30rpx;
+      }
+
+      .value-wrap {
+        display: flex;
+        align-items: baseline;
+        justify-content: flex-end;
+        flex: 1;
+
+        .value {
+          font-size: 28rpx;
+          color: #333;
+          text-align: right;
+          word-break: break-all;
+
+          &.amount {
+            color: #1c9bfd;
+            font-weight: bold;
+            font-family: din;
+            font-size: 32rpx;
+          }
+        }
+        
+        .unit {
+          font-size: 20rpx;
+          color: #999;
+          margin-left: 4rpx;
+        }
+      }
+    }
+
+    .attachment-section {
+      margin-top: 20rpx;
+      .label {
+        font-size: 28rpx;
+        color: #909399;
+        display: block;
+        margin-bottom: 16rpx;
+      }
+    }
+  }
+}
+
 .section {
   width: 100%;
 }

+ 4 - 0
pages/project/components/detail/ProjectInspection.vue

@@ -12,6 +12,10 @@
         :statusType="getStatusType(item.approvalStatus)"
         @click="handleDetail(item)"
       >
+        <CommonInfoRow 
+          label="负责人" 
+          :value="item.projectHeadName || '--'" 
+        />
         <CommonInfoRow 
           v-if="item.projectStartDate || item.projectEndDate"
           label="项目起止" 

+ 17 - 5
pages/project/components/detail/ProjectMembers.vue

@@ -17,16 +17,28 @@
           <!-- 详情信息区块 -->
           <view class="m-detail">
             <CommonInfoRow 
-              label="学位职称" 
-              :value="(getDictLabel('sci_academic_degree', row.degree) || '-') + ' / ' + (row.technicalTitle || '-')" 
+              label="学位" 
+              :value="getDictLabel('sci_academic_degree', row.degree) || '-'" 
             />
             <CommonInfoRow 
-              label="所属科室" 
-              :value="row.deptName" 
+              label="职称" 
+              :value="row.technicalTitle || '-'" 
+            />
+            <CommonInfoRow 
+              label="所属部门" 
+              :value="row.deptName || '-'" 
             />
             <CommonInfoRow 
               label="负责内容" 
-              :value="row.responsibleContent" 
+              :value="row.responsibleContent || '-'" 
+            />
+            <CommonInfoRow 
+              label="贡献率(%)" 
+              :value="row.contributionRate || '-'" 
+            />
+            <CommonInfoRow 
+              label="签署顺序" 
+              :value="row.order || '-'" 
               noBorder
             />
           </view>

+ 3 - 0
pages/project/components/detail/ProjectSetup.vue

@@ -6,6 +6,8 @@
     <ProjectSetupHorizontal v-else-if="projectType === 'horizontal'" :projectData="projectData" />
     <!-- 内部项目 -->
     <ProjectSetupSpontaneity v-else-if="projectType === 'spontaneity'" :projectData="projectData" />
+    <!-- 安评项目 -->
+    <ProjectSetupSafety v-else-if="projectType === 'safety'" :projectData="projectData" />
     <view v-else>
       <uv-empty mode="data" text="未知项目类型"></uv-empty>
     </view>
@@ -16,6 +18,7 @@
 import ProjectSetupVertical from './ProjectSetupVertical.vue';
 import ProjectSetupHorizontal from './ProjectSetupHorizontal.vue';
 import ProjectSetupSpontaneity from './ProjectSetupSpontaneity.vue';
+import ProjectSetupSafety from './ProjectSetupSafety.vue';
 
 const props = defineProps<{
   projectId: number;

+ 72 - 24
pages/project/components/detail/ProjectSetupHorizontal.vue

@@ -2,39 +2,50 @@
   <view class="setup-wrapper">
     <!-- 立项概览 -->
     <CommonSection title="立项概览" isFirst>
-      <CommonInfoRow label="院内项目编号" :value="projectData?.projectNo" />
+      <CommonInfoRow label="项目编号" :value="projectData?.projectCode" />
       <CommonInfoRow label="项目名称" :value="projectData?.projectName" />
-      <CommonInfoRow label="研究类型"
-        :value="projectData?.isClinicalTrial === '10' ? '临床试验' : (projectData?.isClinicalTrial === '20' ? '横向其他' : (projectData?.isClinicalTrial === '1010' ? 'GCP' : (projectData?.isClinicalTrial === '1020' ? '非GCP' : '--')))" />
-      <CommonInfoRow label="项目来源" :value="projectData?.projectSource" />
-
+      <CommonInfoRow label="项目学科分类" :value="getDictLabel('project_class', projectData?.disciplineFirstName)" />
+      <CommonInfoRow label="负责人类型" :value="getDictLabel('sci_leader_type', projectData?.projectLeaderType)" />
+      
       <CommonInfoRow v-if="projectData?.isClinicalTrial === '20'" label="项目类别"
         :value="getDictLabel('sci_pjt_class', projectData?.projectClass)" />
-
-      <CommonInfoRow label="签订时间" :value="projectData?.signDate ? formatDate(projectData.signDate) : '--'" />
-      <CommonInfoRow label="院内合同编号" :value="projectData?.contractCode" />
-      <CommonInfoRow label="项目时间"
-        :value="formatDate(projectData?.startDate) + ' ~ ' + formatDate(projectData?.endDate)" />
-      <CommonInfoRow label="承接科室" :value="projectData?.deptName" />
-      <CommonInfoRow label="主要研究者" :value="projectData?.projectLeaderName" />
-      <CommonInfoRow label="主要研究者电话" :value="projectData?.projectLeaderPhone" />
+        
+      <CommonInfoRow label="合同类别" :value="getDictLabel('sci_contract_type', projectData?.contractType)" />
+      <CommonInfoRow label="项目状态" :value="getDictLabel('project_status', projectData?.projectStatus)" />
+      <CommonInfoRow label="签订日期" :value="projectData?.signDate ? formatDate(projectData.signDate) : '--'" />
+      <CommonInfoRow label="统计年度" :value="projectData?.statisticalYear" />
+      
+      <CommonInfoRow label="开始日期" :value="projectData?.planStartDate ? formatDate(projectData.planStartDate) : '--'" />
+      <CommonInfoRow label="结束日期" :value="projectData?.planEndDate ? formatDate(projectData.planEndDate) : '--'" />
+      
+      <CommonInfoRow label="承接部门" :value="projectData?.deptName" />
+      <CommonInfoRow label="负责人" :value="projectData?.projectLeaderName" />
+      <CommonInfoRow label="负责人电话" :value="projectData?.projectLeaderPhone" />
 
       <CommonInfoRow label="合同经费(元)" :value="amountUnitFormatter(projectData?.contractFunds)" isAmount />
-      <CommonInfoRow label="批准经费(元)" :value="amountUnitFormatter(projectData?.approvedFunds)" isAmount />
-      <CommonInfoRow label="匹配经费(元)" :value="amountUnitFormatter(projectData?.supportFunds)" isAmount />
-      <CommonInfoRow label="自筹经费(元)" :value="amountUnitFormatter(projectData?.selfFunds)" isAmount />
-      <CommonInfoRow label="总金额(元)"
-        :value="amountUnitFormatter((Number(projectData?.contractFunds || 0) + Number(projectData?.supportFunds || 0) + Number(projectData?.selfFunds || 0)))"
-        isAmount />
-      <CommonInfoRow label="承担单位排名"
-        :value="projectData?.unitRank == '10' ? '第一单位' : (projectData?.unitRank == '20' ? '非第一单位' : '--')" />
-      <CommonInfoRow v-if="['10', '1010', '1020'].includes(projectData?.isClinicalTrial)" label="招募人数"
-        :value="projectData?.memberNum || '0'" noBorder />
+      <CommonInfoRow label="配套经费(元)" :value="amountUnitFormatter(projectData?.supportFunds)" isAmount />
+      <CommonInfoRow label="总金额(元)" :value="amountUnitFormatter(projectData?.totalAmount)" isAmount />
+      
+      <CommonInfoRow label="到账金额(元)" :value="amountUnitFormatter(projectData?.arrivalFunds)" isAmount />
+      <CommonInfoRow label="到账日期" :value="projectData?.arrivalDate ? formatDate(projectData.arrivalDate) : '--'" />
+
+      <!-- 所属平台 -->
+      <CommonInfoRow label="所属平台">
+        <view class="platform-tags" v-if="platformList.length > 0">
+          <view v-for="(item, index) in platformList" :key="index" class="platform-tag">
+            {{ item.platformName }}
+          </view>
+        </view>
+        <text v-else>-</text>
+      </CommonInfoRow>
+
+      <CommonInfoRow label="所属团队" :value="projectData?.belongTeam" noBorder />
     </CommonSection>
   </view>
 </template>
 
 <script setup lang="ts">
+import { computed } from 'vue';
 import { useDict } from '@/hooks/useDict';
 import { formatDate } from '@/utils/date';
 import { formatWithComma } from '@/utils/format';
@@ -45,7 +56,28 @@ const props = defineProps<{
   projectData: any;
 }>();
 
-const { getDictLabel } = useDict('sci_tripartite_type', 'sci_pjt_class', 'sci_proj_type', 'compType', 'project_company_type');
+const { getDictLabel } = useDict(
+  'project_class', 
+  'sci_leader_type', 
+  'sci_pjt_class', 
+  'sci_contract_type', 
+  'project_status'
+);
+
+/**
+ * 计算属性:所属平台解析
+ */
+const platformList = computed(() => {
+  if (props.projectData?.belongPlatform) {
+    try {
+      const data = JSON.parse(props.projectData.belongPlatform);
+      return Array.isArray(data) ? data : [];
+    } catch (e) {
+      return [];
+    }
+  }
+  return [];
+});
 
 const amountUnitFormatter = (num: any) => {
   return formatWithComma(num);
@@ -58,6 +90,22 @@ const amountUnitFormatter = (num: any) => {
   padding-bottom: 20px;
 }
 
+.platform-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+  justify-content: flex-end;
+}
+
+.platform-tag {
+  font-size: 24rpx;
+  background-color: #e6f7ff;
+  color: #1a9cff;
+  padding: 4rpx 16rpx;
+  border-radius: 6rpx;
+  border: 1px solid #bae7ff;
+}
+
 .party-card {
   background-color: #f7f9fc;
   border-radius: 12rpx;

+ 67 - 0
pages/project/components/detail/ProjectSetupSafety.vue

@@ -0,0 +1,67 @@
+<template>
+  <view class="setup-wrapper">
+    <CommonSection title="立项概览" :isFirst="true">
+      <CommonInfoRow label="项目编号" :value="projectData?.projectCode" />
+      <CommonInfoRow label="项目名称" :value="projectData?.projectName" />
+      <CommonInfoRow label="项目学科分类" :value="getDictLabel('project_class', projectData?.disciplineFirstName)" />
+      <CommonInfoRow label="负责人类型" :value="getDictLabel('sci_leader_type', projectData?.projectLeaderType)" />
+      <CommonInfoRow v-if="projectData?.isClinicalTrial === '20'" label="项目类别" :value="getDictLabel('sci_pjt_class', projectData?.projectClass)" />
+      <CommonInfoRow label="合同类别" :value="getDictLabel('contract_type', projectData?.contractType)" />
+      <CommonInfoRow label="项目状态" :value="getDictLabel('project_status', projectData?.projectStatus)" />
+      <CommonInfoRow label="签订日期" :value="formatDate(projectData?.signDate)" />
+      <CommonInfoRow label="统计年度" :value="projectData?.statisticalYear" />
+      <CommonInfoRow label="开始日期" :value="formatDate(projectData?.planStartDate)" />
+      <CommonInfoRow label="结束日期" :value="formatDate(projectData?.planEndDate)" />
+      <CommonInfoRow label="承接部门" :value="projectData?.deptName" />
+      <CommonInfoRow label="负责人" :value="projectData?.projectLeaderName" />
+      <CommonInfoRow label="负责人电话" :value="projectData?.projectLeaderPhone" />
+      <CommonInfoRow label="合同经费(元)" :value="formatWithComma(projectData?.contractFunds)" isAmount />
+      <CommonInfoRow label="配套经费(元)" :value="formatWithComma(projectData?.supportFunds)" isAmount />
+      <CommonInfoRow label="总金额(元)" :value="formatWithComma((Number(projectData?.contractFunds || 0) + Number(projectData?.supportFunds || 0) + Number(projectData?.selfFunds || 0)))" isAmount />
+      <CommonInfoRow label="到账金额(元)" :value="formatWithComma(projectData?.arrivalFunds)" isAmount />
+      <CommonInfoRow label="到账日期" :value="formatDate(projectData?.arrivalDate)" />
+      <CommonInfoRow label="所属平台" :value="platformDisplay" />
+      <CommonInfoRow label="所属团队" :value="projectData?.belongTeam" :noBorder="true" />
+    </CommonSection>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useDict } from '@/hooks/useDict';
+import { formatWithComma } from '@/utils/format';
+import { formatDate } from '@/utils/date';
+import CommonSection from '@/components/ui/CommonSection.vue';
+import CommonInfoRow from '@/components/ui/CommonInfoRow.vue';
+
+const { getDictLabel } = useDict('project_class', 'sci_leader_type', 'sci_pjt_class', 'contract_type', 'project_status');
+
+/**
+ * 安评项目 立项信息展示组件
+ */
+const props = defineProps<{
+  /** 项目主数据详情 */
+  projectData: any;
+}>();
+
+/**
+ * 平台信息显示转换
+ */
+const platformDisplay = computed(() => {
+  if (props.projectData?.belongPlatform) {
+    try {
+      const data = JSON.parse(props.projectData.belongPlatform);
+      if (Array.isArray(data)) {
+        return data.map((item: any) => `${item.platformName} (${item.platformType})`).join(', ');
+      }
+    } catch (e) {}
+  }
+  return '--';
+});
+</script>
+
+<style lang="scss" scoped>
+.setup-wrapper {
+  padding-bottom: 30rpx;
+}
+</style>

+ 26 - 25
pages/project/components/detail/ProjectSetupSpontaneity.vue

@@ -3,42 +3,35 @@
     <CommonSection title="立项概览" :isFirst="true">
       <CommonInfoRow label="项目名称" :value="projectData?.projectName" />
       <CommonInfoRow label="项目编号" :value="projectData?.projectCode" />
+      <CommonInfoRow label="项目学科分类" :value="getDictLabel('project_class', projectData?.disciplineFirstName)" />
+      <CommonInfoRow label="成果归属单位" :value="projectData?.deptName" />
       <CommonInfoRow label="项目分类" :value="projectData?.projectClazzName" />
-      <CommonInfoRow label="项目来源" :value="projectData?.projectSource" />
-      <CommonInfoRow label="项目日期"
-        :value="formatDate(projectData?.startDate) + ' ~ ' + formatDate(projectData?.endDate)" />
-      <CommonInfoRow label="所属科室" :value="projectData?.deptName" />
-      <CommonInfoRow label="统计年度" :value="projectData?.statisticalYear" />
+      <CommonInfoRow label="立项日期" :value="formatDate(projectData?.approvalDate)" />
+      <CommonInfoRow label="计划开始日期" :value="formatDate(projectData?.planStartDate)" />
+      <CommonInfoRow label="计划结项日期" :value="formatDate(projectData?.planEndDate)" />
+      <CommonInfoRow label="实际完成日期" :value="formatDate(projectData?.actualEndDate)" />
+      <CommonInfoRow label="批准经费(元)" :value="formatWithComma(projectData?.approvedFunds)" isAmount />
+      <CommonInfoRow label="所属年度" :value="projectData?.statisticalYear" />
       <CommonInfoRow label="项目负责人" :value="projectData?.projectLeaderName" />
       <CommonInfoRow label="项目负责人类型" :value="getDictLabel('sci_leader_type', projectData?.projectLeaderType)" />
-      <CommonInfoRow label="负责人电话" :value="projectData?.projectLeaderPhone" />
-      <CommonInfoRow label="负责人邮箱" :value="projectData?.projectLeaderMail" />
-
-      <CommonInfoRow label="合同经费(元)" :value="amountUnitFormatter(projectData?.contractFunds)" isAmount />
-      <CommonInfoRow label="批准经费(元)" :value="amountUnitFormatter(projectData?.approvedFunds)" isAmount />
-      <CommonInfoRow label="匹配经费(元)" :value="amountUnitFormatter(projectData?.supportFunds)" isAmount />
-      <CommonInfoRow label="自筹经费(元)" :value="amountUnitFormatter(projectData?.selfFunds)" isAmount />
-      <CommonInfoRow label="总金额(元)"
-        :value="amountUnitFormatter((Number(projectData?.contractFunds || 0) + Number(projectData?.supportFunds || 0) + Number(projectData?.selfFunds || 0)))"
-        isAmount />
-      <CommonInfoRow label="承担单位排名"
-        :value="projectData?.unitRank == '10' ? '第一单位' : (projectData?.unitRank == '20' ? '非第一单位' : '--')" />
-
-      <CommonInfoRow label="研究类别" :value="projectData?.researchCategory" />
-      <CommonInfoRow label="预期成果" :value="projectData?.expectedResults" />
+      <CommonInfoRow label="所属平台" :value="platformDisplay" />
+      <CommonInfoRow label="所属团队" :value="projectData?.belongTeam" />
+      <CommonInfoRow label="成果形式" :value="projectData?.outcome" />
+      <CommonInfoRow label="财务账户" :value="projectData?.financeAccount" />
       <CommonInfoRow label="备注" :value="projectData?.remark" :noBorder="true" />
     </CommonSection>
   </view>
 </template>
 
 <script setup lang="ts">
+import { computed } from 'vue';
 import { useDict } from '@/hooks/useDict';
 import { formatWithComma } from '@/utils/format';
 import { formatDate } from '@/utils/date';
 import CommonSection from '@/components/ui/CommonSection.vue';
 import CommonInfoRow from '@/components/ui/CommonInfoRow.vue';
 
-const { getDictLabel } = useDict('sci_leader_type');
+const { getDictLabel } = useDict('sci_leader_type', 'project_class');
 
 /**
  * 自发类项目 立项信息展示组件
@@ -49,11 +42,19 @@ const props = defineProps<{
 }>();
 
 /**
- * 金额格式化处理
+ * 平台信息显示转换
  */
-const amountUnitFormatter = (num: any) => {
-  return formatWithComma(num);
-};
+const platformDisplay = computed(() => {
+  if (props.projectData?.belongPlatform) {
+    try {
+      const data = JSON.parse(props.projectData.belongPlatform);
+      if (Array.isArray(data)) {
+        return data.map((item: any) => `${item.platformName} (${item.platformType})`).join(', ');
+      }
+    } catch (e) {}
+  }
+  return '--';
+});
 </script>
 
 <style lang="scss" scoped>

+ 32 - 23
pages/project/components/detail/ProjectSetupVertical.vue

@@ -2,36 +2,31 @@
   <view class="setup-wrapper">
     <CommonSection title="立项概览" isFirst>
       <CommonInfoRow label="项目名称" :value="projectData?.projectName" />
-      <CommonInfoRow label="项目编号" :value="projectData?.projectCode" />
-      <CommonInfoRow label="项目分类" :value="projectData?.projectClazzName" />
+      <CommonInfoRow label="学科类型" :value="getDictLabel('project_class', projectData?.projectClass)" />
+      <CommonInfoRow label="项目来源单位" :value="projectData?.projectSource" />
+      <CommonInfoRow label="负责人" :value="projectData?.projectLeaderName" />
+      <CommonInfoRow label="负责人类型" :value="getDictLabel('sci_leader_type', projectData?.projectLeaderType)" />
       <CommonInfoRow label="项目级别" :value="getDictLabel('sci_pjt_level', projectData?.projectLevel)" />
-      <CommonInfoRow label="项目来源" :value="projectData?.projectSource" />
-      <CommonInfoRow label="项目执行期"
-        :value="formatDate(projectData?.planStartDate) + ' ~ ' + formatDate(projectData?.planEndDate)" />
-      <CommonInfoRow label="研究类型" :value="getDictLabel('sci_pjt_type', projectData?.studyType)" />
-      <CommonInfoRow label="统计年度" :value="formatDate(projectData?.statisticalYear, 'YYYY')" />
-      <CommonInfoRow label="项目负责人/合作完成人" :value="projectData?.projectLeaderName" />
-      <CommonInfoRow label="所属科室" :value="projectData?.deptName" />
-      <CommonInfoRow label="负责人电话" :value="projectData?.projectLeaderPhone" />
-      <CommonInfoRow label="负责人邮箱" :value="projectData?.projectLeaderMail" />
-      <CommonInfoRow label="是否中医药"
-        :value="projectData?.isMedicine == '10' ? '是' : (projectData?.isMedicine == '20' ? '否' : '--')" />
-      <CommonInfoRow label="包干制项目"
-        :value="projectData?.isLumpSum == '10' ? '是' : (projectData?.isLumpSum == '20' ? '否' : '--')" />
-      <CommonInfoRow label="获批时间" :value="projectData?.approvalDate ? formatDate(projectData.approvalDate) : '--'" />
-      <CommonInfoRow label="获批编号" :value="projectData?.projectNo" />
+      <CommonInfoRow label="项目分类" :value="projectData?.projectClazzName" />
+      <CommonInfoRow label="立项日期" :value="projectData?.approvalDate ? formatDate(projectData.approvalDate) : '--'" />
+      <CommonInfoRow label="开始日期" :value="projectData?.planStartDate ? formatDate(projectData.planStartDate) : '--'" />
+      <CommonInfoRow label="计划结题日期" :value="projectData?.planEndDate ? formatDate(projectData.planEndDate) : '--'" />
       <CommonInfoRow label="合同经费(元)" :value="amountUnitFormatter(projectData?.contractFunds)" isAmount />
       <CommonInfoRow label="批准经费(元)" :value="amountUnitFormatter(projectData?.approvedFunds)" isAmount />
-      <CommonInfoRow label="匹配经费(元)" :value="amountUnitFormatter(projectData?.supportFunds)" isAmount />
-      <CommonInfoRow label="自筹经费(元)" :value="amountUnitFormatter(projectData?.selfFunds)" isAmount />
-      <CommonInfoRow label="总经费(元)" :value="amountUnitFormatter(projectData?.totalAmount)" isAmount />
-      <CommonInfoRow label="单位排名"
-        :value="projectData?.unitRank == '10' ? '第一单位' : (projectData?.unitRank == '20' ? '非第一单位' : '--')" noBorder />
+      <CommonInfoRow label="配套经费(元)" :value="amountUnitFormatter(projectData?.supportFunds)" isAmount />
+      <CommonInfoRow label="外拨经费(元)" :value="amountUnitFormatter(projectData?.outsourcedFunds)" isAmount />
+      <CommonInfoRow label="所属年度" :value="projectData?.statisticalYear || '--'" />
+      <CommonInfoRow label="所属平台" :value="platformNames || '--'" />
+      <CommonInfoRow label="所属团队" :value="projectData?.belongTeam || '--'" />
+      <CommonInfoRow label="到账日期" :value="projectData?.arrivalDate ? formatDate(projectData.arrivalDate) : '--'" />
+      <CommonInfoRow label="到账金额(元)" :value="projectData?.arrivalFunds || '--'" />
+      <CommonInfoRow label="备注" :value="projectData?.remark || '--'" noBorder />
     </CommonSection>
   </view>
 </template>
 
 <script setup lang="ts">
+import { computed } from 'vue';
 import { useDict } from '@/hooks/useDict';
 import { formatDate } from '@/utils/date';
 import { formatWithComma } from '@/utils/format';
@@ -42,7 +37,21 @@ const props = defineProps<{
   projectData: any;
 }>();
 
-const { getDictLabel } = useDict('sci_pjt_type', 'sci_pjt_level');
+const { getDictLabel } = useDict('sci_pjt_level', 'project_class', 'sci_leader_type');
+
+const platformNames = computed(() => {
+  if (props.projectData?.belongPlatform) {
+    try {
+      const data = JSON.parse(props.projectData.belongPlatform)
+      if (Array.isArray(data)) {
+        return data.map(item => `${item.platformName} (${item.platformType})`).join(', ')
+      }
+    } catch (e) {
+      return ''
+    }
+  }
+  return ''
+})
 
 const amountUnitFormatter = (num: any) => {
   return formatWithComma(num);

+ 11 - 14
pages/project/detail.vue

@@ -3,7 +3,8 @@
     <view class="header-card">
       <view class="title">{{ projectStore.projectDetailData.projectName || '未知名称' }}</view>
       <view class="meta-row">
-        <view class="leader" v-if="projectStore.projectDetailData.projectLeaderName || projectStore.projectDetailData.manager">
+        <view class="leader"
+          v-if="projectStore.projectDetailData.projectLeaderName || projectStore.projectDetailData.manager">
           项目负责人:{{ projectStore.projectDetailData.projectLeaderName || projectStore.projectDetailData.manager }}
         </view>
         <view class="tags">
@@ -19,12 +20,8 @@
 
     <scroll-view class="content-area" scroll-y>
       <view class="component-wrapper">
-        <component 
-          :is="currentComponentName" 
-          :projectId="projectId" 
-          :projectType="projectType" 
-          :projectData="projectStore.projectDetailData"
-        ></component>
+        <component :is="currentComponentName" :projectId="projectId" :projectType="projectType"
+          :projectData="projectStore.projectDetailData"></component>
       </view>
     </scroll-view>
   </view>
@@ -86,18 +83,18 @@ const tabList = computed(() => {
     { name: '立项信息', component: 'ProjectSetup' }
   ];
 
-  if (projectType.value !== 'horizontal') {
+  if (projectType.value !== 'horizontal' && projectType.value !== 'safety') {
     tabs.push({ name: '项目预算', component: 'ProjectBudget' });
   }
-
+  
+  tabs.push({ name: '附件信息', component: 'ProjectDocs' });
   tabs.push({ name: '项目成员', component: 'ProjectMembers' });
 
-  if (projectType.value === 'horizontal') {
-    tabs.push({ name: '合作单位', component: 'ProjectCooperates' });
+  if (projectType.value !== 'spontaneity') {
+    tabs.push({ name: '多方信息', component: 'ProjectCooperates' });
   }
-  
+
   tabs.push(
-    { name: '附件信息', component: 'ProjectDocs' },
     { name: '科研成果', component: 'ProjectAchievements' },
     { name: '经费信息', component: 'ProjectFunding' },
     { name: '中检信息', component: 'ProjectInspection' },
@@ -106,7 +103,7 @@ const tabList = computed(() => {
     { name: '审批信息', component: 'ProjectApproval' },
     { name: '伦理信息', component: 'ProjectEthical' }
   );
-  
+
   return tabs;
 });
 

+ 22 - 6
pages/project/index.vue

@@ -37,11 +37,12 @@
       </view>
     </view>
 
-    <!-- 列表区域 -->
+      <!-- 列表区域 -->
     <view class="list-container">
       <VerticalProject v-if="currentTab === 0" :queryParams="queryParams" @goDetail="goDetail"></VerticalProject>
       <HorizontalProject v-if="currentTab === 1" :queryParams="queryParams" @goDetail="goDetail"></HorizontalProject>
       <SpontaneityProject v-if="currentTab === 2" :queryParams="queryParams" @goDetail="goDetail"></SpontaneityProject>
+      <SafetyProject v-if="currentTab === 3" :queryParams="queryParams" @goDetail="goDetail"></SafetyProject>
     </view>
 
     <!-- 状态拾取器 -->
@@ -56,7 +57,13 @@ import { onLoad } from '@dcloudio/uni-app';
 import VerticalProject from './components/VerticalProject.vue';
 import HorizontalProject from './components/HorizontalProject.vue';
 import SpontaneityProject from './components/SpontaneityProject.vue';
-import { projectStatusOptions } from '@/constants/index';
+import SafetyProject from './components/SafetyProject.vue';
+import { 
+  verticalProjectStatusOptions, 
+  horizontalProjectStatusOptions, 
+  spontaneityProjectStatusOptions,
+  safetyProjectStatusOptions
+} from '@/constants/index';
 
 // 查询条件
 const queryParams = ref({
@@ -66,18 +73,26 @@ const queryParams = ref({
 });
 
 const currentTab = ref(0);
-const tabList = ref([{ name: '纵向项目' }, { name: '横向项目' }, { name: '校内项目' }]);
+const tabList = ref([{ name: '纵向项目' }, { name: '横向项目' }, { name: '校内项目' }, { name: '安评项目' }]);
+
+const currentStatusOptions = computed(() => {
+  if (currentTab.value === 0) return verticalProjectStatusOptions;
+  if (currentTab.value === 1) return horizontalProjectStatusOptions;
+  if (currentTab.value === 2) return spontaneityProjectStatusOptions;
+  if (currentTab.value === 3) return safetyProjectStatusOptions;
+  return [];
+});
 
 const currentStatusLabel = computed(() => {
-  const item = projectStatusOptions.find(s => s.dictValue === queryParams.value.projectStatus);
+  const item = currentStatusOptions.value.find(s => s.dictValue === queryParams.value.projectStatus);
   return item && item.dictValue !== '' ? item.dictLabel : '项目状态';
 });
 
-const statusColumns = computed(() => [projectStatusOptions]);
+const statusColumns = computed(() => [currentStatusOptions.value]);
 const statusPicker = ref<any>(null);
 
 const openPicker = () => {
-  const idx = projectStatusOptions.findIndex(s => s.dictValue === queryParams.value.projectStatus);
+  const idx = currentStatusOptions.value.findIndex(s => s.dictValue === queryParams.value.projectStatus);
   if (idx !== -1 && statusPicker.value?.setIndexs) {
     statusPicker.value.setIndexs([idx]);
   }
@@ -90,6 +105,7 @@ const onStatusConfirm = (e: any) => {
 
 const onTabChange = (e: any) => {
   currentTab.value = e.index;
+  queryParams.value.projectStatus = ''; // 切换 Tab 时重置状态筛选
 };
 
 const goDetail = (item: any) => {

+ 28 - 0
pages/todo/components/DocumentInfoDisplay.vue

@@ -116,6 +116,33 @@
       <!-- 软件著作权 -->
       <SciAchievementSoftware v-else-if="defCode === 'sci_achievement_software'" :code="taskCode" />
 
+      <!-- 安评项目 -->
+      <SafetyForm v-else-if="defCode === 'sci_project_safety'" :code="taskCode" />
+      
+      <!-- 横向项目招标 -->
+      <view v-else-if="defCode === 'sci_project_horizontal_bid'" class="section-card fallback-card">
+        <view class="section-title">横向项目招标 ({{ defCode }})</view>
+        <view class="info-row"><text class="label">业务编码</text><text class="value">{{ taskCode }}</text></view>
+      </view>
+
+      <!-- 成果评价/决策 -->
+      <view v-else-if="defCode === 'sci_achievement_evaluation' || defCode === 'sci_achievement_decision'" class="section-card fallback-card">
+        <view class="section-title">成果管理 ({{ defCode }})</view>
+        <view class="info-row"><text class="label">业务编码</text><text class="value">{{ taskCode }}</text></view>
+      </view>
+
+      <!-- 经费下拨 -->
+      <view v-else-if="defCode === 'sci_fund_external_allot'" class="section-card fallback-card">
+        <view class="section-title">经费外部下拨 ({{ defCode }})</view>
+        <view class="info-row"><text class="label">业务编码</text><text class="value">{{ taskCode }}</text></view>
+      </view>
+
+      <!-- 成果转化 -->
+      <view v-else-if="defCode === 'sci_achievement_conversion'" class="section-card fallback-card">
+        <view class="section-title">成果转化 ({{ defCode }})</view>
+        <view class="info-row"><text class="label">业务编码</text><text class="value">{{ taskCode }}</text></view>
+      </view>
+
       <!-- 保底展示 -->
       <view v-else class="section-card fallback-card">
         <view class="section-title">单据详情 ({{ defCode }})</view>
@@ -167,6 +194,7 @@ import SciAchievementPaperPushClaim from './document/SciAchievementPaperPushClai
 import SciAchievementOther from './document/SciAchievementOther.vue';
 import SciAchievementSoftware from './document/SciAchievementSoftware.vue';
 import DeclarationListForm from './document/DeclarationListForm.vue';
+import SafetyForm from './document/SafetyForm.vue';
 
 const props = defineProps<{
   defCode: string; // 对应 PC 端的 state.taskDetail.defCode

+ 303 - 0
pages/todo/components/document/SafetyForm.vue

@@ -0,0 +1,303 @@
+<template>
+  <view class="document-form">
+    <uv-loading-icon v-if="loading" mode="circle" text="正在加载安评项目详情..."></uv-loading-icon>
+    <template v-else-if="form">
+      <!-- 基本信息 -->
+      <view class="common-section-card">
+        <view class="section-title">基本信息</view>
+        <view class="info-row"><text class="label">项目编号</text><text class="value">{{ form.projectCode || '-' }}</text></view>
+        <view class="info-row"><text class="label">项目名称</text><text class="value">{{ form.projectName || '-' }}</text></view>
+        <view class="info-row">
+          <text class="label">学科分类</text>
+          <text class="value">{{ getDictLabel('project_class', form.disciplineFirstName) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="label">负责人类型</text>
+          <text class="value">{{ form.projectLeaderType === '10' ? '教师' : form.projectLeaderType === '20' ? '学生' : '校外人员' }}</text>
+        </view>
+        <view class="info-row"><text class="label">负责人</text><text class="value">{{ form.projectLeaderName || '-' }}</text></view>
+        <view class="info-row">
+          <text class="label">合同类别</text>
+          <text class="value">{{ getDictLabel('contract_type', form.contractType) }}</text>
+        </view>
+        <view class="info-row"><text class="label">统计年度</text><text class="value">{{ form.statisticalYear || '-' }}</text></view>
+        <view class="info-row"><text class="label">签订时间</text><text class="value">{{ form.signDate || '-' }}</text></view>
+        <view class="info-row"><text class="label">项目时间</text><text class="value">{{ form.planStartDate || '-' }} 至 {{ form.planEndDate || '-' }}</text></view>
+        
+        <view class="info-row">
+          <text class="label">合同经费(元)</text>
+          <text class="value num-color">¥{{ formatAmount(form.contractFunds) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="label">配套经费(元)</text>
+          <text class="value num-color">¥{{ formatAmount(form.supportFunds) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="label">到账金额(元)</text>
+          <text class="value primary-color">¥{{ formatAmount(form.arrivalFunds) }}</text>
+        </view>
+        <view class="info-row"><text class="label">到账时间</text><text class="value">{{ form.arrivalDate || '-' }}</text></view>
+        
+        <view class="info-row">
+          <text class="label">所属平台</text>
+          <view class="platform-tags">
+            <template v-if="platformList.length > 0">
+              <view class="p-tag" v-for="(item, index) in platformList" :key="index">
+                {{ item.platformName }} ({{ item.platformType }})
+              </view>
+            </template>
+            <text v-else>-</text>
+          </view>
+        </view>
+        <view class="info-row"><text class="label">所属团队</text><text class="value">{{ form.belongTeam || '-' }}</text></view>
+        <view class="info-row column">
+          <text class="label mb10">备注</text>
+          <text class="value remark">{{ form.remark || '无' }}</text>
+        </view>
+      </view>
+
+      <!-- 合作单位 -->
+      <view class="common-section-card mt20" v-if="form.companyList?.length">
+        <view class="section-title">合作单位</view>
+        <view class="company-list">
+          <view class="company-item" v-for="(comp, index) in form.companyList" :key="index">
+            <view class="comp-header">
+              <text class="c-name">{{ comp.compName }}</text>
+              <text class="c-tag">{{ getDictLabel('project_company_type', comp.contractParty) }}</text>
+            </view>
+            <view class="c-body">
+              <view class="c-line"><text class="l">单位级别:</text><text class="v">{{ getDictLabel('sci_tripartite_type', comp.compType) }}</text></view>
+              <view class="c-line"><text class="l">联系人:</text><text class="v">{{ comp.compContact || '-' }} ({{ comp.compPhoneNum || '-' }})</text></view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 成员信息 -->
+      <view class="common-section-card mt20" v-if="form.member?.length">
+        <view class="section-title">成员信息</view>
+        <view class="member-list">
+          <view class="member-item" v-for="(row, index) in form.member" :key="index">
+            <view class="member-header">
+              <text class="m-name">{{ row.memberName }}</text>
+              <text class="m-tag leader" v-if="row.projectRole === '10'">负责人</text>
+              <text class="m-tag参与人 v-else">参与人</text>
+              <text class="m-rate" v-if="row.contributionRate">贡献率: {{ row.contributionRate }}%</text>
+            </view>
+            <view class="m-body">
+              <view class="m-line" v-if="row.deptName"><text class="l">所属科室:</text><text class="v">{{ row.deptName }}</text></view>
+              <view class="m-line" v-if="row.technicalTitle"><text class="l">职称:</text><text class="v">{{ row.technicalTitle }}</text></view>
+              <view class="m-line" v-if="row.degree"><text class="l">学位:</text><text class="v">{{ getDictLabel('sci_academic_degree', row.degree) }}</text></view>
+              <view class="m-line" v-if="row.responsibleContent"><text class="l">负责内容:</text><text class="v">{{ row.responsibleContent }}</text></view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 预算信息 -->
+      <view class="common-section-card mt20" v-if="form.budget?.length">
+        <view class="section-title">预算信息</view>
+        <view class="budget-list">
+          <view class="budget-item" v-for="(item, index) in form.budget" :key="index">
+            <view class="b-header">
+              <text class="b-name">{{ item.fundsSubjName }}</text>
+              <text class="b-tag">{{ item.fundsClass === '10' ? '直接费用' : '间接费用/管理费' }}</text>
+            </view>
+            <view class="b-grid">
+              <view class="g-item"><text class="l">财政拨款</text><text class="v">¥{{ item.projectFundsAmount || 0 }}</text></view>
+              <view class="g-item"><text class="l">匹配经费</text><text class="v">¥{{ item.otherFundsAmount || 0 }}</text></view>
+              <view class="g-item"><text class="l">自筹经费</text><text class="v">¥{{ item.raiseFundsAmount || 0 }}</text></view>
+              <view class="g-item total"><text class="l">预算金额</text><text class="v">¥{{ item.totalFundsAmount || 0 }}</text></view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 附件信息 -->
+      <view class="common-section-card mt20" v-if="form.files?.length">
+        <view class="section-title">附件资料</view>
+        <view class="attachment-list">
+          <view class="file-item" v-for="(file, index) in form.files" :key="index" @click="previewFile(file.fileUrl, file.fileName)">
+            <view class="file-info-box">
+              <text class="f-name">{{ file.fileName }}</text>
+              <text class="f-type">{{ file.fileType || '附件' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </template>
+    <uv-empty v-else mode="data" text="暂无数据"></uv-empty>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, watch, computed } from 'vue';
+import { useDict } from '@/hooks/useDict';
+import { useDocumentApi } from '@/api/document';
+import { previewFile } from '@/utils/file';
+import { formatAmount } from '@/utils/format';
+import to from 'await-to-js';
+
+const props = defineProps<{
+  code: string;
+}>();
+
+const { getDictLabel } = useDict('project_class', 'contract_type', 'project_company_type', 'sci_tripartite_type', 'sci_academic_degree');
+const documentApi = useDocumentApi();
+
+const form = ref<any>(null);
+const loading = ref(false);
+
+const fetchData = async () => {
+  if (!props.code) return;
+  loading.value = true;
+  const [err, res] = await to(documentApi.getSafetyByCode(props.code));
+  if (!err && res?.data) {
+    form.value = res.data;
+  }
+  loading.value = false;
+};
+
+const platformList = computed(() => {
+  if (form.value?.belongPlatform) {
+    try {
+      const data = JSON.parse(form.value.belongPlatform);
+      return Array.isArray(data) ? data : [];
+    } catch (e) {
+      return [];
+    }
+  }
+  return [];
+});
+
+onMounted(() => {
+  fetchData();
+});
+
+watch(() => props.code, () => {
+  fetchData();
+});
+</script>
+
+<style lang="scss" scoped>
+@import "./common.scss";
+
+.platform-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10rpx;
+  justify-content: flex-end;
+  flex: 1;
+  .p-tag {
+    font-size: 22rpx;
+    background-color: #f0f7ff;
+    color: #007aff;
+    padding: 4rpx 12rpx;
+    border-radius: 4rpx;
+  }
+}
+
+.num-color {
+  color: #666;
+  font-weight: 500;
+}
+
+.company-item, .budget-item {
+  background-color: #fcfdfe;
+  border: 1rpx solid #eff3f8;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  
+  .comp-header, .b-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 16rpx;
+    padding-bottom: 12rpx;
+    border-bottom: 1rpx solid #f1f5f9;
+    
+    .c-name, .b-name {
+      font-size: 28rpx;
+      font-weight: 600;
+      color: #333;
+    }
+    
+    .c-tag, .b-tag {
+      font-size: 20rpx;
+      padding: 2rpx 12rpx;
+      border-radius: 4rpx;
+      background-color: #f8fafc;
+      color: #64748b;
+    }
+  }
+  
+  .c-body {
+    .c-line {
+      display: flex;
+      font-size: 24rpx;
+      margin-bottom: 8rpx;
+      .l { color: #94a3b8; width: 140rpx; }
+      .v { color: #475569; }
+    }
+  }
+  
+  .b-grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 12rpx;
+    
+    .g-item {
+      display: flex;
+      flex-direction: column;
+      background-color: #f8fafc;
+      padding: 12rpx;
+      border-radius: 8rpx;
+      
+      .l { font-size: 20rpx; color: #94a3b8; margin-bottom: 4rpx; }
+      .v { font-size: 24rpx; color: #475569; font-weight: 500; }
+      
+      &.total {
+        background-color: #f0f7ff;
+        .v { color: #1c9bfd; font-weight: bold; }
+      }
+    }
+  }
+}
+
+.attachment-list {
+  .file-item {
+    display: flex;
+    align-items: center;
+    padding: 20rpx;
+    background-color: #f8fbfd;
+    border-radius: 12rpx;
+    border: 1rpx solid #eef2f7;
+    margin-bottom: 16rpx;
+    
+    .file-info-box {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      
+      .f-name {
+        font-size: 26rpx;
+        color: #1c9bfd;
+        word-break: break-all;
+      }
+      
+      .f-type {
+        font-size: 20rpx;
+        color: #94a3b8;
+        margin-top: 4rpx;
+      }
+    }
+  }
+}
+
+.m-rate {
+  font-size: 22rpx;
+  color: #94a3b8;
+  margin-left: auto;
+}
+</style>

+ 63 - 1
store/modules/project.ts

@@ -17,6 +17,9 @@ export const useProjectStore = defineStore('project', () => {
   const spontaneityList = ref<any[]>([]);
   const spontaneityPagination = ref({ pageNum: 1, pageSize: 25, total: 0 });
 
+  const safetyList = ref<any[]>([]);
+  const safetyPagination = ref({ pageNum: 1, pageSize: 25, total: 0 });
+
   const changeList = ref<any[]>([]);
 
   // 变更详情数据
@@ -49,7 +52,8 @@ export const useProjectStore = defineStore('project', () => {
   const loadings = {
     fetchVerticalLoading: ref<boolean>(false),
     fetchHorizontalLoading: ref<boolean>(false),
-    fetchSpontaneityLoading: ref<boolean>(false),
+     fetchSpontaneityLoading: ref<boolean>(false),
+    fetchSafetyLoading: ref<boolean>(false),
     fetchChangeListLoading: ref<boolean>(false),
     fetchChangeDetailLoading: ref<boolean>(false),
     fetchInspListLoading: ref<boolean>(false),
@@ -207,6 +211,51 @@ export const useProjectStore = defineStore('project', () => {
     return await fetchSpontaneityProjects(queryParams, false);
   }
 
+  /**
+   * 获取安评项目列表
+   */
+  async function fetchSafetyProjects(queryParams: any = {}, reset = false) {
+    if (reset) {
+      safetyPagination.value.pageNum = 1;
+      safetyList.value = [];
+    }
+    
+    setRequestLoading('fetchSafetyLoading', true);
+    
+    const params = {
+      pageNum: safetyPagination.value.pageNum,
+      pageSize: safetyPagination.value.pageSize,
+      ...queryParams
+    };
+
+    const [err, res] = await to(projectApi.getSafetyList(params)) as [any, any];
+
+    setRequestLoading('fetchSafetyLoading', false);
+
+    if (err) {
+      console.error('fetchSafetyProjects error:', err);
+      return { success: false, rows: [], total: 0 };
+    }
+
+    if (res && res.code === 200) {
+      let rows = res.data?.list || res.data?.rows || res.rows || (Array.isArray(res.data) ? res.data : []);
+      if (!Array.isArray(rows)) rows = [];
+      const total = res.data?.total || res.total || 0;
+      
+      safetyList.value = reset ? rows : [...safetyList.value, ...rows];
+      safetyPagination.value.total = total;
+      
+      return { success: true, rows, total };
+    }
+    
+    return { success: false, rows: [], total: 0 };
+  }
+
+  async function loadMoreSafetyProjects(queryParams: any = {}) {
+    safetyPagination.value.pageNum++;
+    return await fetchSafetyProjects(queryParams, false);
+  }
+
   /**
    * 获取项目变更列表
    */
@@ -217,6 +266,7 @@ export const useProjectStore = defineStore('project', () => {
     if (projectType === 'vertical') typeCode = '10';
     if (projectType === 'horizontal') typeCode = '20';
     if (projectType === 'spontaneity') typeCode = '30';
+    if (projectType === 'safety') typeCode = '70';
 
     const params = {
       projectId: projectId,
@@ -253,6 +303,7 @@ export const useProjectStore = defineStore('project', () => {
     if (projectType === 'vertical') typeCode = '10';
     if (projectType === 'horizontal') typeCode = '20';
     if (projectType === 'spontaneity') typeCode = '30';
+    if (projectType === 'safety') typeCode = '70';
 
     const params = {
       projectId: projectId,
@@ -319,6 +370,8 @@ export const useProjectStore = defineStore('project', () => {
       baseApiCall = projectApi.getHoriEntityById({ id: projectId });
     } else if (projectType === '30' || projectType === 'spontaneity') {
       baseApiCall = projectApi.getSpontaneityEntityById({ id: projectId });
+    } else if (projectType === '70' || projectType === 'safety') {
+      baseApiCall = projectApi.getSafetyEntityById({ id: projectId });
     }
     
     if (baseApiCall) {
@@ -369,6 +422,8 @@ export const useProjectStore = defineStore('project', () => {
       apiCall = projectApi.getHoriEntityById({ id });
     } else if (type === 'spontaneity' || type === '30') {
       apiCall = projectApi.getSpontaneityEntityById({ id });
+    } else if (type === 'safety' || type === '70') {
+      apiCall = projectApi.getSafetyEntityById({ id });
     }
     
     if (!apiCall) return { success: false, msg: '未知项目类型' };
@@ -391,6 +446,7 @@ export const useProjectStore = defineStore('project', () => {
     if (projectType === 'vertical') typeCode = '10';
     if (projectType === 'horizontal') typeCode = '20';
     if (projectType === 'spontaneity') typeCode = '30';
+    if (projectType === 'safety') typeCode = '70';
 
     const query = { projectCode, projectType: typeCode };
 
@@ -419,6 +475,7 @@ export const useProjectStore = defineStore('project', () => {
     if (projectType === 'vertical') typeCode = '10';
     if (projectType === 'horizontal') typeCode = '20';
     if (projectType === 'spontaneity') typeCode = '30';
+    if (projectType === 'safety') typeCode = '70';
     
     const [err, res] = await to(projectApi.getConclusionTaskList({ projectId, projectType: typeCode, pageSize: 9999 })) as [any, any];
     setRequestLoading('fetchConclusionLoading', false);
@@ -438,6 +495,7 @@ export const useProjectStore = defineStore('project', () => {
     if (projectType === 'vertical') typeCode = '10';
     if (projectType === 'horizontal') typeCode = '20';
     if (projectType === 'spontaneity') typeCode = '30';
+    if (projectType === 'safety') typeCode = '70';
 
     const params = { projectId, projectType: typeCode, pageSize: 9999 };
 
@@ -466,12 +524,16 @@ export const useProjectStore = defineStore('project', () => {
     horizontalPagination,
     spontaneityList,
     spontaneityPagination,
+    safetyList,
+    safetyPagination,
     fetchVerticalProjects,
     loadMoreVerticalProjects,
     fetchHorizontalProjects,
     loadMoreHorizontalProjects,
     fetchSpontaneityProjects,
     loadMoreSpontaneityProjects,
+    fetchSafetyProjects,
+    loadMoreSafetyProjects,
     changeList,
     changeDetailData,
     projectDetailData,

+ 26 - 0
store/modules/user.ts

@@ -127,6 +127,31 @@ export const useUserStore = defineStore('user', () => {
     return res;
   }
 
+  /**
+   * 企业微信免登
+   */
+  async function oAuthLogin(code: string) {
+    setRequestLoading('isLogining', true);
+    
+    const [err, res] = await to(loginApi.oAuthLogin({ code }));
+    
+    setRequestLoading('isLogining', false);
+    
+    if (err) {
+      return Promise.reject(err);
+    }
+    
+    const resToken = res?.data?.token;
+    if (resToken) {
+      token.value = resToken;
+      Local.set(CACHE_KEY.TOKEN, resToken);
+      // 登录成功后主动获取用户信息
+      await fetchUserInfo();
+    }
+    
+    return res;
+  }
+
   /**
    * 获取用户信息
    */
@@ -184,6 +209,7 @@ export const useUserStore = defineStore('user', () => {
     configSetting,
     login,
     dingTalkLogin,
+    oAuthLogin,
     fetchUserInfo,
     logout,
     clearUserStatus,

+ 2 - 0
types/project.ts

@@ -10,6 +10,8 @@ export interface ProjectMember {
   technicalTitle?: string; // 职称
   deptName?: string;  // 科室名称
   responsibleContent?: string; // 负责内容
+  contributionRate?: string | number; // 贡献率
+  order?: string | number;           // 签署顺序
 }
 
 /**

+ 2 - 1
utils/micro_request.js

@@ -27,7 +27,8 @@ function request(options) {
   // 头部处理
   let header = Object.assign({}, options.headers, options.header);
   header['Content-Type'] = header['Content-Type'] || 'application/json;charset=utf-8';
-  
+  header['X-Secret'] = import.meta.env.VITE_SECRET;
+
   if (import.meta.env.VITE_TENANT) {
     header['Tenant'] = import.meta.env.VITE_TENANT;
   }

+ 41 - 0
utils/wechat.ts

@@ -0,0 +1,41 @@
+/**
+ * 判断是否在微信环境
+ */
+export function isWechat() {
+  const ua = window.navigator.userAgent.toLowerCase();
+  // @ts-ignore
+  return ua.match(/MicroMessenger/i) == 'micromessenger';
+}
+
+/**
+ * 判断是否在企业微信环境
+ */
+export function isEnterpriseWechat() {
+  const ua = window.navigator.userAgent.toLowerCase();
+  // @ts-ignore
+  return isWechat() && ua.match(/wxwork/i) == 'wxwork';
+}
+
+/**
+ * 重定向到企业微信授权页面
+ * @param redirectUri 回调地址
+ */
+export function redirectToWechatAuth(redirectUri: string) {
+  const corpId = import.meta.env.VITE_WECHAT_CORPID;
+  // const agentId = import.meta.env.VITE_WECHAT_AGENTID; // 非扫码登录场景通常不需要 agentid,由后端配置决定
+  const state = 'STATE';
+  const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${corpId}&redirect_uri=${encodeURIComponent(
+    redirectUri
+  )}&response_type=code&scope=snsapi_base&state=${state}#wechat_redirect`;
+
+  window.location.href = url;
+}
+
+/**
+ * 从 URL 中获取 code
+ */
+export function getUrlCode(): string | null {
+  const url = window.location.href;
+  const match = url.match(/[?&]code=([^&#]*)/);
+  return match ? match[1] : null;
+}