Browse Source

feature:温岭 增加钉钉 和 权限校验

liuzhenlin 16 hours ago
parent
commit
f9e2908a2d

+ 5 - 0
.env.development

@@ -9,5 +9,10 @@ VITE_ADMIN = dashoo.labsop.admin-55000
 VITE_SCIENTIFIC = dashoo.labsop.scientific-55000
 VITE_WORKFLOW = dashoo.labsop.workflow-55000
 
+# 钉钉corpId
+VITE_DINGTALK_CORPID=dinga8b316209f5ee42435c2f4657eb6378f
+# 钉钉clientId (AppKey)
+VITE_DINGTALK_CLIENTID=
+
 # 租户ID 
 VITE_TENANT=default

+ 4 - 0
.env.production

@@ -9,5 +9,9 @@ VITE_ADMIN = dashoo.labsop.admin-55000
 VITE_SCIENTIFIC = dashoo.labsop.scientific-55000
 VITE_WORKFLOW = dashoo.labsop.workflow-55000
 
+# 钉钉配置
+VITE_DINGTALK_CORPID = dinga8b316209f5ee42435c2f4657eb6378f
+VITE_DINGTALK_CLIENTID = dingo2thrapshzkv6uny
+
 # 租户ID 
 VITE_TENANT=default

+ 43 - 12
App.vue

@@ -1,15 +1,46 @@
-<script>
-	export default {
-		onLaunch: function() {
-			console.log('App Launch')
-		},
-		onShow: function() {
-			console.log('App Show')
-		},
-		onHide: function() {
-			console.log('App Hide')
-		}
-	}
+<script setup>
+import { onLaunch, onShow, onHide } from '@dcloudio/uni-app';
+import { useUserStore } from '@/store/modules/user';
+import { getDingTalkAuthCode } from '@/utils/dingtalk';
+import * as dd from 'dingtalk-jsapi';
+
+const userStore = useUserStore();
+
+onLaunch(async (options) => {
+  console.log('App Launch');
+  
+  // 仅在钉钉环境下尝试免登
+  if (dd.env.platform !== 'notInDingTalk') {
+    // 如果没有 token,或者用户信息为空,尝试免登
+    if (!userStore.token) {
+      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,通常需要引导至手动登录也或报错
+        // 这里视业务需求而定,通常免登失败我们不再强制阻塞,除非是纯免登系统
+      }
+    }
+  }
+});
+
+onShow(() => {
+  console.log('App Show');
+});
+
+onHide(() => {
+  console.log('App Hide');
+});
 </script>
 
 <style lang="scss">

+ 4 - 0
api/document/index.ts

@@ -203,6 +203,10 @@ export function useDocumentApi() {
     // 获取软件著作权详情 (通过ID)
     getSoftwareAchievementById: (id: string | number) => {
       return microRequest.postRequest(sciPath, 'SciAchievementSoftware', 'GetSoftwareEntity', { id: Number(id) });
+    },
+    // 预申报 (sci_project_declaration_list)
+    getDeclarationById: (id: string | number) => {
+      return microRequest.postRequest(sciPath, 'SciProjectDeclarationList', 'GetEntityById', { id: Number(id) });
     }
   };
 }

+ 5 - 0
api/system/login.ts

@@ -26,6 +26,11 @@ export function useLoginApi() {
       return microRequest.postRequest(basePath, 'System', 'GetCaptchaImg');
     },
 
+    // 钉钉免登接口
+    dingTalkLogin: (data: { code: string }) => {
+      return microRequest.postRequest(basePath, 'System', 'DingTalkLogin', data);
+    },
+
 
 
   }

+ 14 - 14
components/ui/CommonInfoRow.vue

@@ -1,11 +1,8 @@
 <template>
-  <view 
-    class="ui-info-row" 
-    :class="{ 
-      'info-column': isColumn || (value && String(value).length > 50),
-      'no-border': noBorder
-    }"
-  >
+  <view class="ui-info-row" :class="{
+    'info-column': isColumn || (value && String(value).length > 50),
+    'no-border': noBorder
+  }">
     <view class="item-label" :class="{ 'bold-label': isBold }">
       {{ label }}
     </view>
