Просмотр исходного кода

feature:增加钉钉免登录、相关字段按照温岭调整

liuzhenlin 1 месяц назад
Родитель
Сommit
d82e24122a

+ 8 - 6
App.vue

@@ -13,24 +13,26 @@ onLaunch(async (options) => {
   if (dd.env.platform !== 'notInDingTalk') {
     // 如果没有 token,或者用户信息为空,尝试免登
     if (!userStore.token) {
+      userStore.setRequestLoading('isLogining', true);
       try {
-        // 尝试从 URL 获取 corpId(钉钉微应用通常在打开时会自动在 URL 后带上 corpId)
-        // 也可以从环境变量 VITE_DINGTALK_CORPID 获取
         const corpId = options?.query?.corpId || import.meta.env.VITE_DINGTALK_CORPID;
-        
         const code = await getDingTalkAuthCode(corpId);
         if (code) {
           console.log('Got DingTalk AuthCode, logging in...', code);
-          alert(code)
           await userStore.dingTalkLogin(code);
           console.log('DingTalk Login Success');
         }
       } catch (err) {
         console.error('DingTalk Auto Login Failed:', err);
-        // 如果免登失败且没有 Token,通常需要引导至手动登录也或报错
-        // 这里视业务需求而定,通常免登失败我们不再强制阻塞,除非是纯免登系统
+        // 如果免登失败(通常是没找到用户),则跳转注册页面
+        uni.reLaunch({ url: '/pages/login/register' });
+      } finally {
+        userStore.setRequestLoading('isLogining', false);
       }
     }
+  } else if (!userStore.token) {
+    // 不在钉钉环境下且没登录,也引导去注册或者登录跳转(暂统一引导至注册或默认逻辑)
+    uni.reLaunch({ url: '/pages/login/register' });
   }
 });
 

+ 1 - 1
api/document/index.ts

@@ -83,7 +83,7 @@ export function useDocumentApi() {
     },
     // 获取经费卡详情
     getFundCardById: (id: string | number) => {
-      return microRequest.postRequest(sciPath, 'SciFundCard', 'GetDetails', { id: Number(id) });
+      return microRequest.postRequest(sciPath, 'SciFundCard', 'GetEntityById', { id: Number(id) });
     },
     // 获取所有一级科目
     getAllFirstSubj: () => {

+ 4 - 0
api/project/index.ts

@@ -95,6 +95,10 @@ export function useProjectApi() {
     // 伦理信息列表
     getEthicalReviewList: (query?: object) => {
       return microRequest.postRequest(sciPath, 'SciEthicalReview', 'GetList', query);
+    },
+    // 学术不端校验
+    checkUserViolationStatus: (query?: object) => {
+      return microRequest.postRequest(sciPath, 'SciAcademicMisconduct', 'CheckUserViolationStatus', query);
     }
   }
 }

+ 9 - 0
api/system/index.ts

@@ -36,4 +36,13 @@ export function useSystemApi() {
 
   }
 
+}
+
+export function useDeptApi() {
+  return {
+    // 部门树
+    getDeptTree(query?: object) {
+      return microRequest.postRequest(basePath, 'Dept', 'GetDeptTree', query)
+    }
+  }
 }

+ 16 - 1
api/system/login.ts

@@ -28,9 +28,24 @@ export function useLoginApi() {
 
     // 钉钉免登接口
     dingTalkLogin: (data: { code: string }) => {
-      return microRequest.postRequest(basePath, 'System', 'DingTalkLogin', data);
+      return microRequest.postRequest(basePath, 'DingDingApi', 'GetUserInfoByCode', data);
     },
 
+    register: (query?: object) => {
+      return microRequest.postRequest(basePath, 'Personnel', 'RegisterPersonnel', query);
+    },
+    checkUserNamePhoneExists: (query?: object) => {
+      return microRequest.postRequest(basePath, 'Personnel', 'CheckUserNamePhoneExists', query);
+    },
+    validatePassword: (query?: object) => {
+      return microRequest.postRequest(basePath, 'Personnel', 'ValidatePassword', query);
+    },
+    generatePassword: () => {
+      return microRequest.postRequest(basePath, 'Personnel', 'GeneratePassword');
+    },
+
+
+
 
 
   }

+ 337 - 0
components/SelectDept/index.vue