@@ -51,7 +48,8 @@ defineProps<{
   font-size: 28rpx;
   line-height: 1.5;
 
-  &:last-child, &.no-border {
+  &:last-child,
+  &.no-border {
     border-bottom: none;
   }
 
@@ -66,7 +64,7 @@ defineProps<{
     // -webkit-box-orient: vertical;
     // line-clamp: 1;
     // overflow: hidden;
-    
+
     &.bold-label {
       font-weight: 600;
       color: #1a1e21;
@@ -80,6 +78,7 @@ defineProps<{
     text-align: right;
     word-break: break-all;
     font-weight: 400;
+    box-sizing: border-box;
 
     &.text-left {
       text-align: left;
@@ -107,7 +106,8 @@ defineProps<{
     .item-label {
       width: 100%;
       margin-bottom: 16rpx;
-      color: #909399; /* 二级标题弱化 */
+      color: #909399;
+      /* 二级标题弱化 */
     }
 
     .item-value {
@@ -117,10 +117,10 @@ defineProps<{
       padding: 16rpx;
       border-radius: 12rpx;
       min-height: 80rpx;
-      display: -webkit-box;
-      -webkit-line-clamp: 2;
-      -webkit-box-orient: vertical;
-      line-clamp: 2;
+      // display: -webkit-box;
+      // -webkit-line-clamp: 2;
+      // -webkit-box-orient: vertical;
+      // line-clamp: 2;
       overflow: hidden;
     }
   }

+ 5 - 5
constants/index.ts

@@ -20,11 +20,11 @@ export const PAGE_CONFIG = {
 // 项目状态选项
 export const projectStatusOptions = [
   { dictLabel: '全部状态', dictValue: '' },
-  { dictLabel: '待立项', dictValue: '05' },
-  { dictLabel: '立项', dictValue: '10' },
-  { dictLabel: '在研', dictValue: '20' },
-  { dictLabel: '结题验收', dictValue: '30' },
-  { dictLabel: '中止', dictValue: '40' }
+  { dictLabel: '待立项', dictValue: '06', type: 'info' },
+  { dictLabel: '立项', dictValue: '10', type: 'primary' },
+  { dictLabel: '在研', dictValue: '20', type: 'success' },
+  { dictLabel: '结题验收', dictValue: '30', type: 'warning' },
+  { dictLabel: '中止', dictValue: '40', type: 'error' }
 ];
 
 // 变更类型选项

+ 26 - 0
directive/hasPermi.ts

@@ -0,0 +1,26 @@
+import { Session } from '@/utils/storage';
+import { CACHE_KEY } from '@/constants/index';
+
+/**
+ * 权限校验指令
+ * 使用方法:v-hasPermi="['system:user:add']"
+ */
+export default {
+    mounted(el: HTMLElement, binding: any) {
+        const { value } = binding;
+        const perms: string[] = Session.get(CACHE_KEY.PERMS) || [];
+
+        if (value && value instanceof Array && value.length > 0) {
+            const hasPermissions = perms.some(permission => {
+                return (value as string[]).includes(permission);
+            });
+
+            if (!hasPermissions) {
+                // 如果没有权限,则移除元素
+                el.parentNode && el.parentNode.removeChild(el);
+            }
+        } else {
+            console.error(`[v-hasPermi]: 请设置操作权限标签值, 如 v-hasPermi="['system:user:add']"`);
+        }
+    }
+};

+ 6 - 0
directive/index.ts

@@ -0,0 +1,6 @@
+import type { App } from 'vue';
+import hasPermi from './hasPermi';
+
+export default function setupDirective(app: App) {
+    app.directive('hasPermi', hasPermi);
+}

+ 2 - 0
main.js

@@ -16,11 +16,13 @@ app.$mount()
 // #ifdef VUE3
 import { createSSRApp } from 'vue'
 import * as Pinia from 'pinia';
+import setupDirective from '@/directive';
 
 export function createApp() {
   const app = createSSRApp(App)
   app.use(uvUI)
   app.use(Pinia.createPinia());
+  setupDirective(app);
   return {
     app,
     Pinia

+ 14 - 0
package-lock.json

@@ -13,6 +13,7 @@
         "axios": "^1.13.5",
         "crypto-js": "^4.2.0",
         "dayjs": "^1.11.19",
+        "dingtalk-jsapi": "^3.2.8",
         "lodash-es": "^4.17.23",
         "pinia": "^3.0.4",
         "sm-crypto": "^0.4.0",
@@ -1169,6 +1170,14 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/dingtalk-jsapi": {
+      "version": "3.2.8",
+      "resolved": "https://registry.npmmirror.com/dingtalk-jsapi/-/dingtalk-jsapi-3.2.8.tgz",
+      "integrity": "sha512-FAoTXIEwtlz0U3wpo+Y0VouBoRHPhS3SlRvKrsCL5NZgIihj6PL1xPzqLQEhzyVz7SoIA9rqAMo5UsRsj3uL8g==",
+      "dependencies": {
+        "promise-polyfill": "^7.1.0"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1620,6 +1629,11 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/promise-polyfill": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmmirror.com/promise-polyfill/-/promise-polyfill-7.1.2.tgz",
+      "integrity": "sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ=="
+    },
     "node_modules/proxy-from-env": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "axios": "^1.13.5",
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.19",
+    "dingtalk-jsapi": "^3.2.8",
     "lodash-es": "^4.17.23",
     "pinia": "^3.0.4",
     "sm-crypto": "^0.4.0",

+ 3 - 10
pages/fund/claim-records/detail.vue

@@ -19,7 +19,7 @@
           </CommonSection>
 
           <CommonSection title="入账经费">
-            <CommonInfoRow label="经费类型" :value="formatAmountType(form.amountType)" />
+            <CommonInfoRow label="经费类型" :value="getDictLabel('PaymentReceivedType', form.amountType)" />
             <CommonInfoRow label="入账金额" :value="amountUnitFormatter(form.amount) + '元'" :isAmount="true" />
             <CommonInfoRow label="入账时间" :value="formatDate(form.applyTime, 'YYYY-MM-DD')" />
             <CommonInfoRow label="拨款单位" :value="form.allotUnit" />
@@ -44,8 +44,10 @@ import { useClaimApi } from '@/api/fund/index';
 import { formatDate } from '@/utils/date';
 import CommonSection from '@/components/ui/CommonSection.vue';
 import CommonInfoRow from '@/components/ui/CommonInfoRow.vue';
+import { useDict } from '@/hooks/useDict';
 
 const claimApi = useClaimApi();
+const { getDictLabel } = useDict('PaymentReceivedType');
 
 const form = ref<any>({
   id: 0,
@@ -74,15 +76,6 @@ const getProjectTypeLabel = (type: string) => {
   return map[type] || '-';
 };
 
-const formatAmountType = (type: string) => {
-  const amountTypeOptions = [
-    { value: '10', label: '财政拨款' },
-    { value: '20', label: '匹配经费' },
-    { value: '30', label: '自筹经费' }
-  ];
-  const find = amountTypeOptions.find((item) => item.value == type);
-  return find ? find.label : '-';
-};
 
 const amountUnitFormatter = (val: any) => {
   if (val === null || val === undefined || val === '') return '0.00';

+ 3 - 0
pages/fund/claim/edit.vue

@@ -42,6 +42,7 @@
                     inputAlign="right" 
                     color="#ff4d4f"
                     customStyle="font-weight: 600; font-family: 'DIN-Medium', sans-serif;"
+                    :formatter="amountInputFormatter"
                   >
                     <template v-slot:suffix>
                       <text style="margin-left: 10rpx; font-size: 28rpx; color: #333; font-weight: normal;">元</text>
@@ -81,6 +82,7 @@ import { onRouterPush } from '@/utils/router';
 import SelectProject from '@/components/SelectProject/index.vue';
 import CommonSection from '@/components/ui/CommonSection.vue';
 import CommonInfoRow from '@/components/ui/CommonInfoRow.vue';
+import { amountInputFormatter } from '@/utils/format';
 
 const systemApi = useSystemApi();
 const fundApi = useFundApi();
@@ -130,6 +132,7 @@ const getDictLabel = (options: any[], value: string) => {
   return find ? find.dictLabel : value || '-';
 };
 
+
 const getDict = async () => {
   try {
     const resTypes: any = await systemApi.getDictDataByType('PaymentReceivedType');

+ 4 - 3
pages/fund/reimbursement/edit.vue

@@ -48,7 +48,7 @@
               <uv-input v-model="form.expend" placeholder="请输入出纳" border="none"/>
             </uv-form-item>
             <uv-form-item label="支出金额(元)" prop="amount">
-              <uv-input v-model="form.amount" type="digit" placeholder="请输入支出金额" border="none"/>
+              <uv-input v-model="form.amount" type="digit" :formatter="amountInputFormatter" placeholder="请输入支出金额" border="none"/>
             </uv-form-item>
           </view>
 
@@ -66,7 +66,7 @@
               <uv-input v-model="item.invoiceNo" placeholder="请输入发票号码" border="none"/>
             </uv-form-item>
             <uv-form-item label="发票金额(元)" required>
-              <uv-input v-model="item.amount" type="digit" placeholder="请输入金额" border="none"/>
+              <uv-input v-model="item.amount" type="digit" :formatter="amountInputFormatter" placeholder="请输入金额" border="none"/>
             </uv-form-item>
             <uv-form-item label="开票日期" required @click="openInvoiceByPicker(Number(index), item.invoiceBy)">
               <uv-input v-model="item.invoiceBy" disabled disabledColor="#ffffff" placeholder="请选择开票日期" suffixIcon="arrow-right" border="none"/>
@@ -110,7 +110,7 @@
               </view>
             </uv-form-item>
             <uv-form-item label="金额(元)" required>
-              <uv-input v-model="item.amount" type="digit" placeholder="请输入金额" border="none"/>
+              <uv-input v-model="item.amount" type="digit" :formatter="amountInputFormatter" placeholder="请输入金额" border="none"/>
             </uv-form-item>
           </view>
 
@@ -154,6 +154,7 @@ import { storeToRefs } from 'pinia';
 import { useExpenseRemindApi, useRebateApi, useExpenseApi, useFundCardApi } from '@/api/fund/index';
 import { useSystemApi } from '@/api/system/index';
 import { CACHE_KEY } from '@/constants/index';
+import { amountInputFormatter } from '@/utils/format';
 
 const userStore = useUserStore();
 const { userInfo } = storeToRefs(userStore);

+ 13 - 5
pages/fund/reimbursement/index.vue

@@ -27,12 +27,14 @@
           <view class="card-header">
             <text class="title">编号:{{ item.expenseNo || '-' }}</text>
             <text class="status-tag" :class="{
-              'status-undo': item.status === '10',
+              'status-undo': item.status === '05',
+              'status-ongoing': item.status === '10',
               'status-done': item.status === '20',
               'status-reject': item.status === '30'
             }">
-              <template v-if="item.status === '10'">待审核</template>
-              <template v-else-if="item.status === '20'">已审核</template>
+              <template v-if="item.status === '05'">待审核</template>
+              <template v-if="item.status === '10'">审核中</template>
+              <template v-else-if="item.status === '20'">已通过</template>
               <template v-else-if="item.status === '30'">已拒绝</template>
             </text>
           </view>
@@ -83,14 +85,15 @@ import FundTabbar from '@/components/FundTabbar.vue';
 const rebateApi = useRebateApi();
 
 const tabList = ref([
-  { name: '待审核', type: '10' },
+  { name: '待审核', type: '05' },
+  { name: '审核中', type: '10' },
   { name: '已通过', type: '20' },
   { name: '已拒绝', type: '30' }
 ]);
 const currentTab = ref(0);
 
 const queryParams = ref({
-  status: '10',
+  status: '05',
   projectName: '',
   expenseNo: '',
   pageNum: 1,
@@ -245,6 +248,11 @@ onLoad(() => {
         background-color: rgba(250, 173, 20, 0.1);
       }
 
+      &.status-ongoing {
+        color: #1c9bfd;
+        background-color: rgba(28, 155, 253, 0.1);
+      }
+
       &.status-reject {
         color: #f5222d;
         background-color: rgba(245, 34, 45, 0.1);

+ 92 - 70
pages/login/index.vue

@@ -2,68 +2,45 @@
   <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;"
-        >
+        <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;"
-        >
+        <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>
+            <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;"
-          >
+          <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>
@@ -71,39 +48,37 @@
           <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 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" 
+
+      <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;"
-      />
-      
+        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>
@@ -117,13 +92,15 @@ 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 { 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 } = userStore;
+const { checkCaptcha, login, dingTalkLogin } = userStore;
 
 const loginApi = useLoginApi();
 
@@ -137,10 +114,14 @@ const loginForm = reactive<LoginParams>({
 
 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,说明当前处于已登录状态
@@ -163,7 +144,7 @@ onMounted(async () => {
     loginForm.password = user.password;
     rememberMeArr.value = ['remember'];
   }
-  
+
   await checkCaptcha();
   if (configSetting.value.isCaptcha === '10') {
     getCaptchaImg();
@@ -189,14 +170,14 @@ const handleLogin = async () => {
     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);
@@ -204,7 +185,7 @@ const handleLogin = async () => {
 
     // 调用 store 处理真实请求和持久化
     await login(loginForm);
-    
+
     // 记住密码逻辑独立于全局token的持久化
     if (rememberMeArr.value.includes('remember')) {
       Local.set(CACHE_KEY.REMEMBER_USER, {
@@ -214,9 +195,9 @@ const handleLogin = async () => {
     } else {
       Local.remove(CACHE_KEY.REMEMBER_USER);
     }
-    
+
     toastRef.value.show({ message: '登录成功', type: 'success' });
-    
+
     // 登录成功跳转首页
     setTimeout(() => {
       uni.switchTab({ url: '/pages/home/index' }).catch((err) => {
@@ -224,7 +205,7 @@ const handleLogin = async () => {
         uni.reLaunch({ url: '/pages/home/index' });
       });
     }, 1000);
-    
+
   } catch (error) {
     console.error('Login Failed', error);
     // 登录失败若有验证码,刷新验证码
@@ -236,6 +217,46 @@ const handleLogin = async () => {
     loading.value = false;
   }
 };
+
+/**
+ * 钉钉免登逻辑
+ */
+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>
@@ -333,7 +354,8 @@ const handleLogin = async () => {
 
 .captcha-img {
   width: 200rpx;
-  height: 104rpx; /* 与左侧输入框 60(height) + 40(padding) + 4(border) 高度保持一致 */
+  height: 104rpx;
+  /* 与左侧输入框 60(height) + 40(padding) + 4(border) 高度保持一致 */
   border-radius: 20rpx;
   background: #f1f5f9;
   flex-shrink: 0;

+ 6 - 1
pages/project/components/HorizontalProject.vue

@@ -23,7 +23,7 @@
       >
         <view class="card-header">
           <text class="title">{{ item.projectName || item.name || '未命名' }}</text>
-          <text class="status-tag" :class="'status-' + item.projectStatus">{{ getStatusName(item.projectStatus) }}</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">
@@ -100,6 +100,11 @@ const getStatusName = (code: string) => {
   return item ? item.dictLabel : code || '未知状态';
 };
 
+const getStatusType = (code: string) => {
+  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  return item ? item.type : 'info';
+};
+
 const fetchData = async (reset = false) => {
   loadStatus.value = 'loading';
   

+ 6 - 1
pages/project/components/SpontaneityProject.vue

@@ -23,7 +23,7 @@
       >
         <view class="card-header">
           <text class="title">{{ item.projectName || item.name || '未命名' }}</text>
-          <text class="status-tag" :class="'status-' + item.projectStatus">{{ getStatusName(item.projectStatus) }}</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">
@@ -100,6 +100,11 @@ const getStatusName = (code: string) => {
   return item ? item.dictLabel : code || '未知状态';
 };
 
+const getStatusType = (code: string) => {
+  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  return item ? item.type : 'info';
+};
+
 const fetchData = async (reset = false) => {
   loadStatus.value = 'loading';
   

+ 6 - 1
pages/project/components/VerticalProject.vue

@@ -23,7 +23,7 @@
       >
         <view class="card-header">
           <text class="title">{{ item.projectName || item.name || '未命名' }}</text>
-          <text class="status-tag" :class="'status-' + item.projectStatus">{{ getStatusName(item.projectStatus) }}</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">
@@ -101,6 +101,11 @@ const getStatusName = (code: string) => {
   return item ? item.dictLabel : code || '未知状态';
 };
 
+const getStatusType = (code: string) => {
+  const item = projectStatusOptions.find(opt => opt.dictValue === code);
+  return item ? item.type : 'info';
+};
+
 const fetchData = async (reset = false) => {
   loadStatus.value = 'loading';
   

+ 11 - 13
pages/project/components/detail/ChangeDetailPopup.vue

@@ -10,7 +10,7 @@
 
         <uv-tabs :list="tabList" :current="currentTab" @click="handleTabClick" lineColor="#1c9bfd" activeColor="#1c9bfd" inactiveColor="#666"></uv-tabs>
 
-        <view class="tab-pane" v-show="currentTab === 0">
+        <view class="tab-pane" v-show="tabList[currentTab]?.name === '基本信息'">
           <!-- 基本信息 -->
           <CommonSection title="基本信息">
             <CommonInfoRow label="变更类型" :value="getDictLabel(changeTypeOptions, projectStore.changeDetailData.changeType)" />
@@ -56,7 +56,7 @@
           </CommonSection>
         </view>
 
-        <view class="tab-pane" v-show="currentTab === 1">
+        <view class="tab-pane" v-show="tabList[currentTab]?.name === '变更前信息'">
           <!-- 变更前信息 -->
           <CommonSection title="变更前信息" v-if="projectStore.changeDetailData.changeType != '50'">
             <!-- 10: 成员变更 -->
@@ -80,8 +80,8 @@
 
             <!-- 30: 延期变更 -->
             <template v-if="projectStore.changeDetailData.changeType == '30'">
-              <CommonInfoRow label="开始日期" :value="projectStore.changePreData && projectStore.changePreData[0] ? projectStore.changePreData[0] : '--'" />
-              <CommonInfoRow label="结束日期" :value="projectStore.changePreData && projectStore.changePreData[1] ? projectStore.changePreData[1] : '--'" noBorder />
+              <CommonInfoRow label="开始日期" :value="projectStore.changePreData && projectStore.changePreData[0] ? dayjs(projectStore.changePreData[0]).format('YYYY-MM-DD') : '--'" />
+              <CommonInfoRow label="结束日期" :value="projectStore.changePreData && projectStore.changePreData[1] ? dayjs(projectStore.changePreData[1]).format('YYYY-MM-DD') : '--'" noBorder />
             </template>
 
             <!-- 40: 经费变更 -->
@@ -113,7 +113,7 @@
           <uv-empty v-else mode="data" text="此变更类型无变更前信息"></uv-empty>
         </view>
 
-        <view class="tab-pane" v-show="currentTab === 2">
+        <view class="tab-pane" v-show="tabList[currentTab]?.name === '变更后信息'">
           <!-- 变更后信息 -->
           <CommonSection title="变更后信息" v-if="projectStore.changeDetailData.changeType != '50'">
             <!-- 10: 成员变更 -->
@@ -137,8 +137,8 @@
 
             <!-- 30: 延期变更 -->
             <template v-if="projectStore.changeDetailData.changeType == '30'">
-              <CommonInfoRow label="开始日期" :value="projectStore.changeAfterData && projectStore.changeAfterData[0] ? projectStore.changeAfterData[0] : '--'" />
-              <CommonInfoRow label="结束日期" :value="projectStore.changeAfterData && projectStore.changeAfterData[1] ? projectStore.changeAfterData[1] : '--'" noBorder />
+              <CommonInfoRow label="开始日期" :value="projectStore.changeAfterData && projectStore.changeAfterData[0] ? dayjs(projectStore.changeAfterData[0]).format('YYYY-MM-DD') : '--'" />
+              <CommonInfoRow label="结束日期" :value="projectStore.changeAfterData && projectStore.changeAfterData[1] ? dayjs(projectStore.changeAfterData[1]).format('YYYY-MM-DD') : '--'" noBorder />
             </template>
 
             <!-- 40: 经费变更 -->
@@ -170,13 +170,12 @@
           <uv-empty v-else mode="data" text="此变更类型无变更后信息"></uv-empty>
         </view>
 
-        <view class="tab-pane" v-show="currentTab === 3">
+        <view class="tab-pane" v-show="tabList[currentTab]?.name === '审批信息'">
           <!-- 审批信息 -->
-          <CommonSection title="审批信息" v-if="projectStore.changeDetailData.approvalStatus > 10">
+          <CommonSection title="审批信息">
             <FlowTable :id="projectStore.changeDetailData.id" :businessCode="String(projectStore.changeDetailData.id)"
               defCode="sci_project_change" />
           </CommonSection>
-          <uv-empty v-else mode="data" text="暂无审批信息"></uv-empty>
         </view>
 
       </scroll-view>
@@ -204,7 +203,7 @@ const projectStore = useProjectStore();
 const popupRef = ref<any>(null);
 
 // Pull globally dynamic dictionaries
-const sysDict = useDict('sci_pjt_level', 'sci_pjt_type');
+const sysDict = useDict('sci_pjt_level', 'sci_pjt_type', 'sci_academic_degree');
 
 /** Tab 页标题配置 */
 const tabList = computed(() => {
@@ -259,8 +258,7 @@ const getDictLabel = (options: any[], value: string | number) => {
 };
 
 const getDegreeName = (val: string) => {
-  const map: Record<string, string> = { '10': '无', '20': '研究生', '30': '博士生' };
-  return map[val] || val || '--';
+  return sysDict.getDictLabel('sci_academic_degree', val) || '--';
 };
 
 const getMemberTypeName = (val: string) => {

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

@@ -8,8 +8,6 @@
         :value="projectData?.isClinicalTrial === '10' ? '临床试验' : (projectData?.isClinicalTrial === '20' ? '横向其他' : (projectData?.isClinicalTrial === '1010' ? 'GCP' : (projectData?.isClinicalTrial === '1020' ? '非GCP' : '--')))" />
       <CommonInfoRow label="项目来源" :value="projectData?.projectSource" />
 
-      <CommonInfoRow v-if="projectData?.isClinicalTrial === '10'" label="项目级别"
-        :value="getDictLabel('sci_proj_type', projectData?.projectClass)" />
       <CommonInfoRow v-if="projectData?.isClinicalTrial === '20'" label="项目类别"
         :value="getDictLabel('sci_pjt_class', projectData?.projectClass)" />
 

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

@@ -4,7 +4,6 @@
       <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)" />
@@ -42,7 +41,7 @@ const props = defineProps<{
   projectData: any;
 }>();
 
-const { getDictLabel } = useDict('sci_pjt_level', 'sci_pjt_type');
+const { getDictLabel } = useDict('sci_pjt_type');
 
 const amountUnitFormatter = (num: any) => {
   return formatWithComma(num);

+ 5 - 11
pages/project/index.vue

@@ -19,12 +19,12 @@
 
         <!-- 我负责/我参与 切换 -->
         <view class="role-tabs">
-          <view class="role-tab" :class="{ active: queryParams.operatorType === '30' }"
-            @click="queryParams.operatorType = '30'">全部</view>
           <view class="role-tab" :class="{ active: queryParams.operatorType === '10' }"
             @click="queryParams.operatorType = '10'">我负责的</view>
           <view class="role-tab" :class="{ active: queryParams.operatorType === '20' }"
             @click="queryParams.operatorType = '20'">我参与的</view>
+          <view class="role-tab" :class="{ active: queryParams.operatorType === '30' }"
+            @click="queryParams.operatorType = '30'">全部</view>
         </view>
       </view>
 
@@ -45,12 +45,7 @@
     </view>
 
     <!-- 状态拾取器 -->
-    <uv-picker
-      ref="statusPicker"
-      :columns="statusColumns"
-      keyName="dictLabel"
-      @confirm="onStatusConfirm"
-    ></uv-picker>
+    <uv-picker ref="statusPicker" :columns="statusColumns" keyName="dictLabel" @confirm="onStatusConfirm"></uv-picker>
 
   </view>
 </template>
@@ -58,7 +53,6 @@
 <script setup lang="ts">
 import { ref, computed } from 'vue';
 import { onLoad } from '@dcloudio/uni-app';
-
 import VerticalProject from './components/VerticalProject.vue';
 import HorizontalProject from './components/HorizontalProject.vue';
 import SpontaneityProject from './components/SpontaneityProject.vue';
@@ -67,8 +61,8 @@ import { projectStatusOptions } from '@/constants/index';
 // 查询条件
 const queryParams = ref({
   projectName: '',
-  projectStatus: '', // 空表示全部
-  operatorType: '30' // 30表示全部
+  projectStatus: '',
+  operatorType: '10'
 });
 
 const currentTab = ref(0);

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

@@ -110,6 +110,9 @@
       <!-- 其他成果 -->
       <SciAchievementOther v-else-if="defCode === 'sci_achievement_other'" :code="taskCode" />
       
+      <!-- 预申报 -->
+      <DeclarationListForm v-else-if="defCode === 'sci_project_declaration_list'" :code="taskCode" />
+      
       <!-- 软件著作权 -->
       <SciAchievementSoftware v-else-if="defCode === 'sci_achievement_software'" :code="taskCode" />
 
@@ -163,6 +166,7 @@ import SciAchievementStandard from './document/SciAchievementStandard.vue';
 import SciAchievementPaperPushClaim from './document/SciAchievementPaperPushClaim.vue';
 import SciAchievementOther from './document/SciAchievementOther.vue';
 import SciAchievementSoftware from './document/SciAchievementSoftware.vue';
+import DeclarationListForm from './document/DeclarationListForm.vue';
 
 const props = defineProps<{
   defCode: string; // 对应 PC 端的 state.taskDetail.defCode

+ 102 - 0
pages/todo/components/document/DeclarationListForm.vue

@@ -0,0 +1,102 @@
+<template>
+  <view class="document-form">
+    <uv-loading-icon v-if="state.loading" mode="circle" text="正在加载预申报详情..."></uv-loading-icon>
+    <template v-else-if="state.form">
+      <!-- 基本信息 -->
+      <view class="common-section-card">
+        <view class="section-title">基本信息</view>
+        <view class="info-row"><text class="label">项目名称</text><text class="value">{{ state.form.projectName }}</text></view>
+        <view class="info-row">
+          <text class="label">项目级别</text>
+          <text class="value">{{ getDictLabel('sci_pjt_level', state.form.projectLevel) }}</text>
+        </view>
+        <view class="info-row"><text class="label">项目来源</text><text class="value">{{ state.form.projectSource || '-' }}</text></view>
+        <view class="info-row">
+          <text class="label">计划日期</text>
+          <text class="value">{{ formatDate(state.form.planStartDate) }} 至 {{ formatDate(state.form.planEndDate) }}</text>
+        </view>
+        <view class="info-row">
+          <text class="label">研究类型</text>
+          <text class="value">{{ getDictLabel('sci_pjt_type', state.form.studyType) }}</text>
+        </view>
+        <view class="info-row"><text class="label">申请人</text><text class="value">{{ state.form.applyName || '-' }}</text></view>
+        <view class="info-row"><text class="label">申报单位</text><text class="value">{{ state.form.applyUnit || '-' }}</text></view>
+        <view class="info-row"><text class="label">所属科室</text><text class="value">{{ state.form.deptName || '-' }}</text></view>
+        <view class="info-row"><text class="label">项目负责人</text><text class="value">{{ state.form.projectLeaderName || '-' }}</text></view>
+        <view class="info-row"><text class="label">负责人电话</text><text class="value">{{ state.form.projectLeaderPhone || '-' }}</text></view>
+        <view class="info-row"><text class="label">负责人邮箱</text><text class="value">{{ state.form.projectLeaderMail || '-' }}</text></view>
+      </view>
+
+      <!-- 附件信息 -->
+      <view class="common-section-card mt20" v-if="state.form.fileList?.length">
+        <view class="section-title">附件资料</view>
+        <view class="common-file-list">
+          <view class="file-item" v-for="(file, index) in state.form.fileList" :key="index" @click="previewFile(file.fileUrl, file.fileName)">
+            <view class="file-info">
+              <text class="f-name">{{ file.fileName }}</text>
+              <view class="f-meta">
+                <text class="f-type">{{ file.fileType || '附件' }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </template>
+    <uv-empty v-else mode="data" text="暂无数据"></uv-empty>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { reactive, onMounted, watch, nextTick } from 'vue';
+import { useDict } from '@/hooks/useDict';
+import { useDocumentApi } from '@/api/document';
+import { formatDate } from '@/utils/date';
+import { previewFile } from '@/utils/file';
+import to from 'await-to-js';
+
+const props = defineProps<{
+  code: string;
+}>();
+
+const { getDictLabel } = useDict('sci_pjt_level', 'sci_pjt_type');
+const documentApi = useDocumentApi();
+
+const state = reactive({
+  form: null as any,
+  loading: false
+});
+
+const initForm = async (code: string) => {
+  if (!code) return;
+  state.loading = true;
+  const [err, res] = await to(documentApi.getDeclarationById(code));
+  if (!err && res?.data) {
+    await nextTick();
+    const data = res.data.entity || res.data.data || res.data;
+    state.form = data;
+    
+    // 兼容附件处理
+    if (!state.form.fileList && state.form.weedFile) {
+        state.form.fileList = [{
+            fileType: '申报书',
+            fileName: state.form.fileName || '附件',
+            fileUrl: state.form.weedFile,
+        }];
+    }
+    state.form.fileList = state.form.fileList || [];
+  }
+  state.loading = false;
+};
+
+watch(() => props.code, (val) => {
+  initForm(val);
+}, { immediate: true });
+
+defineExpose({
+  initForm
+});
+</script>
+
+<style lang="scss" scoped>
+@import "./common.scss";
+</style>

+ 0 - 4
pages/todo/components/document/SpontaneityFormPlan.vue

@@ -9,10 +9,6 @@
           <text class="label">项目名称</text>
           <text class="value">{{ form.projectName || '-' }}</text>
         </view>
-        <view class="info-row">
-          <text class="label">项目级别</text>
-          <text class="value">{{ getDictLabel('sci_pjt_level', form.projectLevel) }}</text>
-        </view>
         <view class="info-row">
           <text class="label">项目来源</text>
           <text class="value">{{ form.projectSource || '-' }}</text>

+ 26 - 0
store/modules/user.ts

@@ -101,6 +101,31 @@ export const useUserStore = defineStore('user', () => {
     return res;
   }
 
+  /**
+   * 钉钉免登
+   */
+  async function dingTalkLogin(code: string) {
+    setRequestLoading('loginLoading', true);
+    
+    const [err, res] = await to(loginApi.dingTalkLogin({ code }));
+    
+    setRequestLoading('loginLoading', 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;
+  }
+
   /**
    * 获取用户信息
    */
@@ -157,6 +182,7 @@ export const useUserStore = defineStore('user', () => {
     roles,
     configSetting,
     login,
+    dingTalkLogin,
     fetchUserInfo,
     logout,
     clearUserStatus,

+ 96 - 24
styles/business.scss

@@ -7,7 +7,7 @@
   box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
   position: relative;
   overflow: hidden;
-  
+
   &::before {
     content: '';
     position: absolute;
@@ -17,7 +17,7 @@
     height: 100%;
     background-color: #1c9bfd;
   }
-  
+
   .card-header {
     display: flex;
     justify-content: space-between;
@@ -25,7 +25,7 @@
     margin-bottom: 24rpx;
     padding-bottom: 20rpx;
     border-bottom: 2rpx dashed #eee;
-    
+
     .title {
       font-size: 32rpx;
       font-weight: bold;
@@ -41,15 +41,46 @@
       line-clamp: 2;
       white-space: normal;
     }
-    
+
     .status-tag {
       font-size: 24rpx;
       padding: 6rpx 16rpx;
       border-radius: 8rpx;
       white-space: nowrap;
+
+      &.status-05 {
+        color: #8c8c8c;
+        background-color: rgba(140, 140, 140, 0.1);
+      }
+
+      // 待立项
+      &.status-10 {
+        color: #1c9bfd;
+        background-color: rgba(28, 155, 253, 0.1);
+      }
+
+      // 立项
+      &.status-20 {
+        color: #52c41a;
+        background-color: rgba(82, 196, 26, 0.1);
+      }
+
+      // 在研
+      &.status-30 {
+        color: #faad14;
+        background-color: rgba(250, 173, 20, 0.1);
+      }
+
+      // 结题验收
+      &.status-40 {
+        color: #f5222d;
+        background-color: rgba(245, 34, 45, 0.1);
+      }
+
+      // 中止
     }
   }
-  
+
   .card-body {
     .info-item {
       display: flex;
@@ -58,11 +89,29 @@
       font-size: 28rpx;
       line-height: 1.5;
 
-      &:last-child { border-bottom: none; }
-      
-      .label { color: #343A3F; width: 180rpx; flex-shrink: 0; }
-      .value { color: #585858; flex: 1; text-align: right; word-break: break-all; }
-      .amount { color: #ff4d4f; font-weight: bold; font-family: din; font-size: 32rpx; }
+      &:last-child {
+        border-bottom: none;
+      }
+
+      .label {
+        color: #343A3F;
+        width: 180rpx;
+        flex-shrink: 0;
+      }
+
+      .value {
+        color: #585858;
+        flex: 1;
+        text-align: right;
+        word-break: break-all;
+      }
+
+      .amount {
+        color: #ff4d4f;
+        font-weight: bold;
+        font-family: din;
+        font-size: 32rpx;
+      }
     }
   }
 
@@ -90,6 +139,7 @@
     margin-bottom: 24rpx;
     padding-left: 20rpx;
     position: relative;
+
     &::before {
       content: '';
       position: absolute;
@@ -109,17 +159,39 @@
     padding: 16rpx 0;
     border-bottom: 1rpx solid #f8f8f8;
     font-size: 28rpx;
-    &:last-child { border-bottom: none; }
-    .label { color: #8c8c8c; width: 180rpx; flex-shrink: 0; }
-    .value { color: #262626; flex: 1; text-align: right; word-break: break-all; }
-    
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .label {
+      color: #8c8c8c;
+      width: 180rpx;
+      flex-shrink: 0;
+    }
+
+    .value {
+      color: #262626;
+      flex: 1;
+      text-align: right;
+      word-break: break-all;
+    }
+
     &.column {
       flex-direction: column;
       align-items: flex-start;
-      .label { margin-bottom: 12rpx; color: #909399; }
-      .value { text-align: left !important; width: 100%; }
+
+      .label {
+        margin-bottom: 12rpx;
+        color: #909399;
+      }
+
+      .value {
+        text-align: left !important;
+        width: 100%;
+      }
     }
-    
+
     .remark {
       background-color: #f8fafc;
       padding: 20rpx;
@@ -141,16 +213,16 @@
     align-items: center;
     padding: 24rpx 0;
     border-bottom: 2rpx dashed #f5f5f5;
-    
+
     &:last-child {
       border-bottom: none;
     }
-    
+
     .file-info {
       flex: 1;
       display: flex;
       flex-direction: column;
-      
+
       .f-name {
         font-size: 28rpx;
         color: #343A3F;
@@ -158,24 +230,24 @@
         margin-bottom: 12rpx;
         word-break: break-all;
       }
-      
+
       .f-meta {
         display: flex;
         justify-content: space-between;
         align-items: center;
         font-size: 24rpx;
-        
+
         .f-type {
           color: #1677ff;
           background-color: #e6f4ff;
           padding: 4rpx 12rpx;
           border-radius: 6rpx;
         }
-        
+
         .f-date {
           color: #585858;
         }
       }
     }
   }
-}
+}

+ 28 - 0
utils/dingtalk.ts

@@ -0,0 +1,28 @@
+import * as dd from 'dingtalk-jsapi';
+
+/**
+ * 获取钉钉免登授权码
+ * @param {string} corpId 企业的corpId (可选,如果不传则尝试从环境或URL获取)
+ * @param {string} clientId 应用的clientId (可选,如果不传则从环境获取)
+ */
+export function getDingTalkAuthCode(corpId?: string, clientId?: string) {
+  return new Promise<string>((resolve, reject) => {
+    // 钉钉环境判断
+    if (dd.env.platform === 'notInDingTalk') {
+      reject(new Error('请在钉钉客户端打开'));
+      return;
+    }
+
+    // @ts-ignore 新版写法,可能类型定义未及时更新
+    dd.requestAuthCode({
+      corpId: corpId || '',
+      clientId: clientId || import.meta.env.VITE_DINGTALK_CLIENTID || '',
+      onSuccess: (result: { code: string }) => {
+        resolve(result.code);
+      },
+      onFail: (err: any) => {
+        reject(err);
+      }
+    });
+  });
+}

+ 25 - 0
utils/format.ts

@@ -28,3 +28,28 @@ export const formatWithComma = (val: any, digit: number = 2) => {
   };
   return num.toLocaleString('en-US', options);
 };
+
+/**
+ * 限制输入正数且最多两位小数的格式化工具
+ * 常用于 uv-input 的 formatter 属性
+ * @param val 输入字符串
+ * @returns 格式化后的字符串
+ */
+export const amountInputFormatter = (val: string) => {
+  if (!val) return '';
+  // 1. 仅保留数字和小数点
+  let formatted = val.replace(/[^\d.]/g, '');
+  // 2. 保证只有一个小数点
+  const dotIndex = formatted.indexOf('.');
+  if (dotIndex !== -1) {
+    const mainPart = formatted.substring(0, dotIndex);
+    let decimalPart = formatted.substring(dotIndex + 1).replace(/\./g, ''); // 移除多余小数点
+    // 3. 限制两位小数
+    if (decimalPart.length > 2) {
+      decimalPart = decimalPart.substring(0, 2);
+    }
+    formatted = `${mainPart}.${decimalPart}`;
+  }
+  return formatted;
+};
+