@@ -0,0 +1,337 @@
+<template>
+  <view>
+    <uv-popup ref="popupRef" mode="bottom" round="24rpx" @change="onPopupChange">
+      <view class="dept-select-container">
+        <!-- Header -->
+        <view class="header">
+          <view class="title">选择组织部门</view>
+          <uv-icon name="close" color="#94a3b8" size="20" @click="close"></uv-icon>
+        </view>
+
+        <!-- Breadcrumbs (Hierarchy Path) -->
+        <view class="breadcrumbs" v-if="path.length > 0">
+          <scroll-view scroll-x class="breadcrumb-scroll" :show-scrollbar="false">
+            <view class="breadcrumb-inner">
+              <view class="breadcrumb-item" @click="goHome">
+                <text class="breadcrumb-text">全部</text>
+                <uv-icon name="arrow-right" size="12" color="#cbd5e1" customStyle="margin: 0 8rpx;"></uv-icon>
+              </view>
+              <view v-for="(node, index) in path" :key="node.id" class="breadcrumb-item" @click="goToPath(index)">
+                <text class="breadcrumb-text" :class="{'active': index === path.length - 1}">{{ node.deptName }}</text>
+                <uv-icon v-if="index < path.length - 1" name="arrow-right" size="12" color="#cbd5e1" customStyle="margin: 0 8rpx;"></uv-icon>
+              </view>
+            </view>
+          </scroll-view>
+        </view>
+
+        <!-- Search Bar -->
+        <view class="search-box">
+          <uv-search v-model="keyword" placeholder="搜索部门名称" :showAction="false" @change="onSearch" @clear="onSearch"></uv-search>
+        </view>
+
+        <!-- Content Area -->
+        <scroll-view scroll-y class="list-container">
+          <view v-if="isSearching" class="search-results">
+            <view v-for="item in searchResults" :key="item.id" class="node-item" @click="handleSelect(item)">
+              <view class="node-content">
+                <view class="node-name-group">
+                  <text class="node-name">{{ item.deptName }}</text>
+                  <text class="node-path" v-if="item.fullPath">{{ item.fullPath }}</text>
+                </view>
+                <view class="node-action">
+                  <uv-icon v-if="!item.hasChildren" name="checkbox-mark" :color="modelValue === item.id ? '#3b82f6' : '#e2e8f0'" size="20"></uv-icon>
+                  <text v-else class="dir-tip">进入</text>
+                </view>
+              </view>
+            </view>
+            <view v-if="searchResults.length === 0" class="empty-tip">未找到匹配的部门</view>
+          </view>
+
+          <view v-else class="level-list">
+            <view v-for="node in currentList" :key="node.id" class="node-item" @click="handleClick(node)">
+              <view class="node-content">
+                <view class="node-info">
+                  <uv-icon :name="node.children && node.children.length > 0 ? 'folder' : 'order'" 
+                    :color="node.children && node.children.length > 0 ? '#60a5fa' : '#94a3b8'" size="20" style="margin-right: 20rpx;"></uv-icon>
+                  <text class="node-name" :class="{'selected': modelValue === node.id}">{{ node.deptName }}</text>
+                </view>
+                <view class="node-action">
+                  <template v-if="node.children && node.children.length > 0">
+                    <text class="child-count">{{ node.children.length }}</text>
+                    <uv-icon name="arrow-right" color="#cbd5e1" size="16"></uv-icon>
+                  </template>
+                  <template v-else>
+                    <uv-icon name="checkbox-mark" :color="modelValue === node.id ? '#3b82f6' : '#e2e8f0'" size="20"></uv-icon>
+                  </template>
+                </view>
+              </view>
+            </view>
+          </view>
+        </scroll-view>
+
+        <!-- Footer / Tip -->
+        <view class="footer-tip" v-if="!isSearching">
+          <uv-icon name="info-circle" color="#3b82f6" size="14" customStyle="margin-right: 8rpx;"></uv-icon>
+          <text>请选择最末级科室</text>
+        </view>
+      </view>
+    </uv-popup>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, computed } from 'vue';
+
+const props = defineProps({
+  modelValue: [Number, String],
+  treeData: {
+    type: Array as any,
+    default: () => []
+  }
+});
+
+const emit = defineEmits(['update:modelValue', 'select']);
+
+const popupRef = ref();
+const keyword = ref('');
+const isSearching = computed(() => keyword.value.trim().length > 0);
+const path = ref<any[]>([]);
+const currentList = ref<any[]>([]);
+
+// Initialize currentList when treeData arrives
+watch(() => props.treeData, (newVal) => {
+  if (newVal && newVal.length > 0 && path.value.length === 0) {
+    currentList.value = newVal;
+  }
+}, { immediate: true });
+
+const searchResults = ref<any[]>([]);
+
+const onSearch = () => {
+  if (!keyword.value.trim()) {
+    searchResults.value = [];
+    return;
+  }
+  const results: any[] = [];
+  const walk = (nodes: any[], parentPath: string = '') => {
+    for (const node of nodes) {
+      const currentPath = parentPath ? `${parentPath} / ${node.deptName}` : node.deptName;
+      if (node.deptName.includes(keyword.value)) {
+        results.push({
+          ...node,
+          fullPath: parentPath,
+          hasChildren: node.children && node.children.length > 0
+        });
+      }
+      if (node.children) walk(node.children, currentPath);
+    }
+  };
+  walk(props.treeData);
+  searchResults.value = results;
+};
+
+const open = () => {
+  popupRef.value.open();
+};
+
+const close = () => {
+  popupRef.value.close();
+};
+
+const onPopupChange = (e: any) => {
+  if (!e.show) {
+    // Reset path when closing if desired, or keep state
+  }
+};
+
+const handleClick = (node: any) => {
+  if (node.children && node.children.length > 0) {
+    path.value.push(node);
+    currentList.value = node.children;
+  } else {
+    handleSelect(node);
+  }
+};
+
+const handleSelect = (node: any) => {
+  if (node.children && node.children.length > 0) {
+    // Drill down instead of select
+    path.value = findPathToNode(props.treeData, node.id) || [];
+    currentList.value = node.children;
+    keyword.value = '';
+    return;
+  }
+  emit('update:modelValue', node.id);
+  emit('select', node);
+  close();
+};
+
+const findPathToNode = (nodes: any[], id: number, currentPath: any[] = []): any[] | null => {
+  for (const node of nodes) {
+    if (node.id === id) return [...currentPath, node];
+    if (node.children) {
+      const res = findPathToNode(node.children, id, [...currentPath, node]);
+      if (res) return res;
+    }
+  }
+  return null;
+};
+
+const goToPath = (index: number) => {
+  const targetNode = path.value[index];
+  path.value = path.value.slice(0, index + 1);
+  currentList.value = targetNode.children;
+};
+
+const goHome = () => {
+  path.value = [];
+  currentList.value = props.treeData;
+};
+
+defineExpose({ open, close });
+</script>
+
+<style lang="scss" scoped>
+.dept-select-container {
+  background-color: #ffffff;
+  height: 80vh;
+  display: flex;
+  flex-direction: column;
+  border-radius: 24rpx 24rpx 0 0;
+}
+
+.header {
+  padding: 30rpx 40rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 2rpx solid #f1f5f9;
+}
+
+.title {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.breadcrumbs {
+  padding: 20rpx 40rpx;
+  background-color: #f8fafc;
+  border-bottom: 2rpx solid #f1f5f9;
+}
+
+.breadcrumb-scroll {
+  width: 100%;
+}
+
+.breadcrumb-inner {
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+}
+
+.breadcrumb-item {
+  display: flex;
+  align-items: center;
+}
+
+.breadcrumb-text {
+  font-size: 26rpx;
+  color: #64748b;
+  
+  &.active {
+    color: #3b82f6;
+    font-weight: 500;
+  }
+}
+
+.search-box {
+  padding: 20rpx 40rpx;
+}
+
+.list-container {
+  flex: 1;
+  height: 0;
+}
+
+.node-item {
+  padding: 0 40rpx;
+  
+  &:active {
+    background-color: #f8fafc;
+  }
+}
+
+.node-content {
+  padding: 30rpx 0;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 2rpx solid #f1f5f9;
+}
+
+.node-info {
+  display: flex;
+  align-items: center;
+  flex: 1;
+}
+
+.node-name {
+  font-size: 30rpx;
+  color: #334155;
+  
+  &.selected {
+    color: #3b82f6;
+    font-weight: 600;
+  }
+}
+
+.node-action {
+  display: flex;
+  align-items: center;
+}
+
+.child-count {
+  font-size: 24rpx;
+  color: #94a3b8;
+  margin-right: 12rpx;
+}
+
+.dir-tip {
+  font-size: 24rpx;
+  color: #3b82f6;
+  background: #eff6ff;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+
+.search-results {
+  .node-name-group {
+    display: flex;
+    flex-direction: column;
+  }
+  
+  .node-path {
+    font-size: 22rpx;
+    color: #94a3b8;
+    margin-top: 4rpx;
+  }
+}
+
+.empty-tip {
+  padding: 100rpx 0;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 28rpx;
+}
+
+.footer-tip {
+  padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+  background-color: #f8fafc;
+  display: flex;
+  align-items: center;
+  font-size: 24rpx;
+  color: #64748b;
+  border-top: 2rpx solid #f1f5f9;
+}
+</style>

+ 1 - 0
constants/index.ts

@@ -23,6 +23,7 @@ export const projectStatusOptions = [
   { dictLabel: '待立项', dictValue: '06', type: 'info' },
   { 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' }
 ];

+ 1 - 0
index.html

@@ -2,6 +2,7 @@
 <html lang="zh-CN">
   <head>
     <meta charset="UTF-8" />
+    <script src='https://g.alicdn.com/code/npm/@ali/dingtalk-h5-remote-debug/0.1.3/index.js'></script>
     <script>
       var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
         CSS.supports('top: constant(a)'))

+ 10 - 2
pages.json

@@ -1,5 +1,11 @@
 {
   "pages": [
+    {
+      "path": "pages/home/index",
+      "style": {
+        "navigationBarTitleText": "首页"
+      }
+    },
     {
       "path": "pages/login/index",
       "style": {
@@ -7,9 +13,11 @@
       }
     },
     {
-      "path": "pages/home/index",
+      "path": "pages/login/register",
       "style": {
-        "navigationBarTitleText": "首页"
+        "navigationBarTitleText": "注册账号",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black"
       }
     },
     {

+ 3 - 2
pages/fund/claim/detail.vue

@@ -23,14 +23,15 @@
         <!-- 1. 单据信息 -->
         <view v-if="currentTab === 0">
           <CommonSection title="到款信息" :isFirst="true">
-            <CommonInfoRow label="到账金额(元)" :value="amountUnitFormatter(paymentForm.amount)" isAmount />
+            <CommonInfoRow label="到款金额(元)" :value="amountUnitFormatter(paymentForm.amount)" isAmount />
+            <CommonInfoRow label="管理费比例(%)" :value="(paymentForm.proportion !== null && paymentForm.proportion !== undefined ? paymentForm.proportion : 0) + '%'" />
             <CommonInfoRow label="管理费(元)" :value="amountUnitFormatter(paymentForm.manageAmount)" isAmount />
             <CommonInfoRow label="税费(元)" :value="amountUnitFormatter(paymentForm.taxAmount)" isAmount />
             <CommonInfoRow label="待认领金额(元)" :value="amountUnitFormatter(Number(paymentForm.allotAmount || 0))"
               isAmount />
             <CommonInfoRow label="到款日期"
               :value="paymentForm.date ? formatDate(new Date(paymentForm.date), 'YYYY-MM-DD') : '-'" />
-            <CommonInfoRow label="经费类型" :value="getDictLabel(paymentReceivedTypeOptions, paymentForm.type)" />
+            <CommonInfoRow label="到款类型" :value="getDictLabel(paymentReceivedTypeOptions, paymentForm.type)" />
             <CommonInfoRow label="打款单位" :value="paymentForm.unit || '-'" />
           </CommonSection>
 

+ 35 - 29
pages/fund/claim/edit.vue

@@ -15,13 +15,14 @@
               </CommonInfoRow>
               <CommonInfoRow label="项目负责人" v-if="form.projectLeaderName" :value="form.projectLeaderName" />
               <CommonInfoRow label="科室" v-if="form.deptName" :value="form.deptName" />
-              <CommonInfoRow label="到款金额(元)" :value="formatAmount(form.amount)" isAmount />
-              <CommonInfoRow label="管理费(元)" :value="formatAmount(form.manageAmount)" isAmount />
-              <CommonInfoRow label="税费(元)" :value="formatAmount(form.taxAmount)" isAmount />
-              <CommonInfoRow label="待认领金额(元)" :value="formatAmount(form.allotAmount)" isAmount />
-              <CommonInfoRow label="到款日期" :value="form.date ? formatDate(new Date(form.date), 'YYYY-MM-DD') : '-'" />
-              <CommonInfoRow label="到款类型" :value="getDictLabel(paymentReceivedTypeOptions, form.type)" />
-              <CommonInfoRow label="打款单位" :value="form.unit || '-'" />
+              <CommonInfoRow label="到款金额(元)" :value="formatAmount(paymentForm.amount)" isAmount />
+              <CommonInfoRow label="管理费比例(%)" :value="(paymentForm.proportion !== null && paymentForm.proportion !== undefined ? paymentForm.proportion : 0) + '%'" />
+              <CommonInfoRow label="管理费(元)" :value="formatAmount(paymentForm.manageAmount)" isAmount />
+              <CommonInfoRow label="税费(元)" :value="formatAmount(paymentForm.taxAmount)" isAmount />
+              <CommonInfoRow label="待认领金额(元)" :value="formatAmount(paymentForm.allotAmount)" isAmount />
+              <CommonInfoRow label="到款日期" :value="paymentForm.date ? formatDate(new Date(paymentForm.date), 'YYYY-MM-DD') : '-'" />
+              <CommonInfoRow label="到款类型" :value="getDictLabel(paymentReceivedTypeOptions, paymentForm.type)" />
+              <CommonInfoRow label="打款单位" :value="paymentForm.unit || '-'" />
             </view>
           </CommonSection>
 
@@ -96,12 +97,20 @@ const isContinue = ref(false);
 
 const detail = ref<any[]>([]);
 
-const form = reactive({
+const paymentForm = reactive({
   id: 0,
-  allotAmount: null,
-  amount: 0, 
+  amount: 0,
   manageAmount: 0,
   taxAmount: 0,
+  allotAmount: 0,
+  date: '',
+  type: '',
+  unit: '',
+  status: '',
+  proportion: 0
+});
+
+const form = reactive({
   projectId: 0,
   projectName: '',
   projectLeaderName: '',
@@ -109,16 +118,10 @@ const form = reactive({
   projectType: '',
   startDate: '',
   endDate: '',
-  createdName: '',
-  createdTime: '',
-  date: '',
-  remark: '',
-  serialNo: '',
-  type: '',
-  unit: '',
-  status: '',
+  amount: 0,
   externalAmount: 0,
-  internalAmount: 0
+  internalAmount: 0,
+  remark: ''
 });
 
 const formatAmount = (cellValue: any) => {
@@ -154,18 +157,21 @@ const getFundDetail = async (id: number) => {
   try {
     const res: any = await fundApi.getDetails({ id });
     if (res.code == 200 && res.data) {
-      Object.assign(form, res.data);
-      form.id = res.data.id;
-      form.amount = res.data.amount || 0;
-      form.manageAmount = res.data.manageAmount || 0;
-      form.taxAmount = res.data.taxAmount || 0;
-      form.allotAmount = res.data.allotAmount || 0;
+      Object.assign(paymentForm, res.data);
+      
+      form.projectId = res.data.projectId || 0;
+      form.projectName = res.data.projectName || '';
+      form.projectLeaderName = res.data.projectLeaderName || '';
+      form.deptName = res.data.projectDeptName || '';
+      form.projectType = res.data.projectType || '';
+      form.startDate = res.data.projectStartDate || '';
+      form.endDate = res.data.projectEndDate || '';
       
       if (res.data.projectName) {
         isContinue.value = true;
       }
       
-      if (form.status == '20') {
+      if (paymentForm.status == '20') {
          onRouterPush(`/pages/fund/claim/detail?id=${id}`);
       }
     }
@@ -196,7 +202,7 @@ const submitForm = async () => {
   }
 
   const totalEntry = detail.value.reduce((total, item) => total + (Number(item.amount) || 0), 0);
-  const allotAmount = Number(form.allotAmount) || 0;
+  const allotAmount = Number(paymentForm.allotAmount) || 0;
   const tolerance = 0.01;
 
   if (totalEntry === 0) {
@@ -210,12 +216,12 @@ const submitForm = async () => {
   }
 
   const params = JSON.parse(JSON.stringify(form));
-  params.fundId = form.id;
+  params.fundId = paymentForm.id;
   params.detail = detail.value.map((item: any) => ({
     ...item,
     amount: Number(item.amount || 0)
   }));
-  params.amount = Number(params.amount || 0);
+  params.amount = totalEntry;
   params.externalAmount = Number(params.externalAmount || 0);
   params.internalAmount = Number(params.internalAmount || 0);
   

+ 46 - 4
pages/home/index.vue

@@ -7,6 +7,9 @@
 -->
 <template>
   <view class="app-container">
+    <uv-loading-page :loading="isLogining && !token" text="科研钉钉平台登录中..." iconSize="40"></uv-loading-page>
+    
+    <view v-if="token">
     <view class="card">
       <view class="card-title">常用功能</view>
       <view class="nav">
@@ -82,13 +85,16 @@
           </view>
         </view>
       </view>
+      </view>
     </view>
   </view>
 </template>
 
 <script name="home" lang="ts" setup>
-import { ref } from 'vue';
+import { ref, watch, onMounted } from 'vue';
+import { storeToRefs } from 'pinia';
 import { onShow } from '@dcloudio/uni-app';
+import { useUserStore } from '@/store/modules/user';
 import { useTodoStore } from '@/store/modules/todo';
 import type { TodoItem } from '@/types/todo';
 import { onRouterPush } from '@/utils/router';
@@ -96,7 +102,10 @@ import { formatDate } from '@/utils/date';
 import { HOME_NAV_LIST } from '@/constants';
 import { useFundApi, useExpenseRemindApi } from '@/api/fund';
 import { useSystemApi } from '@/api/system';
+import * as dd from 'dingtalk-jsapi';
 
+const userStore = useUserStore();
+const { token, isLogining } = storeToRefs(userStore);
 const todoStore = useTodoStore();
 const fundApi = useFundApi();
 const expenseRemindApi = useExpenseRemindApi();
@@ -167,14 +176,47 @@ const handleApprove = (item: TodoItem) => {
 };
 
 /**
- * 页面生命周期:每次显示页面时刷新待办列表
+ * 刷新首页所有数据
  */
-onShow(() => {
-  // 调用 store 中的 action 获取最新的首页待办信息
+const refreshAllData = () => {
+  if (!token.value) return;
+  
   todoStore.fetchHomeTodoList();
   getFundList();
   getExpendseList();
   getNoticeList();
+};
+
+/**
+ * 页面生命周期:每次显示页面时刷新待办列表
+ */
+onShow(() => {
+  if (token.value) {
+    refreshAllData();
+  } else {
+    // 如果没有 token,且不在钉钉环境,跳转登录
+    if (dd.env.platform === 'notInDingTalk' && !isLogining.value) {
+      setTimeout(() => {
+        if (!token.value) {
+          uni.reLaunch({ url: '/pages/login/index' });
+        }
+      }, 500);
+    }
+  }
+});
+
+// 监听 token,登录成功后立即加载数据
+watch(token, (newVal) => {
+  if (newVal) {
+    refreshAllData();
+  }
+});
+
+// 自动检测钉钉登录状态并引导
+onMounted(() => {
+  if (token.value) {
+    refreshAllData();
+  }
 });
 </script>
 

+ 35 - 289
pages/login/index.vue

@@ -3,80 +3,9 @@
     <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 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>
@@ -84,179 +13,51 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue';
-import { storeToRefs } from 'pinia';
+import { ref, onMounted } from 'vue';
 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);
+  if (userStore.token) {
+    uni.switchTab({ url: '/pages/home/index' }).catch(() => {
       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();
+  // 仅在钉钉环境下尝试免登
+  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' });
     }
-  } finally {
-    loading.value = false;
+  } else {
+    // 不在钉钉环境下,直接跳转到注册页(因为不需要登录页)
+    uni.reLaunch({ 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('获取授权码失败');
-    }
-
-    toastRef.value.show({ message: code, type: '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>
@@ -295,66 +96,11 @@ const handleDingTalkLogin = async () => {
   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 {
+.loading-box {
   display: flex;
+  flex-direction: column;
   align-items: center;
-  gap: 20rpx;
-}
-
-.captcha-img {
-  width: 200rpx;
-  height: 104rpx;
+  z-index: 10;
   /* 与左侧输入框 60(height) + 40(padding) + 4(border) 高度保持一致 */
   border-radius: 20rpx;
   background: #f1f5f9;

+ 399 - 0
pages/login/index_his.vue

@@ -0,0 +1,399 @@
+<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>

+ 570 - 0
pages/login/register.vue

@@ -0,0 +1,570 @@
+<template>
+  <view class="container">
+    <view class="bg-shape shadow"></view>
+    <view class="bg-shape2 shadow"></view>
+
+    <view class="register-box">
+      <view class="header">
+        <text class="title">账号注册</text>
+        <text class="subtitle">欢迎开启您的科研工作之旅</text>
+      </view>
+
+      <view class="step-nav">
+        <uv-steps :current="active" activeColor="#3b82f6" inactiveColor="#94a3b8" customStyle="padding: 0 40rpx;">
+          <uv-steps-item title="账号设置"></uv-steps-item>
+          <uv-steps-item title="基本信息"></uv-steps-item>
+        </uv-steps>
+      </view>
+
+      <scroll-view scroll-y class="form-scroll-view" :show-scrollbar="false">
+        <view class="form-content">
+          <!-- Step 0: 登录信息 -->
+          <view v-if="active === 0" class="step-wrapper">
+            <view class="form-group">
+              <text class="label">登录账号<text class="required">*</text></text>
+              <uv-input v-model="form.userName" placeholder="请输入登录账号" border="none" @blur="checkUsername" clearable
+                customStyle="background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                <template #prefix>
+                  <uv-icon name="account" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                </template>
+              </uv-input>
+            </view>
+
+            <view class="form-group">
+              <text class="label">密码<text class="required">*</text></text>
+              <uv-input v-model="form.password" :type="showPassword ? 'text' : 'password'" clearable
+                placeholder="请输入密码" border="none" @blur="checkPasswordValidate"
+                customStyle="background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                <template #prefix>
+                  <uv-icon name="lock" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                </template>
+                <template #suffix>
+                  <uv-icon :name="showPassword ? 'eye-fill' : 'eye-off'" size="20" color="#94a3b8"
+                    @click="showPassword = !showPassword" customStyle="margin-left: 16rpx"></uv-icon>
+                </template>
+              </uv-input>
+            </view>
+
+            <view class="form-group">
+              <text class="label">确认密码<text class="required">*</text></text>
+              <uv-input v-model="form.confirmPassword" :type="showConfirmPassword ? 'text' : 'password'" clearable
+                placeholder="请再次填写密码" border="none"
+                customStyle="background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                <template #prefix>
+                  <uv-icon name="lock-fill" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                </template>
+                <template #suffix>
+                  <uv-icon :name="showConfirmPassword ? 'eye-fill' : 'eye-off'" size="20" color="#94a3b8"
+                    @click="showConfirmPassword = !showConfirmPassword" customStyle="margin-left: 16rpx"></uv-icon>
+                </template>
+              </uv-input>
+            </view>
+
+            <view class="password-hint">
+              <text>提示:密码需包含大小写字母、数字和特殊字符,长度在10-20位</text>
+            </view>
+          </view>
+
+          <!-- Step 1: 个人信息 -->
+          <view v-if="active === 1" class="step-wrapper">
+            <view class="form-group">
+              <text class="label">姓名<text class="required">*</text></text>
+              <uv-input v-model="form.nickName" placeholder="请输入您的姓名" border="none" clearable
+                customStyle="background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                <template #prefix>
+                  <uv-icon name="account" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                </template>
+              </uv-input>
+            </view>
+
+            <view class="form-group">
+              <text class="label">性别<text class="required">*</text></text>
+              <view class="radio-box">
+                <uv-radio-group v-model="form.sex" placement="row">
+                  <uv-radio v-for="(item, index) in sexOptions" :key="index" :name="item.dictValue"
+                    :label="item.dictLabel" customStyle="margin-right: 40rpx;"></uv-radio>
+                </uv-radio-group>
+              </view>
+            </view>
+
+            <view class="form-group">
+              <text class="label">组织部门<text class="required">*</text></text>
+              <view class="selector-box" @click="handleOpenDept">
+                <uv-icon name="tags" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                <text v-if="form.deptName" class="val">{{ form.deptName }}</text>
+                <text v-else class="placeholder">请选择所在部门</text>
+                <uv-icon name="arrow-right" size="14" color="#94a3b8" customStyle="margin-left: auto"></uv-icon>
+              </view>
+            </view>
+
+            <view class="form-group" v-if="form.deptId === 999999">
+              <text class="label">单位名称<text class="required">*</text></text>
+              <uv-input v-model="form.unitName" placeholder="请输入单位名称" border="none"
+                customStyle="background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                <template #prefix>
+                  <uv-icon name="home" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                </template>
+              </uv-input>
+            </view>
+
+            <view class="form-group">
+              <text class="label">手机号码<text class="required">*</text></text>
+              <uv-input v-model="form.phone" type="number" maxlength="11" placeholder="用于接收重要通知" border="none" clearable
+                @blur="checkPhone" customStyle="background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                <template #prefix>
+                  <uv-icon name="phone" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                </template>
+              </uv-input>
+            </view>
+
+            <view class="form-group">
+              <text class="label">电子邮箱<text class="required">*</text></text>
+              <uv-input v-model="form.email" placeholder="请输入常用的邮箱" border="none" clearable
+                customStyle="background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                <template #prefix>
+                  <uv-icon name="email" size="20" color="#94a3b8" customStyle="margin-right: 16rpx"></uv-icon>
+                </template>
+              </uv-input>
+            </view>
+
+            <view class="form-group">
+              <text class="label">证件号码<text class="required">*</text></text>
+              <view class="id-group">
+                <view class="id-type">{{ idTypeLabel }}</view>
+                <uv-input v-model="form.idCode" placeholder="请输入证件号" border="none" clearable
+                  customStyle="flex: 1; background: #f1f5f9; padding: 24rpx 30rpx; border-radius: 20rpx;">
+                </uv-input>
+              </view>
+            </view>
+          </view>
+        </view>
+      </scroll-view>
+
+      <view class="footer-actions">
+        <view class="btn-group">
+          <view class="btn-side" v-if="active > 0">
+            <uv-button text="上一步" @click="preStep" plain color="#64748b"
+              customStyle="height: 100rpx; border-radius: 50rpx; border-color: #e2e8f0; font-weight: 600;"></uv-button>
+          </view>
+          <view class="btn-main">
+            <uv-button v-if="active === 0" text="下一步" @click="nextStep" shape="circle"
+              color="linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)"
+              customStyle="height: 100rpx; font-size: 30rpx; font-weight: 600; box-shadow: 0 10rpx 20rpx rgba(37, 99, 235, 0.2); border: none;"></uv-button>
+            <uv-button v-if="active === 1" text="立即提交注册" @click="onRegister" :loading="loading" shape="circle"
+              color="linear-gradient(135deg, #059669 0%, #10b981 100%)"
+              customStyle="height: 100rpx; font-size: 30rpx; font-weight: 600; box-shadow: 0 10rpx 20rpx rgba(16, 185, 129, 0.2); border: none;"></uv-button>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <uv-toast ref="toastRef"></uv-toast>
+
+    <!-- 部门选择组件 -->
+    <SelectDept ref="selectDeptRef" :treeData="deptTree" v-model="form.deptId" @select="onDeptSelect" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed } from 'vue';
+import { onLoad } from '@dcloudio/uni-app';
+import { useSystemApi, useDeptApi } from '@/api/system/index';
+import { useLoginApi } from '@/api/system/login';
+import { encryptWithBackendConfig } from '@/utils/aesCrypto';
+import smCrypto from 'sm-crypto';
+import SelectDept from '@/components/SelectDept/index.vue';
+
+const sm3 = smCrypto.sm3;
+const systemApi = useSystemApi();
+const deptApi = useDeptApi();
+const loginApi = useLoginApi();
+
+const active = ref(0);
+const loading = ref(false);
+const toastRef = ref();
+
+const selectDeptRef = ref();
+
+const showPassword = ref(false);
+const showConfirmPassword = ref(false);
+
+const deptTree = ref<any[]>([]);
+const sexOptions = ref<any[]>([]);
+const userCertList = ref<any[]>([]);
+
+const form = reactive({
+  userName: '',
+  password: '',
+  confirmPassword: '',
+  nickName: '',
+  userType: '10',
+  deptId: undefined as number | undefined,
+  deptName: '',
+  phone: '',
+  email: '',
+  sex: '30',
+  idType: '20', // 默认身份证
+  idCode: '',
+  registerType: '10',
+  saltValue: '',
+  status: '10',
+  unitName: '',
+});
+
+const idTypeLabel = computed(() => {
+  if (userCertList.value.length > 0) {
+    const cert = userCertList.value.find(item => item.dictValue === form.idType);
+    if (cert) return cert.dictLabel;
+  }
+  return form.idType === '20' ? '身份证' : '其他证件';
+});
+
+const searchTree = (tree: any[], id: number): any => {
+  for (const node of tree) {
+    if (node.id === id) return node;
+    if (node.children && node.children.length > 0) {
+      const found = searchTree(node.children, id);
+      if (found) return found;
+    }
+  }
+  return null;
+};
+
+const initData = async () => {
+  try {
+    const results = await Promise.allSettled([
+      systemApi.getDictDataByType('sys_com_sex'),
+      deptApi.getDeptTree({}),
+      systemApi.getDictDataByType('sys_user_certificate')
+    ]);
+
+    const dictRes = results[0].status === 'fulfilled' ? results[0].value : null;
+    const deptRes = results[1].status === 'fulfilled' ? results[1].value : null;
+    const certRes = results[2].status === 'fulfilled' ? results[2].value : null;
+
+    if (dictRes && dictRes.code === 200 && dictRes.data) {
+      sexOptions.value = dictRes.data.values || [];
+    }
+    if (certRes && certRes.code === 200 && certRes.data) {
+      userCertList.value = certRes.data.values || [];
+    }
+    if (deptRes && deptRes.code === 200 && deptRes.data) {
+      let tree = deptRes.data;
+      if (form.userType === '10' && tree.length > 0) {
+        const root = searchTree(tree, 100001);
+        if (root) tree = [root];
+      }
+      deptTree.value = tree;
+    }
+  } catch (err) {
+    console.error('Init data fail:', err);
+  }
+};
+
+const handleOpenDept = () => {
+  if (deptTree.value.length === 0) {
+    uni.showLoading({ title: '加载中...' });
+    initData().then(() => {
+      uni.hideLoading();
+      if (deptTree.value.length > 0) {
+        selectDeptRef.value.open();
+      } else {
+        uni.showModal({ title: '提示', content: '未能获取到部门数据,请稍后重试', showCancel: false });
+      }
+    });
+    return;
+  }
+  selectDeptRef.value.open();
+};
+
+const onDeptSelect = (item: any) => {
+  form.deptName = item.deptName;
+};
+
+const checkUsername = async () => {
+  if (!form.userName) return;
+  try {
+    await loginApi.checkUserNamePhoneExists({ userName: form.userName, phone: '' });
+  } catch (err) { }
+};
+
+const checkPhone = async () => {
+  if (!form.phone || form.phone.length !== 11) return;
+  try {
+    await loginApi.checkUserNamePhoneExists({ userName: '', phone: form.phone });
+  } catch (err) { }
+};
+
+const checkPasswordValidate = async () => {
+  if (!form.password) return;
+  try {
+    await loginApi.validatePassword({ password: form.password });
+  } catch (err) { }
+};
+
+const validateStep0 = () => {
+  if (!form.userName) {
+    toastRef.value.show({ message: '请输入账户名称', type: 'error' });
+    return false;
+  }
+  if (!form.password || form.password.length < 10 || form.password.length > 20) {
+    toastRef.value.show({ message: '密码长度在10-20位之间', type: 'error' });
+    return false;
+  }
+  if (form.password !== form.confirmPassword) {
+    toastRef.value.show({ message: '两次密码输入不一致', type: 'error' });
+    return false;
+  }
+  return true;
+};
+
+const validateStep1 = () => {
+  if (!form.nickName) {
+    toastRef.value.show({ message: '请输入姓名', type: 'error' });
+    return false;
+  }
+  if (!form.deptId) {
+    toastRef.value.show({ message: '请选择组织部门', type: 'error' });
+    return false;
+  }
+  if (!form.phone || form.phone.length !== 11) {
+    toastRef.value.show({ message: '请输入正确的手机号', type: 'error' });
+    return false;
+  }
+  if (!form.idCode) {
+    toastRef.value.show({ message: '请输入证件号', type: 'error' });
+    return false;
+  }
+  return true;
+};
+
+const preStep = () => {
+  active.value--;
+};
+
+const nextStep = () => {
+  if (validateStep0()) {
+    active.value++;
+  }
+};
+
+const onRegister = async () => {
+  if (validateStep1()) {
+    loading.value = true;
+    try {
+      const params = JSON.parse(JSON.stringify(form));
+      params.password = sm3(params.password);
+      params.saltValue = encryptWithBackendConfig(params.password);
+      delete params.confirmPassword;
+
+      const res: any = await loginApi.register(params);
+      if (res.code === 200) {
+        toastRef.value.show({ message: '注册成功,请等待管理员审核', type: 'success' });
+        setTimeout(() => {
+          uni.reLaunch({ url: '/pages/login/register' });
+        }, 3000);
+      } else {
+        toastRef.value.show({ message: res.msg || '注册失败', type: 'error' });
+      }
+    } catch (err) {
+      console.error(err);
+    } finally {
+      loading.value = false;
+    }
+  }
+};
+
+const goBack = () => {
+  uni.navigateBack();
+};
+
+onLoad(() => {
+  initData();
+});
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  position: relative;
+  background: #f8fafc;
+  overflow: hidden;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 40rpx 30rpx;
+  box-sizing: border-box;
+}
+
+.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);
+}
+
+.register-box {
+  width: 100%;
+  height: 92vh;
+  background: rgba(255, 255, 255, 0.9);
+  backdrop-filter: blur(20px);
+  border-radius: 40rpx;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  z-index: 10;
+  border: 2rpx solid rgba(255, 255, 255, 0.5);
+  box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, 0.05);
+  overflow: hidden;
+}
+
+.header {
+  padding: 50rpx 40rpx 30rpx;
+  flex-shrink: 0;
+}
+
+.title {
+  font-size: 48rpx;
+  font-weight: 800;
+  color: #1e293b;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.subtitle {
+  font-size: 26rpx;
+  color: #64748b;
+  display: block;
+}
+
+.step-nav {
+  padding-bottom: 30rpx;
+  flex-shrink: 0;
+}
+
+.form-scroll-view {
+  flex: 1;
+  min-height: 0;
+  padding: 0 40rpx;
+  box-sizing: border-box;
+}
+
+.form-content {
+  padding-bottom: 40rpx;
+}
+
+.form-group {
+  margin-bottom: 30rpx;
+}
+
+.label {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #334155;
+  margin-bottom: 12rpx;
+  display: block;
+  padding-left: 8rpx;
+}
+
+.required {
+  color: #ef4444;
+  margin-left: 6rpx;
+}
+
+
+
+.password-hint {
+  margin-top: 10rpx;
+  display: flex;
+  align-items: center;
+  font-size: 20rpx;
+  color: #ef4444;
+  padding-left: 10rpx;
+}
+
+.radio-box {
+  background: #f1f5f9;
+  padding: 24rpx 30rpx;
+  border-radius: 20rpx;
+}
+
+.selector-box {
+  background: #f1f5f9;
+  padding: 24rpx 30rpx;
+  border-radius: 20rpx;
+  display: flex;
+  align-items: center;
+}
+
+.selector-box .placeholder {
+  color: #94a3b8;
+  font-size: 28rpx;
+}
+
+.selector-box .val {
+  color: #1e293b;
+  font-size: 28rpx;
+}
+
+.id-group {
+  display: flex;
+  gap: 16rpx;
+  align-items: center;
+}
+
+.id-type {
+  width: 220rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  background: #e2e8f0;
+  color: #475569;
+  font-size: 24rpx;
+  text-align: center;
+  border-radius: 20rpx;
+}
+
+.footer-actions {
+  padding: 30rpx 40rpx 40rpx;
+  background: rgba(255, 255, 255, 0.5);
+  border-top: 2rpx solid rgba(241, 245, 249, 0.5);
+  border-radius: 0 0 40rpx 40rpx;
+}
+
+.btn-group {
+  display: flex;
+  gap: 20rpx;
+}
+
+.btn-side {
+  flex: 1;
+}
+
+.btn-main {
+  flex: 2;
+}
+
+.back-link {
+  margin-top: 30rpx;
+  text-align: center;
+  font-size: 26rpx;
+  color: #3b82f6;
+  font-weight: 500;
+}
+</style>

+ 1 - 1
pages/my/index.vue

@@ -26,7 +26,7 @@
     <view class="footer">
       <!-- <uv-button customStyle="margin-bottom: 20rpx;" color="#1cb4fd" plain text="修改密码"
         @click="onRouterPush('/pages/user/password')"></uv-button> -->
-      <uv-button type="primary" text="退出登录" @click="signOut"></uv-button>
+      <!-- <uv-button type="primary" text="退出登录" @click="signOut"></uv-button> -->
     </view>
   </view>
 </template>

+ 15 - 1
pages/project/components/detail/ProjectApproval.vue

@@ -3,12 +3,13 @@
     <FlowTable
       :id="projectId"
       :businessCode="projectData?.projectCode"
-      :defCode="projectData?.projectStatus === '05' ? 'pre_sci_project_vertical' : 'sci_project_vertical'"
+      :defCode="computedDefCode"
     />
   </view>
 </template>
 
 <script setup lang="ts">
+import { computed } from 'vue';
 import FlowTable from './FlowTable.vue';
 
 const props = defineProps<{
@@ -16,6 +17,19 @@ const props = defineProps<{
   projectType: string;
   projectData: any;
 }>();
+
+const computedDefCode = computed(() => {
+  const prefix = props.projectData?.projectStatus === '05' ? 'pre_' : '';
+  let baseCode = 'sci_project_vertical';
+
+  if (props.projectType === 'horizontal' || props.projectType === '20') {
+    baseCode = 'sci_project_horizontal';
+  } else if (props.projectType === 'spontaneity' || props.projectType === '30') {
+    baseCode = 'sci_project_spontaneity';
+  }
+
+  return prefix + baseCode;
+});
 </script>
 
 <style lang="scss" scoped>

+ 1 - 1
pages/project/components/detail/ProjectSetupHorizontal.vue

@@ -24,7 +24,7 @@
       <CommonInfoRow label="匹配经费(元)" :value="amountUnitFormatter(projectData?.supportFunds)" isAmount />
       <CommonInfoRow label="自筹经费(元)" :value="amountUnitFormatter(projectData?.selfFunds)" isAmount />
       <CommonInfoRow label="总金额(元)"
-        :value="amountUnitFormatter((Number(projectData?.contractFunds || 0) + Number(projectData?.approvedFunds || 0) + Number(projectData?.selfFunds || 0)))"
+        :value="amountUnitFormatter((Number(projectData?.contractFunds || 0) + Number(projectData?.supportFunds || 0) + Number(projectData?.selfFunds || 0)))"
         isAmount />
       <CommonInfoRow label="承担单位排名"
         :value="projectData?.unitRank == '10' ? '第一单位' : (projectData?.unitRank == '20' ? '非第一单位' : '--')" />

+ 1 - 1
pages/project/components/detail/ProjectSetupSpontaneity.vue

@@ -19,7 +19,7 @@
       <CommonInfoRow label="匹配经费(元)" :value="amountUnitFormatter(projectData?.supportFunds)" isAmount />
       <CommonInfoRow label="自筹经费(元)" :value="amountUnitFormatter(projectData?.selfFunds)" isAmount />
       <CommonInfoRow label="总金额(元)"
-        :value="amountUnitFormatter((Number(projectData?.contractFunds || 0) + Number(projectData?.approvedFunds || 0) + Number(projectData?.selfFunds || 0)))"
+        :value="amountUnitFormatter((Number(projectData?.contractFunds || 0) + Number(projectData?.supportFunds || 0) + Number(projectData?.selfFunds || 0)))"
         isAmount />
       <CommonInfoRow label="承担单位排名"
         :value="projectData?.unitRank == '10' ? '第一单位' : (projectData?.unitRank == '20' ? '非第一单位' : '--')" />

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

@@ -4,6 +4,7 @@
       <CommonInfoRow label="项目名称" :value="projectData?.projectName" />
       <CommonInfoRow label="项目编号" :value="projectData?.projectCode" />
       <CommonInfoRow label="项目分类" :value="projectData?.projectClazzName" />
+      <CommonInfoRow label="项目级别" :value="getDictLabel('sci_pjt_level', projectData?.projectLevel)" />
       <CommonInfoRow label="项目来源" :value="projectData?.projectSource" />
       <CommonInfoRow label="项目执行期"
         :value="formatDate(projectData?.planStartDate) + ' ~ ' + formatDate(projectData?.planEndDate)" />
@@ -41,7 +42,7 @@ const props = defineProps<{
   projectData: any;
 }>();
 
-const { getDictLabel } = useDict('sci_pjt_type');
+const { getDictLabel } = useDict('sci_pjt_type', 'sci_pjt_level');
 
 const amountUnitFormatter = (num: any) => {
   return formatWithComma(num);

+ 1 - 1
pages/project/detail.vue

@@ -97,7 +97,7 @@ const tabList = computed(() => {
   }
   
   tabs.push(
-    { name: '项目文档', component: 'ProjectDocs' },
+    { name: '附件信息', component: 'ProjectDocs' },
     { name: '科研成果', component: 'ProjectAchievements' },
     { name: '经费信息', component: 'ProjectFunding' },
     { name: '中检信息', component: 'ProjectInspection' },

+ 14 - 20
pages/todo/components/document/SciFundReverse.vue

@@ -5,20 +5,20 @@
       <!-- 项目信息 -->
       <view class="common-section-card">
         <view class="section-title">项目信息</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">{{ projectInfo?.projectName || '-' }}</text></view>
         <view class="info-row">
           <text class="label">经费类型</text>
-          <text class="value">{{ getFundType(form.type) }}</text>
+          <text class="value">{{ getFundType(projectInfo?.type) }}</text>
         </view>
-        <view class="info-row"><text class="label">项目负责人</text><text class="value">{{ form.projectIncharge || '-' }}</text></view>
-        <view class="info-row"><text class="label">所属科室</text><text class="value">{{ form.projectDeptName || '-' }}</text></view>
+        <view class="info-row"><text class="label">项目负责人</text><text class="value">{{ projectInfo?.inchargeName || '-' }}</text></view>
+        <view class="info-row"><text class="label">所属科室</text><text class="value">{{ projectInfo?.deptName || '-' }}</text></view>
         <view class="info-row">
-          <text class="label">到账总额(元)</text>
-          <text class="value red-color">¥{{ formatMoney(projectInfo?.amount) }}</text>
+          <text class="label">到账总额</text>
+          <text class="value red-color">¥{{ formatMoney(projectInfo?.amount) }}</text>
         </view>
         <view class="info-row">
-          <text class="label">结余金额(元)</text>
-          <text class="value red-color">¥{{ formatMoney(projectInfo?.balanceAmount) }}</text>
+          <text class="label">结余金额</text>
+          <text class="value red-color">¥{{ formatMoney(projectInfo?.balanceAmount) }}</text>
         </view>
       </view>
 
@@ -27,28 +27,22 @@
         <view class="section-title">报销经费</view>
         <view class="info-row"><text class="label">费用科目</text><text class="value">{{ form.subjName || '-' }}</text></view>
         <view class="info-row">
-          <text class="label">入账金额(元)</text>
-          <text class="value red-color">¥{{ formatMoney(subjAmount?.amount) }}</text>
+          <text class="label">入账金额</text>
+          <text class="value red-color">¥{{ formatMoney(subjAmount?.amount) }}</text>
         </view>
         <view class="info-row">
-          <text class="label">可用金额(元)</text>
-          <text class="value red-color">¥{{ formatMoney(subjAmount?.balanceAmount) }}</text>
+          <text class="label">可用金额</text>
+          <text class="value red-color">¥{{ formatMoney(subjAmount?.balanceAmount) }}</text>
         </view>
         <view class="info-row">
-          <text class="label">冲销金额(元)</text>
-          <text class="value red-color">¥{{ formatMoney(form.amount) }}</text>
+          <text class="label">冲销金额</text>
+          <text class="value red-color">¥{{ formatMoney(form.amount) }}</text>
         </view>
         <view class="info-row"><text class="label">冲销日期</text><text class="value">{{ form.reverseTime || '-' }}</text></view>
         <view class="info-row"><text class="label">冲销人</text><text class="value">{{ form.reverse || '-' }}</text></view>
         <view class="info-row"><text class="label">操作人</text><text class="value">{{ form.operator || '-' }}</text></view>
         <view class="info-row"><text class="label">记录人</text><text class="value">{{ form.record || '-' }}</text></view>
       </view>
-
-      <!-- 备注 -->
-      <view class="common-section-card mt20" v-if="form.remark">
-        <view class="section-title">备注</view>
-        <view class="remark-content">{{ form.remark }}</view>
-      </view>
     </template>
     <uv-empty v-else mode="data" text="暂无数据"></uv-empty>
   </view>

+ 67 - 63
pages/todo/index.vue

@@ -3,59 +3,30 @@
     <!-- 顶部搜索 + tab -->
     <view class="search-header">
       <view class="search-row">
-        <uv-input
-          placeholder="请输入审批名称"
-          v-model="queryParams.instTitle"
-          prefixIcon="search"
-          prefixIconStyle="color: #999; font-size: 32rpx;"
-          clearable
-          shape="circle"
-          customStyle="background-color: #f5f7fa; border: none;"
-        ></uv-input>
+        <uv-input placeholder="请输入审批名称" v-model="queryParams.instTitle" prefixIcon="search"
+          prefixIconStyle="color: #999; font-size: 32rpx;" clearable shape="circle"
+          customStyle="background-color: #f5f7fa; border: none;"></uv-input>
       </view>
 
       <view class="type-tabs">
-        <uv-tabs
-          :list="tabList"
-          :current="currentTab"
-          @change="onTabChange"
+        <uv-tabs :list="tabList" :current="currentTab" @change="onTabChange"
           :activeStyle="{ color: '#1c9bfd', fontWeight: 'bold', transform: 'scale(1.05)' }"
-          :inactiveStyle="{ color: '#666' }"
-          lineColor="#1c9bfd"
-          lineWidth="40"
-          :itemStyle="{ height: '88rpx', flex: 1 }"
-          :scrollable="false"
-        ></uv-tabs>
+          :inactiveStyle="{ color: '#666' }" lineColor="#1c9bfd" lineWidth="40"
+          :itemStyle="{ height: '88rpx', flex: 1 }" :scrollable="false"></uv-tabs>
       </view>
     </view>
 
     <!-- 列表区域 -->
     <view class="list-container">
-      <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"
-      >
+      <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 v-if="!todoStore.list.length && loadStatus !== 'loading'" mode="list"></uv-empty>
 
-        <view
-          class="common-list-card"
-          v-for="item in todoStore.list"
-          :key="item.id || item.taskId"
-        >
+        <view class="common-list-card" v-for="item in todoStore.list" :key="item.id || item.taskId">
           <view class="card-header">
             <text class="title">{{ item.instTitle || '未命名流程' }}</text>
-            <text
-              class="status-tag"
-              :class="item.isFinish == '10' ? 'status-done' : 'status-undo'"
-            >
+            <text class="status-tag" :class="item.isFinish == '10' ? 'status-done' : 'status-undo'">
               {{ item.isFinish == '10' ? '已完成' : '未完成' }}
             </text>
           </view>
@@ -74,30 +45,16 @@
             </view>
           </view>
           <view class="card-footer" v-if="currentType === 'approval'">
-            <uv-button
-              v-if="item.isFinish == '20'"
-              type="primary"
-              text="审批"
-              size="small"
-              customStyle="padding: 0 24rpx; height: 60rpx;"
-              @click="openApprove(item)"
-            ></uv-button>
+            <uv-button v-if="item.isFinish == '20'" type="primary" text="审批" size="small"
+              customStyle="padding: 0 24rpx; height: 60rpx;" @click="openApprove(item)"></uv-button>
           </view>
         </view>
 
-        <uv-load-more
-          v-if="todoStore.list.length > 0 || loadStatus === 'loading'"
-          :status="loadStatus"
-          @loadmore="loadMore"
-        ></uv-load-more>
+        <uv-load-more v-if="todoStore.list.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>
+      <uv-back-top :scrollTop="currentScrollTop" :bottom="100" :right="30" @click="backToTop"></uv-back-top>
     </view>
 
   </view>
@@ -112,8 +69,13 @@ import { onRouterPush } from '@/utils/router';
 import { formatDate } from '@/utils/date';
 import type { TodoItem } from '@/types/todo';
 import { TODO_TAB_LIST } from '@/constants/index';
+import { useExecutionApi } from '@/api/execution';
+import { useProjectApi } from '@/api/project';
+import to from 'await-to-js';
 
 const todoStore = useTodoStore();
+const executionApi = useExecutionApi();
+const projectApi = useProjectApi();
 
 const tabList = ref(TODO_TAB_LIST);
 
@@ -202,8 +164,52 @@ const viewDetail = (item: TodoItem) => {
   onRouterPush(`/pages/todo/detail?mode=view&id=${item.id || ''}&taskId=${item.taskId || ''}&taskCode=${item.businessCode || ''}&defCode=${item.defCode || ''}`);
 };
 
-const openApprove = (item: TodoItem) => {
-  onRouterPush(`/pages/todo/detail?mode=approval&id=${item.id || ''}&taskId=${item.taskId || ''}&taskCode=${item.businessCode || ''}&defCode=${item.defCode || ''}`);
+const openApprove = async (item: TodoItem) => {
+  // 审批前增加学术不端行为校验
+  uni.showLoading({ title: '校验中...', mask: true });
+
+  // 1. 获取参与者列表,找出发起人
+  const [err, res] = await to(executionApi.getParticipantByProcInstID({ id: item.id }));
+
+  if (err || !res?.data) {
+    uni.hideLoading();
+    // 如果获取失败,出于流程通畅考虑,直接跳转
+    onRouterPush(`/pages/todo/detail?mode=approval&id=${item.id || ''}&taskId=${item.taskId || ''}&taskCode=${item.businessCode || ''}&defCode=${item.defCode || ''}`);
+    return;
+  }
+
+  const participants = res.data as any[];
+  const startUser = participants.find(p => p.type === 'start');
+  const startUserId = startUser?.userId;
+
+  if (!startUserId) {
+    uni.hideLoading();
+    onRouterPush(`/pages/todo/detail?mode=approval&id=${item.id || ''}&taskId=${item.taskId || ''}&taskCode=${item.businessCode || ''}&defCode=${item.defCode || ''}`);
+    return;
+  }
+
+  // 2. 调用学术不端校验接口
+  const [err2, res2] = await to(projectApi.checkUserViolationStatus({ personId: startUserId }));
+  uni.hideLoading();
+
+  if (!err2 && res2?.data?.hasViolation) {
+    const { restrictions } = res2.data;
+    let restrictionList = [];
+    if (restrictions.limitProjectApply) restrictionList.push('限制项目申报');
+    if (restrictions.limitFundApply) restrictionList.push('限制申请经费');
+    if (restrictions.limitRewardApply) restrictionList.push('限制成为奖励对象');
+
+    uni.showModal({
+      title: '学术不端行为提醒',
+      content: `发起人 [${startUser?.userName || '未知'}] 存在学术不端记录,已被限制:${restrictionList.join('、')}。`,
+      confirmText: '我知道了',
+      showCancel: false
+    });
+    // 不跳转
+    onRouterPush(`/pages/todo/detail?mode=approval&id=${item.id || ''}&taskId=${item.taskId || ''}&taskCode=${item.businessCode || ''}&defCode=${item.defCode || ''}`);
+  } else {
+    onRouterPush(`/pages/todo/detail?mode=approval&id=${item.id || ''}&taskId=${item.taskId || ''}&taskCode=${item.businessCode || ''}&defCode=${item.defCode || ''}`);
+  }
 };
 
 onShow(() => {
@@ -268,6 +274,4 @@ onLoad(() => {
     }
   }
 }
-
-/* 审批弹窗样式已移除,审批在详情页中处理 */
 </style>

+ 3 - 2
store/modules/user.ts

@@ -25,6 +25,7 @@ export const useUserStore = defineStore('user', () => {
   // 页面请求加载状态
   const loadings = {
     loginLoading: ref<boolean>(false),
+    isLogining: ref<boolean>(false),
     fetchUserInfoLoading: ref<boolean>(false),
     logoutLoading: ref<boolean>(false),
   } as const;
@@ -105,11 +106,11 @@ export const useUserStore = defineStore('user', () => {
    * 钉钉免登
    */
   async function dingTalkLogin(code: string) {
-    setRequestLoading('loginLoading', true);
+    setRequestLoading('isLogining', true);
     
     const [err, res] = await to(loginApi.dingTalkLogin({ code }));
     
-    setRequestLoading('loginLoading', false);
+    setRequestLoading('isLogining', false);
     
     if (err) {
       return Promise.reject(err);