feat(auth): 更新门户认证页面布局和样式
- 替换AuthPageLayout为自定义门户认证布局
- 添加门户品牌标识和标语展示
- 集成审批协同、课题管理、信息互通等功能介绍
- 实现响应式设计适配不同屏幕尺寸
- 添加浏览器兼容性提示
feat(router): 添加个人中心路由和个人资料管理功能
- 新增Profile路由支持个人中心访问
- 实现个人资料编辑功能包括显示名称、手机号码、电子邮箱
- 添加密码设置和个人账号绑定功能
- 集成社交账号绑定管理界面
- 实现个人资料的保存和更新逻辑
feat(layout): 优化基本布局结构和门户主题样式
- 更新头部导航栏布局和样式
- 添加门户品牌文字组件
- 集成个人中心入口按钮
- 优化全局搜索组件外观
- 添加门户主题CSS文件支持
feat(company): 更新公司配置管理和门户名称显示
- 修改江苏商贸公司名称为江苏省供销合作经济学会融合门户
- 实现公司名称显示逻辑根据环境变量调整
- 添加门户名称固定功能防止修改
- 更新公司配置保存和应用逻辑
feat(menu): 添加门户系统菜单支持和权限控制
- 实现portalSystemMenus模块化菜单处理
- 集成角色权限验证机制
- 添加门户系统特定菜单项
- 优化菜单转换和渲染逻辑
| | |
| | | apiUrl: 'http://36.213.150.237:9006/admin-api', |
| | | }, |
| | | JSSM: { |
| | | name: 'æ±èåè´¸', |
| | | logo: '/logos/JSSM.svg', |
| | | favicon: '/logos/JSSM.svg', |
| | | name: 'æ±èçä¾éåä½ç»æµå¦ä¼èå鍿·', |
| | | logo: '/logos/JSSM-portal.svg', |
| | | favicon: '/logos/JSSM-portal.svg', |
| | | apiUrl: '', |
| | | }, |
| | | }; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | --- |
| | | name: æ±èçä¾éåä½ç»æµå¦ä¼èå鍿· |
| | | description: åè徿¹åä¸ç鍿·ã认è¯ä¸ä¸ªäººä¸å¿å®ç°è®°å½ |
| | | colors: |
| | | portal-blue: "#1b4f80" |
| | | portal-red: "#b7242d" |
| | | portal-ink: "#18365a" |
| | | portal-muted: "#5c7187" |
| | | portal-surface: "#fff" |
| | | footer: "#204d79" |
| | | rounded: |
| | | field: "6px" |
| | | card: "6px" |
| | | identity-card: "7px" |
| | | service-strip: "8px" |
| | | authentication-panel: "12px" |
| | | spacing: |
| | | content: "16px" |
| | | section: "18px" |
| | | panel-gap: "20px" |
| | | --- |
| | | |
| | | # Design System: æ±èçä¾éåä½ç»æµå¦ä¼èå鍿· |
| | | |
| | | ## Overview |
| | | |
| | | æ¬è¯´æè®°å½ç°æå®ç°ï¼è§è§ä¾æ®ä¸ºç¨æ·æå®çæºæé¨æ·åèãä¸å¡é¦é¡µéç¨ä¸å±å¤´é¨ãå
¨å¹
乡æåå¸çº¢ç»¸ä¸»å¾ã红è²å¿«æ·æå¡ãæéæ ç®åèè²é¡µèï¼ä¸ªäººä¸å¿æ²¿ç¨åä¸ä¸å¡å¤å£³ã认è¯é¡µä¿çç½è²æºæé¡µå¤´ãæ¯è§ä»ç»åºä¸è´¦å·è¡¨åãé»è®¤æµ
è²ï¼é¨åæè²è¦çä¸è½è§ä¸ºå®æ´æè²éªæ¶ã |
| | | |
| | | æºæåç§°ç± `preferences.copyright.companyName` æä¾ï¼JSSM 端åºå®ä¸ºâæ±èçä¾éåä½ç»æµå¦ä¼èå鍿·âï¼ç¼è¯é
ç½®ãå端å±ç¤ºå±ä¸é¡µæ é¢ä¿æä¸è´ãLogo 使ç¨é
ç½®æ¥æºï¼å½å鍿·ææ¬å°ç»¿è²æ è®° `public/logos/JSSM-portal.svg`ãç»å½ä»ç»ä½¿ç¨å
¬å¸é
ç½®åæºç ç¼ºçææ¡ã䏿§é»è®¤å¤´å为 `public/static/imgs/portal-avatar.svg`ãè¿äºèµäº§ä¸ä»£è¡¨å·²åå¾å
¶ä»æºæç身份ææã |
| | | |
| | | ## Colors |
| | | |
| | | åç½®è²å¼æ¥èªç°æä¸»é¢ä¸é¡µèãèè²æ¿æ
龿¥ãä¾§æ éæ©åç»æå±æ¬¡ï¼æ·±èæ¿æ
ä¸»è¦æåï¼ç°èæ¿æ
è¾
å©ä¿¡æ¯ï¼ç½è²æ¿æ
å
容表é¢ãçº¢è²æ¿æ
æ¡é¢ä¸çº§å¯¼èªéä¸ãå¿«æ·æå¡æ è¯åä¸»è¦æä½ï¼èè²é¡µèåæ´æ·±ççææ¡æ¶æé¿é¡µé¢ãå±é¨ç»¿è²æ è®°ååè½å¾æ 䏿©å±ä¸ºæ°å
¨å±åçè²ã |
| | | |
| | | ## Typography |
| | | |
| | | æ£æä¸è¡¨æ ¼ç»§æ¿é¡¹ç®åºç¡å使 ãé¦é¡µä¸»æ é¢ä½¿ç¨ç³»ç»æ¥·ä½/å®ä½åéï¼KaiTiãSTKaitiãNoto Serif SCãserifï¼ï¼å°ºå¯¸é宽度ååï¼32â54pxã800ï¼ï¼ææºä¸ºï¼32pxï¼ï¼æçªå±ä¸ºï¼29pxï¼ã头鍿ºæå为ï¼25pxã750ï¼ï¼ä¸ç宽度为ï¼21pxï¼ï¼ææºä¸ºï¼16pxï¼ãæ ç®æ é¢ä¸ºï¼17pxã700ï¼ï¼æ£æä¸è¾
å©ä¿¡æ¯ä¸»è¦ä¸ºï¼11â14pxï¼ã个人ä¸å¿é¡µæ é¢ä¸ºï¼29pxã700ï¼ï¼ææºä¸ºï¼25pxï¼ãåè·å¼ºè°ç¨äºåç䏿¨ªå¹
ï¼ä¸åºç¨äºèµæè¡¨åæ£æã |
| | | |
| | | ## Layout |
| | | |
| | | ä¸å¡æ¨¡å¼éç¨ `mixed-nav`ï¼æ¡é¢å¤´é¨ä¸ºå·¥å
·å±ï¼32pxï¼ãåçä¸æç´¢å±ï¼80pxï¼ãä¸çº§å¯¼èªå±ï¼46pxï¼ï¼å
±ï¼158pxï¼ï¼ææºåå½ï¼72pxï¼å¤´é¨ä¸æ½å±å¯¼èªãä¸ç宽度ï¼768â1100pxï¼éèåçéå æåãæ¶çªæç´¢ä¸åçåºï¼ä¿çæµç§°åæä½å¯è¯»æ§ãæ¡é¢å¯¼èªè¶
åºå¯ç¨å®½åº¦æ¶çº³å°âæ´å¤âãä¾§æ é»è®¤å±å¼å®½åº¦ï¼228pxï¼ï¼æ ç¾æ é«ï¼40pxï¼ï¼æ ç¾æ æå®é
ä¾§æ å ç¨å®½åº¦å¯¹é½ï¼æ ç¾åå²ä¸å
¨é¨æä¹
åã |
| | | |
| | | é¦é¡µæ¨ªå¹
å
¨å¹
ï¼æ¡é¢æå°é«ï¼350pxï¼ï¼ææºä¸ºï¼280pxï¼ï¼æ£æå®¹å¨æå¤§ï¼1280pxï¼ï¼æ¡é¢å·¦å³åçï¼40pxï¼ï¼ï¼1050pxï¼ä»¥ä¸åçï¼24pxï¼ï¼ï¼767pxï¼ä»¥ä¸åçï¼14pxï¼ãæ°é»åºå为宽çªååï¼æ ç®ç½æ ¼æå°å宽ï¼270pxï¼ãææºå°æ°é»ä¸æå¡åºæ¹ä¸ºååãä¸é¢æ¹ä¸ºååï¼å¿«æ·å
¥å£æ¹ä¸ºä¸åï¼çº¢è²æ ç¾ä»å·¦ä¾§ç§»è³é¡¶é¨ãé¦é¡µä¸ç¨èè²é¡µèä¸çåäºæ¡æ¶é»è®¤åºå®é¡µèã |
| | | |
| | | 个人ä¸å¿æå¤§å®½åº¦ï¼1240pxï¼ï¼å·¦èº«ä»½å¡ï¼282pxï¼ãå³è®¾ç½®åºèªéåºï¼é´éï¼20pxï¼ï¼ï¼1000pxï¼ä»¥ä¸å·¦åï¼240pxï¼ãé´éï¼14pxï¼ï¼ï¼767pxï¼ä»¥ä¸ä¸ä¸å å ãè®¤è¯æ¨¡å¼ä»ä¸ºæ¡é¢ä»ç»ä¸è¡¨åååï¼ï¼850pxï¼ä»¥ä¸ååï¼ï¼420pxï¼ä»¥ä¸ç¼©å表åå
è·ä¸Logoã |
| | | |
| | | `src/main.ts` éè¿å½åå好å½å空é´ç `portal-appearance-v2` æ è®°æ§è¡ä¸æ¬¡å¤è§è¿ç§»ï¼ä¿çç»å½åå¨ï¼éåå
è®¸ç¨æ·ç»§ç»è°æ´å¤è§ã |
| | | |
| | | ## Elevation & Depth |
| | | |
| | | ç½è²è¡¨é¢ãæµ
èç°è¾¹æ¡åè½»é´å½±ææä¸å¡å±çº§ãæ ç®ä½¿ç¨ `0 3px 10px rgb(24 66 106 / 4%)`ï¼ä¸ªäººä¸å¿ä¸ºç¸åæ¹åçæ´æ·¡é´å½±ï¼3%ï¼ã认è¯è¡¨åä¿çæ´ææ¾ç `0 18px 56px rgb(24 62 98 / 15%)`ï¼ä¸æ¨å¹¿ä¸ºä¸å¡å表é´å½±ã |
| | | |
| | | ## Shapes |
| | | |
| | | æ ç®ä¸è¾å
¥ä»¥å°åè§ç»ç»ï¼èº«ä»½å¡ãå¿«æ·æ¡ã认è¯é¢æ¿çåè§è§å置令çãä¸çº§å¯¼èªä¸ºæ´é½æ¹å½¢è²åï¼å¿«æ·åºæ¡é¢çº¢è²æ ç¾å¸¦ç®å¤´å½¢è£åãææºæ¹ç©å½¢ï¼åè½å¾æ 使ç¨åå½¢åºãå表éç¨æ°´å¹³åéï¼é¿å
é¢å¤è£
饰线干æ°èµæç¼è¾ã |
| | | |
| | | ## Components |
| | | |
| | | - **头é¨**ï¼å¯è¯»å¤´åæµç§°ä¸æã个人ä¸å¿ç´è¾¾ãå¯ç 设置快æ·èåä¸å
¨å±èåæç´¢ãæ¡é¢ä¸çº§èåéä¸çº¢åºç½åï¼ä¾§æ éä¸èåºç½åï¼âæ´å¤âä¿çå±å¼ãå
³éåç¦ç¹äº¤äºãä¸ç宽度ä¼å
å缩åçï¼ä¸æ¤æç¨æ·å
¥å£ã |
| | | - **é¦é¡µ**ï¼`usePortalMenus` ä»è´¦å·æéèåæå»ºå¯è§æ ç®ä¸å¶å
¥å£ï¼æé¤éèãç¦ç¨å空ç®å½ï¼æç°æè·¯å¾ååç§°è¯å«è¯¾é¢ãå¾
åãæ¥éãå
¬åçåè½ãç¸å
³é¢æ¿è°ç¨çå®ä¸å¡ APIï¼ç¼ºå°æéæ¶ä¸åé¢è§æ ·æ¬æ·»å ç产模åãä¿çå è½½ã空æã失败åéç¨çéè¯ç¶æã |
| | | - **个人ä¸å¿**ï¼å·¦ä¾§å¤´åã身份åè系信æ¯ï¼å³ä¾§åºæ¬èµæãå¯ç 设置ãè´¦å·ç»å®ä¸ä¸ªé¡µç¾ï¼ææºå å ãæ²¿ç¨åæ¥å£ï¼å¤´åä¸ä¼ 读åè¿å对象ç `url`ï¼ç©ºé®ç®±å
许ä¸å¡«ï¼å¯ç ä¿®æ¹æå忏
空表åãç»å®åè°ä¿ç hash è·¯ç±ï¼ä½¿ç¨ `router.replace` æ¸
ç `code/state/type`ï¼çé¢ä¸å±ç¤º openid æ tokenãProfile æ¯éè core è·¯ç±ï¼ä½ä¸èµ° core è·¯ç±å
认è¯åæ¯ï¼ä»æ ¡éª tokenã |
| | | - **ç»å½**ï¼ä¿çè´¦å·å¯ç æ ç¾ãéªè¯ç å¼å
³ä¸æ¢ææäº¤é»è¾ï¼è¾å
¥åæäº¤æé®é«ï¼46pxï¼ï¼ææç¡®èç¦ä¸æäº¤ä¸ç¶æã |
| | | - **å¾å**ï¼`public/static/imgs/portal-hero-v2.png` ä¸ºæ æåç乡æãåå¸ä¸çº¢ç»¸ä¸»å¾ï¼`portal-modules-v2.png` æ¯ä¹¦ç±ãåå
¬ãè´¢å¡ãæ¡£æ¡å象éå¾éï¼ä¿çç `portal-landscape-v1.png` ç¨äºè®¤è¯æ¯è§ãçæå¾ä¸ä½ä¸ºçå®å°ç¹æäºä»¶ç
§çï¼ç¨éä¸é¢è§è¾¹çè§ README.mdã |
| | | |
| | | ## Do's and Don'ts |
| | | |
| | | - **Do**ï¼ä¿æèç½ç»æã红è²å¼ºè°ãé
ç½®åçä¸å¨ææéèåï¼çªå±æ£æ¥æ½å±ãåçå缩ã表åå页ç¾å¯è¯»æ§ã |
| | | - **Do**ï¼è®©çå® API çå è½½ã失败ãæåç¶æä¸æ¼ç¤ºæ°æ®ä¿ææ¸
æ¥è¾¹çï¼ä¿çé®çç¦ç¹åææå段éèã |
| | | - **Don't**ï¼æç¤ºä¾æªå¾ãæ£æµå¨æ æç¤ºæå±é¨éææ£æ¥è§ä¸ºçå®è´¦å·ãæéãä¸ä¼ ãæ¹å¯åç»å®éªæ¶ã |
| | | - **Don't**ï¼å° `scripts/portal-preview` æ fixture æ°æ®æ¥å
¥æ£å¼å
¥å£ã |
| | | |
| | | ç¬ç«å®¡æ¥ç»è®ºä¸ºâè§è§æ¶å°¾éè¿ï¼å¯äº¤ä»çé¢é¢è§âãçå®å端ç»å½ä¸åå
¥ãç¬¬ä¸æ¹ææã宿´é®çæµç¨ãæè²å计ç®å¯¹æ¯åº¦ä»æªéªæ¶ï¼å
·ä½è¾¹çè§ REVIEW.mdã |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | # èåæå¡é¨æ·å¤è§ |
| | | |
| | | ## æ¬å°é¢è§ |
| | | |
| | | 使ç¨ç°æä¾èµå¯å¨ï¼ |
| | | |
| | | ```powershell |
| | | node node_modules/vite/bin/vite.js --mode development --host 127.0.0.1 --port 5670 --strictPort |
| | | ``` |
| | | |
| | | çå®ç»å½å
¥å£ `/auth/login`ï¼é¦é¡µ `/analytics`ï¼ä¸ªäººä¸å¿ `/profile`ï¼ä»æ ¡éª tokenï¼ãæ£å¼å
¥å£ä¸å è½½ `scripts/portal-preview`ãå¼åæ ·å¼å
¥å£ `/scripts/portal-preview.html?surface=home|menu|login|profile|system` ä½¿ç¨ fixtureï¼`system` æ¼ç¤ºåªè¿åç§æ·èåæ¶æå·²ææç³»ç»é¡µé¢é建导èªãè忥æ¤åºæ¯å¯éå `crowded=1`ï¼ä¸ªäººä¸å¿é¡µç¾å¯ç¨ `section=resetPwd` æ `section=userSocial`ãå
·ä½æ¯æåæ°ä»¥èæ¬ä¸ºåã |
| | | |
| | | ## å½åå®ç°ä¸éªè¯è¾¹ç |
| | | |
| | | å½åæ¹åè´´è¿ç¨æ·åèï¼æ¡é¢ä¸å±å¤´é¨ãå
¨å¹
乡æåå¸çº¢ç»¸ä¸»å¾ã红è²å¿«æ·æå¡ã卿æéæ ç®åèè²é¡µèï¼è¡¥å
个人ä¸å¿ä¸å¤´é¨ç¨æ·æä½ãç¸å
³é¦é¡µæ¨¡åæ¥å
¥çå®è¯¾é¢ãå¾
åãæ¥éåå
¬å APIï¼ä¸ªäººä¸å¿æ²¿ç¨åèµæã头åãå¯ç åç»å®æ¥å£ï¼æ ·å¼é¢è§ä½¿ç¨ç¤ºä¾ååºã |
| | | |
| | | JSSM åçåç§°åºå®ä¸ºâæ±èçä¾éåä½ç»æµå¦ä¼èå鍿·âï¼é¡µé¢æ é¢ã头é¨ãç»å½å页èä¸è´ãç³»ç»ç®¡çè¿æ»¤ç§æ·é¡µï¼åªè¡¥å
ç»å½è´¦å·å·²è·æ¥è¯¢æéçç°æç®¡ç页ï¼è¶
级管çåå¯è§å
¨é¨ï¼ï¼å¦æåç«¯æ¢æªè¿åéç§æ·èå乿ªææå¯¹åºæéï¼ç³»ç»ç®¡çå
¥å£ä¸ä¼åºç°ï¼éè¦å¨å端é
ç½®è§è²ææã个人ä¸å¿åé¿ä¸å¡é¡µä½¿ç¨ä¸»å
å®¹åºæ»å¨ï¼å¤å±é¡µé¢ä¿æåºå®å¤´é¨ã |
| | | |
| | | æ¬æ¬¡ç¸å
³æºç å¨ `vue-tsc` è¾åºä¸è¿æ»¤æ éï¼å
¨ä»å åééå¤å®ä¹éåºï¼æ¥å¿ä¸º `typecheck-latest.log`ãåç«¯è¿æ¥ä¸å¯ç¨ï¼çå®ç»å½ãèµæåå
¥ãä¸ä¼ ãæ¹å¯åç¬¬ä¸æ¹ç»å®ä»æªéªæ¶ãæ£æµå¨é¦é¡µè¾åº `[]`ï¼ä¸ªäººä¸å¿å¯ä¸ `border-left` è£
饰æç¤ºå·²ç§»é¤ãç¬ç«å¤æ ¸ç»è®ºä¸ºâè§è§æ¶å°¾éè¿ï¼å¯äº¤ä»çé¢é¢è§âï¼å®æ´é®çæµç¨ãæè²ä¸è®¡ç®å¯¹æ¯åº¦æªéªæ¶ï¼è¯¦è§ REVIEW.mdã |
| | | |
| | | å½åæªå¾ï¼`deep-home-full.png`ã`deep-home-mobile.png`ã`header-deep-overflow.png`ã`profile-desktop.png`ã`profile-mobile-form.png`ã`profile-password.png`ã`profile-bindings.png`ãå为示ä¾åºæ¯ï¼ä¸è½è¯æçäº§è´¦å·æ°æ®ææ¥å£åå
¥æåãç®å½ä¸æ§çæªå¾ä¸æ§æ£æµæä»¶çä½åå²è®°å½ï¼ä¸ä»£è¡¨æ¬çç¶æã |
| | | |
| | | ## å¾åä¸åç |
| | | |
| | | - `public/static/imgs/portal-hero-v2.png`ï¼çæçæ æå乡æãåå¸ä¸çº¢ç»¸ä¸»å¾ï¼ç¨äºé¦é¡µæ¨ªå¹
åå±é¨è£åã |
| | | - `public/static/imgs/portal-modules-v2.png`ï¼çæç书ç±ãåå
¬ãè´¢å¡ãæ¡£æ¡ 2Ã2 å¾éï¼éè¿èæ¯å®ä½è£åç¨äºæ ç®ä¸ä¸é¢ã |
| | | - `public/static/imgs/portal-landscape-v1.png`ï¼åççææ¯è§ï¼ä¿çç¨äºè®¤è¯èæ¯ã |
| | | - `public/logos/JSSM-portal.svg`ï¼æ¬å°ç»¿è²é¨æ·æ è®°ï¼æºæåç§°åLogoä»éè¿æ¢æåçé
ç½®æä¾ã |
| | | - `public/static/imgs/portal-avatar.svg`ï¼æ¬å°ä¸æ§é»è®¤å¤´åã |
| | | |
| | | çææ¯è§ä¸å¾é䏿¯ç¹å®çå®å°ç¹æäºä»¶çè®°å½ç
§çãv2 æ¤å¤ä»
è®°å½å·²ç¥ç¨éï¼ä¸å°ç®è¿°åå
宿´çææç¤ºè¯ã |
| | | |
| | | v1 åçææç¤ºè¯ï¼åå²è®°å½ï¼ï¼ |
| | | |
| | | > Create one premium photorealistic panoramic website background asset for a Jiangsu supply and marketing cooperation / academic business portal. No text, lettering, logos, watermarks or UI. Landscape 3:2 composition. Jiangnan green agricultural fields and a calm river in foreground, small white traditional villages in middle distance, a restrained modern Chinese city skyline far on the right horizon, soft blue sky, warm clear morning daylight. Quiet, credible institutional photography / realistic composite, natural greens and pale sky blues, no oversaturation. Large clean pale sky in the upper left half for HTML headings; landscape fills the lower 45 percent and skyline stays distant on the right. Crisp detail and atmospheric depth. No red fabric, flags, people, charts or glowing effects. |
| | | |
| | | ## å¤è§è¿ç§» |
| | | |
| | | `src/preferences.ts` å®ä¹å
±ç¨å¤è§ã`src/main.ts` ä»¥ç°æå好å½å空é´ç `portal-appearance-v2` æ è®°æ§è¡ä¸æ¬¡è¿ç§»ï¼æ´æ°å¸å±ä¸é»è®¤å¤´åï¼ä¿çç»å½åå¨ï¼æ§è¡åå
许åç»ç¨æ·åå¥½è°æ´ãæ¡é¢å®é
头é¨é«åº¦ç±å¸å±å鍿·æ ·å¼å
±åå®ç°ï¼ä¸è½åªè¯»å好ä¸ç72pxæ¨ææ¡é¢é«åº¦ã |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | # æ¬ç夿 ¸è®°å½ |
| | | |
| | | ç¬ç«å®¡æ¥ç»è®ºï¼**è§è§æ¶å°¾éè¿ï¼å¯äº¤ä»çé¢é¢è§ã** æªåç°é»æäº¤ä»è§è§ææç缺é·ã该ç»è®ºä»
è¦çå½åçé¢é¢è§ï¼ä¸ä»£è¡¨çå®å端åå
¨é¨äº¤äºéªæ¶ã |
| | | |
| | | | æ ¸éªèå´ | å½åè®°å½ | è¾¹ç | |
| | | | --- | --- | --- | |
| | | | é¦é¡µä¸å¤´é¨ | å·²ææ¡é¢ãææºãæº¢åºå¯¼èªç¤ºä¾æªå¾ï¼é¦é¡µæ£æµå¨è¾åº `[]` | ç¤ºä¾æ°æ®ä¸è¯æç产æéåçå® API è¿å | |
| | | | èåäº¤äº | æç´¢â课é¢âè¿å课é¢ç®¡çãç³æ¥ãæåï¼âæ´å¤âå±å¼æ¾ç¤º9项溢åºèå | æªéªæ¶å®æ´é®çæµç¨ | |
| | | | 个人ä¸å¿ | å·²ææ¡é¢ãææºè¡¨åãå¯ç ãç»å®ç¤ºä¾æªå¾ï¼å¯ä¸ `border-left` è£
饰æç¤ºå·²ç§»é¤ | ä¸å®£ç§°çå®ä¸ä¼ ãä¿åãæ¹å¯ãç»å®æå | |
| | | | 页ç¾è¡¨å | 忢å¯ç åååºæ¬èµæï¼æªä¿åæ¾ç¤ºåç§°ä¿çï¼å
容ä½äºçå® TabPaneï¼åæ¢åªæ´æ°æ¬å°ç¶æ | ç¤ºä¾æ°æ®èå´å
éªè¯ | |
| | | | ç±»åæ£æ¥ | æ¬æ¬¡ç¸å
³æºç è¿æ»¤æ é | å
¨ä»å åééå¤å®ä¹éåºï¼è§ `typecheck-latest.log` | |
| | | | ç»å½ä¸åå
¥ | æªéªæ¶ | åç«¯è¿æ¥ä¸å¯ç¨ï¼çå®èµæ/å¯ç /头ååå
¥ä¸ç¬¬ä¸æ¹ææå¾
èè° | |
| | | | å
¶ä»éªæ¶ | å°æªæ§è¡å®æ´éªæ¶ | 宿´é®çæµç¨ãæè²å计ç®å¯¹æ¯åº¦æªéªæ¶ | |
| | | |
| | | ## 2026-10-19 è¡¥å
ä¿®æ£ |
| | | |
| | | - åç§°åºå®ä¸ºâæ±èçä¾éåä½ç»æµå¦ä¼èå鍿·âï¼æ¬å°å¤´é¨é¢è§æ¾ç¤ºæºæåä¸âèå鍿·âï¼é¡µèåç»å½é¡µä¸åéå¤éå â鍿·ç³»ç»âã |
| | | - 个人ä¸å¿ä¸»å
容åºå®æµ `clientHeight=759`ã`scrollHeight=1212`ï¼æ»å¨å°åºå `scrollTopâ453`ï¼åºé¨è¡¨å说æå¯è§ï¼ä¿®æ£å主å
容åºè¢«æè³ 1212pxãå¤å± body ç¦æ¢æ»å¨ã |
| | | - ç³»ç»ç®¡ç模æâå端ä»
è¿åç§æ·é¡µãè´¦å·å
·å¤ç¨æ·/è§è²çæ¥è¯¢æéâæ¶ï¼å¯¼èªæ¾ç¤ºå
ä¸ªç°æç®¡çæ¨¡åï¼æ âç§æ·âææ¬ï¼çº¯èååæ¢è¿æ£æ¥äºæ ææéèãå·²æé¡µé¢å»éã |
| | | - æªå¾ `system-menu-no-tenant.png` ä¸ `profile-scroll-fixed.png` æ¯æ¬å° fixture è¯æ®ãçå®èåæéåæ°æ®åå
¥ä»éå端èè°ã |
| | | - æ¬æ¬¡å
¨ä» `vue-tsc` å¤è·å¨çº¦ 4GB Node å å¤å
åä¸è¶³ï¼`typecheck-followup.log` ä¿åäºè¾åºï¼æ¬è½®æ¹å¨ç Vue åæä»¶ç»ä»¶å·²éè¿ç¬ç«è§£æä¸æ¨¡æ¿ç¼è¯ï¼èåæé忢已éè¿æ¬å°æè¨ï¼æ¬å° Vite é¢è§æ£å¸¸ã |
| | | |
| | | 个人ä¸å¿å·²å®ç°ï¼ä¸ä¼ è¿å对象å `url`ã空é®ç®±å¯éãæ¹å¯æåæ¸
空ãOAuth åè°ä¿ç hash è·¯ç±å¹¶ç± `router.replace` æ¸
ç `code/state/type`ã䏿¾ç¤º openid/tokenï¼Profile éè core è·¯ç±ä»æ£æ¥ tokenãç§»å¨ä¸ªäººä¸å¿å¾æ 带 `aria-label`ï¼é»è®¤å¤´å为æ¬å°ä¸æ§ SVGã以ä¸å±äºå®ç°è®°å½ï¼ä¸çåäºçå®å端èè°æåã |
| | | |
| | | æ¬ç主è¦è¯æ®ï¼`deep-home-full.png`ã`deep-home-mobile.png`ã`header-deep-overflow.png`ã`profile-desktop.png`ã`profile-mobile-form.png`ã`profile-password.png`ã`profile-bindings.png`ãæªå¾å为示ä¾åºæ¯ãæ£å¼å
¥å£æªå è½½ fixture é¢è§èæ¬ï¼é¢è§èåä¸å¾ä½ä¸ºå®é
è´¦å·æé说æãæ§çæªå¾ä¸æ§æ£æµæä»¶ä¿çä½åå²è®°å½ã |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | [ |
| | | { |
| | | "antipattern": "side-tab", |
| | | "name": "Side-tab accent border", |
| | | "description": "Thick colored border on one side of a card â the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.", |
| | | "severity": "warning", |
| | | "category": "slop", |
| | | "file": "D:\\ç马\\åé\\å端\\mom-pro2-after\\mom-pro2-before\\src\\views\\_core\\profile\\modules\\reset-pwd.vue", |
| | | "line": 104, |
| | | "snippet": "border-left: 3px solid #4f82ac", |
| | | "importedBy": [ |
| | | "index.vue" |
| | | ] |
| | | } |
| | | ] |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none" role="img" aria-label="ä¾éå使塿 è¯"> |
| | | <g stroke="#078344" stroke-width="7" stroke-linejoin="miter"> |
| | | <path d="M30 16 40 6l10 10M64 30l10 10-10 10M50 64 40 74 30 64M16 50 6 40l10-10"/> |
| | | <path d="m26 25 14-8 14 8v29l-14 9-14-9Z"/> |
| | | </g> |
| | | <path fill="#078344" d="m13 23 8-8 6 6-8 8Zm46-8 8 8-6 6-8-8Zm8 44-8 8-6-6 8-8Zm-46 8-8-8 6-6 8 8Z"/> |
| | | <circle cx="40" cy="40" r="9" stroke="#078344" stroke-width="6"/> |
| | | </svg> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="48" fill="#edf5fc"/><circle cx="48" cy="35" r="16" fill="#6b9bc3"/><path d="M19 83c1-20 12-31 29-31s28 11 29 31" fill="#3e729e"/></svg> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!doctype html> |
| | | <html lang="zh-CN"> |
| | | <head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>鍿·æ ·å¼é¢è§</title></head> |
| | | <body><div id="app"></div><script type="module" src="./portal-preview.ts"></script></body> |
| | | </html> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /** ä»
ä¾ Vite æ¬å°è§è§æ£æ¥ï¼ä½¿ç¨æ¼ç¤ºèååç©ºæ°æ®ï¼ä¸è¿æ¥ä¸å¡æ¥å£ã */ |
| | | import { createApp, defineComponent, h } from 'vue'; |
| | | import { createMemoryHistory, createRouter } from 'vue-router'; |
| | | |
| | | import { BasicLayout, UserDropdown } from '@vben/layouts'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { initPreferences, updatePreferences } from '@vben/preferences'; |
| | | import { initStores, useAccessStore, useDictStore } from '@vben/stores'; |
| | | |
| | | import '../src/packages/styles/src'; |
| | | import '../src/packages/styles/src/antd/index.css'; |
| | | import '../src/portal-theme.css'; |
| | | |
| | | import { initComponentAdapter } from '../src/adapter/component'; |
| | | import { initSetupVbenForm } from '../src/adapter/form'; |
| | | import { requestClient } from '../src/api/request'; |
| | | import App from '../src/app.vue'; |
| | | import AuthLayout from '../src/layouts/auth.vue'; |
| | | import { setupI18n } from '../src/locales'; |
| | | import { overridesPreferences, portalAppearancePreferences, preferencesExtension } from '../src/preferences'; |
| | | import Login from '../src/views/_core/authentication/login.vue'; |
| | | import Analytics from '../src/views/dashboard/analytics/index.vue'; |
| | | import PortalBrandText from '../src/components/portal/portal-brand-text.vue'; |
| | | import Profile from '../src/views/_core/profile/index.vue'; |
| | | import { portalSystemMenus } from '../src/router/portal-system-menus'; |
| | | |
| | | await initPreferences({ namespace: 'portal-visual-preview', overrides: overridesPreferences, extension: preferencesExtension }); |
| | | await initComponentAdapter(); |
| | | await initSetupVbenForm(); |
| | | |
| | | // æ¤æ¿èº«ä»
åå¨äºç¬ç«é¢è§å
¥å£ï¼æ£å¼å
¥å£ä¸å è½½æ¬æä»¶ã |
| | | const surface = new URLSearchParams(location.search).get('surface'); |
| | | const crowded = new URLSearchParams(location.search).get('crowded') === '1'; |
| | | const populated = new URLSearchParams(location.search).get('populated') === '1'; |
| | | let failNoticeOnce = surface === 'notice-error'; |
| | | requestClient.get = async (url: string) => { |
| | | if (url.includes('/system/user/profile/get')) { |
| | | if (surface === 'profile-error') throw new Error('é¢è§èµæå 载失败'); |
| | | return { id: 1, username: 'preview-user', nickname: 'é¢è§ç¨æ·', email: 'preview@example.com', mobile: '', sex: 1, avatar: '', dept: { name: '妿¯ç ç©¶é¨ï¼ç¤ºä¾ï¼' }, roles: [{ id: 1, name: 'æ®éç¨æ·ï¼ç¤ºä¾ï¼' }], posts: [{ name: 'ç 究人åï¼ç¤ºä¾ï¼' }], createTime: '2026-01-01 09:00:00', loginDate: '2026-10-18 09:30:00', loginIp: '127.0.0.1' } as never; |
| | | } |
| | | if (url.includes('/system/social-user/get-bind-list')) return [] as never; |
| | | if (failNoticeOnce && url.includes('/bpm/oa/notice/page')) { |
| | | failNoticeOnce = false; |
| | | throw new Error('æ¼ç¤ºå
¬åå 载失败'); |
| | | } |
| | | if (populated && url.includes('/research/application/page')) { |
| | | return { total: 12, list: [ |
| | | 'ä¾éå使å¡ä½ç³»ä¸ä¹¡æäº§ä¸åååå±ç ç©¶', |
| | | 'åäº§åæµéæ¨¡å¼ä¸æ°ååæå¡ç ç©¶', |
| | | 'åæç°ä»£ç©æµä¸èµæºååæºå¶ç ç©¶', |
| | | 'æ°å½¢å¿ä¸ä¾éåä½ç»æµåå±è·¯å¾ç ç©¶', |
| | | 'åºå±æå¡è½å建设ä¸åæ°å®è·µç ç©¶', |
| | | 'åºåååä¸ä¾éåä½é«è´¨éåå±ç ç©¶', |
| | | ].map((title, index) => ({ id: index + 1, title: `示ä¾ï½${title}`, applicantName: 'é¢è§ç¨æ·', createTime: `2026-10-${String(18 - index).padStart(2, '0')}`, status: 15 })) } as never; |
| | | } |
| | | if (populated && url.includes('/bpm/oa/notice/page')) { |
| | | return { total: 6, list: [ |
| | | 'å
³äºå¼å±å¹´åº¦ç 究课é¢ç³æ¥å·¥ä½çéç¥', |
| | | 'å
³äºè¯¾é¢ç³æ¥æææäº¤çç¸å
³è¯´æ', |
| | | 'å
³äºä¸å¡ååæå¡äºé¡¹çéç¥', |
| | | 'å
³äºå 强课é¢ç ç©¶ææäº¤æµçéç¥', |
| | | 'å
³äºå®åå¹³å°ä¸ªäººèµæçéç¥', |
| | | 'å
³äºè¿æç³»ç»æå¡å®æçéç¥', |
| | | ].map((title, index) => ({ id: index + 1, title: `示ä¾ï½${title}`, publishTime: `2026-10-${String(18 - index).padStart(2, '0')}`, createTime: '2026-10-18' })) } as never; |
| | | } |
| | | if (populated && url.includes('/bpm/task/todo')) return { list: [], total: 3 } as never; |
| | | if (populated && url.includes('/finance/reimbursement/page')) return { list: [], total: 8 } as never; |
| | | if (populated && url.includes('/research/guideline/page')) return { list: [], total: 4 } as never; |
| | | return { list: [], total: 0 } as never; |
| | | }; |
| | | |
| | | const emptyPage = defineComponent({ render: () => h('div', { style: 'padding:32px;background:white' }, 'ä¸å¡é¡µé¢é¢è§') }); |
| | | const shell = defineComponent({ |
| | | setup: () => () => h(BasicLayout, { class: 'supply-portal-shell' }, { |
| | | 'logo-text': () => h(PortalBrandText), |
| | | 'user-dropdown': () => h(UserDropdown, { showText: true, avatar: '/static/imgs/portal-avatar.svg', text: populated ? 'é¢è§æ°æ®' : 'æ¼ç¤ºç¨æ·', menus: [{ text: '个人ä¸å¿', icon: 'lucide:user-round', handler: () => router.push('/profile') }, { text: 'å¯ç 设置', icon: 'lucide:shield-check', handler: () => router.push('/profile?section=resetPwd') }] }), |
| | | 'header-right-165': () => h('button', { class: 'portal-personal-entry', 'aria-label': '个人ä¸å¿', onClick: () => router.push('/profile') }, [h(IconifyIcon, { icon: 'lucide:user-round' }), h('span', '个人ä¸å¿')]), |
| | | }), |
| | | }); |
| | | const router = createRouter({ |
| | | history: createMemoryHistory(), |
| | | routes: [ |
| | | { path: '/auth', component: AuthLayout, children: [{ path: 'login', component: Login, name: 'Login', meta: { title: 'ç»å½' } }] }, |
| | | { path: '/', name: 'PortalPreviewShell', component: shell, children: [ |
| | | { path: 'analytics', name: 'Analytics', component: Analytics, meta: { title: 'é¦é¡µ', affixTab: true, icon: 'lucide:house' } }, |
| | | { path: 'profile', name: 'Profile', component: Profile, meta: { title: '个人ä¸å¿', fullPathKey: false } }, |
| | | { path: 'bpm/task/todo', name: 'BpmTaskTodo', component: emptyPage, meta: { title: 'å¾
åä»»å¡' } }, |
| | | { path: 'bpm/task/done', name: 'BpmTaskDone', component: emptyPage, meta: { title: 'å·²åä»»å¡' } }, |
| | | { path: 'bpm/create', name: 'BpmProcessInstanceCreate', component: emptyPage, meta: { title: 'åèµ·æµç¨' } }, |
| | | { path: 'research/application', name: 'ResearchApplication', component: emptyPage, meta: { title: '课é¢ç³æ¥' } }, |
| | | { path: 'research/guideline', name: 'ResearchGuideline', component: emptyPage, meta: { title: 'è¯¾é¢æå' } }, |
| | | { path: 'finance/reimbursement', name: 'FinanceReimbursement', component: emptyPage, meta: { title: 'è´¢å¡æ¥é' } }, |
| | | { path: 'bpm/oa/notice', name: 'BpmOaNotice', component: emptyPage, meta: { title: 'éç¥å
Œ' } }, |
| | | ] }, |
| | | ], |
| | | }); |
| | | |
| | | const app = createApp(App); |
| | | await initStores(app, { namespace: 'portal-visual-preview' }); |
| | | useDictStore().setDictCache({ system_user_sex: [{ label: 'ç·', value: '1' }, { label: '女', value: '2' }] }); |
| | | await setupI18n(app); |
| | | const access = useAccessStore(); |
| | | const previewMenus = [ |
| | | { path: '/analytics', name: 'é¦é¡µ', icon: 'lucide:house' }, |
| | | { path: '/bpm', name: '审æ¹ä¸å¿', icon: 'lucide:workflow', children: [ |
| | | { path: '/bpm/task/todo', name: 'å¾
åä»»å¡', icon: 'lucide:list-todo' }, |
| | | { path: '/bpm/task/done', name: 'å·²åä»»å¡', icon: 'lucide:circle-check' }, |
| | | { path: '/bpm/create', name: 'åèµ·æµç¨', icon: 'lucide:circle-plus' }, |
| | | { path: '/bpm/oa/notice', name: 'éç¥å
Œ', icon: 'lucide:megaphone' }, |
| | | ] }, |
| | | { path: '/research', name: '课é¢ç®¡ç', icon: 'lucide:book-open-text', children: [ |
| | | { path: '/research/application', name: '课é¢ç³æ¥', icon: 'lucide:file-text' }, |
| | | { path: '/research/guideline', name: 'è¯¾é¢æå', icon: 'lucide:book-open' }, |
| | | ] }, |
| | | { path: '/finance', name: 'è´¢å¡æ¥é', icon: 'lucide:receipt', children: [ |
| | | { path: '/finance/reimbursement', name: 'æ¥é管ç', icon: 'lucide:receipt' }, |
| | | ] }, |
| | | ]; |
| | | if (surface === 'system') { |
| | | const [system] = portalSystemMenus([ |
| | | { id: 1, parentId: 0, path: '/system', name: 'ç³»ç»ç®¡ç', meta: { title: 'ç³»ç»ç®¡ç' }, children: [ |
| | | { id: 2, parentId: 1, path: 'tenant', name: 'ç§æ·ç®¡ç', meta: { title: 'ç§æ·ç®¡ç' }, children: [ |
| | | { id: 3, parentId: 2, path: 'list', name: 'ç§æ·å表', component: 'system/tenant/index', meta: { title: 'ç§æ·å表' } }, |
| | | ] }, |
| | | ] }, |
| | | ], ['system:user:list', 'system:role:query', 'system:dept:query', 'system:post:query', 'system:menu:query', 'system:dict:query'], []); |
| | | if (system) { |
| | | previewMenus.push({ path: '/system', name: system.name, icon: system.icon ?? 'lucide:settings-2', children: system.children?.map((item) => { |
| | | const path = `/system/${item.path}`; |
| | | router.addRoute('PortalPreviewShell', { path, name: item.componentName ?? item.name, component: emptyPage, meta: { title: item.name } }); |
| | | return { path, name: item.name, icon: item.icon ?? 'lucide:settings-2' }; |
| | | }) ?? [] }); |
| | | } |
| | | } |
| | | if (crowded) { |
| | | for (let index = 1; index <= 16; index++) { |
| | | const path = `/preview-module/${index}`; |
| | | previewMenus.push({ path, name: `æ¼ç¤ºæ¨¡å${index}`, icon: 'lucide:layers' }); |
| | | router.addRoute('PortalPreviewShell', { path, name: `PreviewModule${index}`, component: emptyPage, meta: { title: `æ¼ç¤ºæ¨¡å${index}` } }); |
| | | } |
| | | } |
| | | access.setAccessMenus(previewMenus.map((menu) => ({ ...menu, parents: [], children: menu.children?.map((child) => ({ ...child, parents: [menu.path] })) }))); |
| | | router.addRoute({ path: '/bpm', redirect: '/bpm/task/todo' }); |
| | | router.addRoute({ path: '/research', redirect: '/research/application' }); |
| | | router.addRoute({ path: '/finance', redirect: '/finance/reimbursement' }); |
| | | updatePreferences({ ...portalAppearancePreferences, copyright: overridesPreferences.copyright, logo: overridesPreferences.logo, app: { ...overridesPreferences.app, ...portalAppearancePreferences.app, enableCheckUpdates: false, enablePreferences: crowded }, widget: { ...portalAppearancePreferences.widget, notification: false, globalSearch: true, fullscreen: crowded, themeToggle: false } }); |
| | | app.use(router); |
| | | await router.push(surface === 'login' ? '/auth/login' : surface?.startsWith('profile') ? { path: '/profile', query: { section: new URLSearchParams(location.search).get('section') ?? 'basicInfo' } } : surface === 'menu' ? '/bpm/task/todo' : surface === 'system' ? '/system/user' : '/analytics'); |
| | | await router.isReady(); |
| | | app.mount('#app'); |
| | | document.getElementById('__app-loading__')?.remove(); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!doctype html> |
| | | <html lang="zh-CN"> |
| | | <head><meta charset="UTF-8" /><title>鍿·è§è§æ ¸éª</title><style>body{margin:0;background:#e6edf4;font:13px system-ui;color:#284766}p{margin:12px 20px}.frame{position:relative;margin:0 20px;overflow:hidden}iframe{border:0;position:absolute;left:0;top:0;transform-origin:top left;background:#fff}</style></head> |
| | | <body><p id="caption"></p><div class="frame"><iframe title="å®é
ç»ä»¶é¢è§"></iframe></div><script> |
| | | const query=new URLSearchParams(location.search),mobile=query.has('mobile'),surface=query.get('surface')||'home'; |
| | | const width=mobile?390:1440,height=mobile?800:1080,scale=mobile?1:.5; |
| | | document.querySelector('.frame').style.cssText=`width:${width*scale}px;height:${height*scale}px;margin:${mobile?'0 auto':'0 20px'}`; |
| | | const frame=document.querySelector('iframe');frame.style.cssText=`width:${width}px;height:${height}px;transform:scale(${scale})`;frame.src=`/scripts/portal-preview.html?surface=${surface}&populated=${query.has('populated')?'1':'0'}&crowded=${query.has('crowded')?'1':'0'}`; |
| | | document.querySelector('#caption').textContent=`æ ·å¼æ ¸éª · ${mobile?'ææº 390px':'æ¡é¢ 1440px'} · ${surface} · æ¼ç¤ºèå / ${query.has('populated')?'示ä¾ä¸å¡æ°æ®':'空ä¸å¡æ°æ®'}`; |
| | | document.getElementById('__app-loading__')?.remove(); |
| | | </script></body></html> |
| | |
| | | import { initStores } from './packages/stores/src'; |
| | | import './packages/styles/src'; |
| | | import './packages/styles/src/antd/index.css'; |
| | | import './portal-theme.css'; |
| | | |
| | | import { useTitle } from '@vueuse/core'; |
| | | |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed } from 'vue'; |
| | | |
| | | import { preferences } from '@vben/preferences'; |
| | | |
| | | const name = computed(() => preferences.copyright.companyName); |
| | | const isSocietyPortal = computed(() => name.value === 'æ±èçä¾éåä½ç»æµå¦ä¼èå鍿·'); |
| | | const subtitle = computed(() => isSocietyPortal.value |
| | | ? 'JIANGSU SOCIETY OF SUPPLY AND MARKETING ECONOMY' |
| | | : 'åååå
¬ · 妿¯ç ç©¶ · èåæå¡'); |
| | | </script> |
| | | |
| | | <template> |
| | | <span class="shell-brand" :title="name"> |
| | | <span class="shell-brand-main"><strong>{{ isSocietyPortal ? 'æ±èçä¾éåä½ç»æµå¦ä¼' : name }}</strong><em v-if="isSocietyPortal">èå鍿·</em></span> |
| | | <small>{{ subtitle }}</small> |
| | | </span> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { preferences } from '@vben/preferences'; |
| | | |
| | | const router = useRouter(); |
| | | const name = computed(() => preferences.copyright.companyName); |
| | | const year = new Date().getFullYear(); |
| | | </script> |
| | | |
| | | <template> |
| | | <footer class="portal-site-footer"> |
| | | <div class="portal-footer-inner"> |
| | | <div class="portal-footer-brand"><img :src="preferences.logo.source" alt="" /><div><strong>{{ name }}</strong><span>弿¾åä½ Â· 妿¯ç ç©¶ · æå¡åå±</span></div></div> |
| | | <div class="portal-footer-service"><p>弿¾åä½ Â· 妿¯ç ç©¶ · æå¡åå±</p><span>课é¢ç ç©¶ãæµç¨åå䏿¥å¸¸ä¸å¡çä¸ç«å¼æå¡å
¥å£</span></div> |
| | | <nav aria-label="页è导èª"><button type="button" @click="router.push('/analytics')">è¿åé¦é¡µ</button><button v-if="router.hasRoute('Profile')" type="button" @click="router.push({ name: 'Profile' })">个人ä¸å¿</button></nav> |
| | | </div> |
| | | <div class="portal-footer-copyright">Copyright © {{ year }} {{ name }}</div> |
| | | </footer> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .portal-site-footer { margin-top: 28px; color: #e9f3fd; background: #204d79; } |
| | | .portal-footer-inner { display: flex; width: min(1280px, calc(100% - 80px)); min-height: 122px; align-items: center; justify-content: space-between; gap: 28px; margin: 0 auto; } |
| | | .portal-footer-brand { display: flex; align-items: center; gap: 14px; } |
| | | .portal-footer-brand img { width: 50px; height: 50px; object-fit: contain; filter: brightness(0) invert(1); } |
| | | .portal-footer-brand strong { display: block; font-size: 19px; font-weight: 650; } |
| | | .portal-footer-brand span { display: block; margin-top: 5px; color: #c5daee; font-size: 11px; letter-spacing: .12em; } |
| | | .portal-footer-service { padding-left: 26px; border-left: 1px solid #567b9f; } |
| | | .portal-footer-service p { margin: 0 0 9px; font-size: 14px; } |
| | | .portal-footer-service span { color: #ccdeef; font-size: 12px; } |
| | | nav { display: flex; gap: 18px; } |
| | | button { color: #e9f3fd; font-size: 12px; cursor: pointer; white-space: nowrap; } |
| | | button:hover { text-decoration: underline; } |
| | | button:focus-visible { outline: 2px solid white; outline-offset: 5px; } |
| | | .portal-footer-copyright { padding: 14px 20px; color: #cbdcec; background: #173853; font-size: 11px; text-align: center; } |
| | | @media (max-width: 850px) { .portal-footer-inner { width: calc(100% - 40px); flex-wrap: wrap; gap: 18px; padding: 24px 0; } .portal-footer-service { border: 0; padding: 0; } } |
| | | @media (max-width: 520px) { .portal-footer-inner { width: calc(100% - 30px); flex-direction: column; align-items: flex-start; } .portal-footer-brand strong { font-size: 17px; } } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { MenuRecordRaw } from '@vben/types'; |
| | | |
| | | import { computed } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useAccessStore } from '@vben/stores'; |
| | | |
| | | function visibleTree(items: MenuRecordRaw[]): MenuRecordRaw[] { |
| | | return items.flatMap((item) => { |
| | | if (item.show === false || item.disabled || item.path === '/analytics') return []; |
| | | const children = item.children ? visibleTree(item.children) : undefined; |
| | | if (item.children?.length && !children?.length) return []; |
| | | return [{ ...item, children }]; |
| | | }); |
| | | } |
| | | |
| | | function flatten(items: MenuRecordRaw[]): MenuRecordRaw[] { |
| | | return items.flatMap((item) => item.children?.length ? flatten(item.children) : [item]); |
| | | } |
| | | |
| | | export function firstPortalPage(menu: MenuRecordRaw): MenuRecordRaw | undefined { |
| | | if (menu.disabled || menu.show === false) return; |
| | | if (menu.children?.length) { |
| | | for (const child of menu.children) { |
| | | const target = firstPortalPage(child); |
| | | if (target) return target; |
| | | } |
| | | return; |
| | | } |
| | | return menu.path ? menu : undefined; |
| | | } |
| | | |
| | | export function portalCategory(menu: MenuRecordRaw) { |
| | | const key = `${menu.path} ${menu.name}`; |
| | | if (/research|课é¢|ç§ç |妿¯/i.test(key)) return 'research'; |
| | | if (/finance|è´¢å¡|æ¥é/i.test(key)) return 'finance'; |
| | | if (/bpm|审æ¹|åå
¬|æµç¨/i.test(key)) return 'office'; |
| | | return 'archive'; |
| | | } |
| | | |
| | | export function usePortalMenus() { |
| | | const router = useRouter(); |
| | | const access = useAccessStore(); |
| | | const menus = computed(() => visibleTree(access.accessMenus)); |
| | | const leaves = computed(() => flatten(menus.value)); |
| | | const match = (pattern: RegExp) => leaves.value.find((menu) => pattern.test(`${menu.name} ${menu.path}`)); |
| | | const features = computed(() => ({ |
| | | todo: match(/å¾
åä»»å¡|æçå¾
å|\/task\/todo/), |
| | | create: match(/åèµ·æµç¨|\/process-instance\/create/), |
| | | application: match(/课é¢ç³æ¥|\/research\/application/), |
| | | guideline: match(/è¯¾é¢æå|\/research\/guideline/), |
| | | reimbursement: match(/è´¢å¡æ¥é|æ¥é管ç|\/finance\/reimbursement/), |
| | | notice: match(/\/bpm\/oa\/notice/) ?? match(/éç¥å
Œ|\/system\/notice/), |
| | | })); |
| | | |
| | | function goMenu(menu?: MenuRecordRaw) { |
| | | const target = menu && firstPortalPage(menu); |
| | | if (!target) return; |
| | | if (/^https?:\/\//i.test(target.path)) window.open(target.path, '_blank', 'noopener,noreferrer'); |
| | | else router.push({ path: target.path, query: target.query }); |
| | | } |
| | | |
| | | return { menus, leaves, features, goMenu }; |
| | | } |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, onMounted } from 'vue'; |
| | | |
| | | import { AuthPageLayout } from '@vben/layouts'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { preferences } from '@vben/preferences'; |
| | | |
| | | import { $t } from '#/locales'; |
| | | import { useCompanyConfigStore } from '#/store/company-config'; |
| | | import PortalBrandText from '#/components/portal/portal-brand-text.vue'; |
| | | |
| | | declare const __APP_LOGO__: string; |
| | | |
| | |
| | | const appName = computed(() => preferences.copyright.companyName); |
| | | // ç»å½é¡µ logo ä¼å
åæ°æ®åºé
ç½®ï¼preferences.logo.sourceï¼ï¼æªé
ç½®æ¶åéç¼è¯æé»è®¤ __APP_LOGO__ |
| | | const logo = computed(() => preferences.logo.source || __APP_LOGO__); |
| | | const logoDark = computed(() => preferences.logo.sourceDark); |
| | | // ç»å½é¡µæ é¢/æè¿°ä½¿ç¨ i18n åºå®ææ¡ï¼ä¸éå
¬å¸é
ç½®åå |
| | | const pageTitle = computed(() => $t('authentication.pageTitle')); |
| | | const pageDescription = computed(() => $t('authentication.pageDesc')); |
| | | const pageTitle = computed(() => companyConfigStore.loginTitle || 'æå¡ä¾éåä½ï¼å©åä¹¡ææ¯å
´'); |
| | | const pageDescription = computed(() => companyConfigStore.loginDescription || 'è¿æ¥è¯¾é¢ç ç©¶ã审æ¹åä½ä¸æ¥å¸¸ä¸å¡ï¼è®©æ¯ä¸é¡¹å·¥ä½æåºæ¨è¿ã'); |
| | | </script> |
| | | |
| | | <template> |
| | | <AuthPageLayout |
| | | :app-name="appName" |
| | | :logo="logo" |
| | | :logo-dark="logoDark" |
| | | :page-description="pageDescription" |
| | | :page-title="pageTitle" |
| | | > |
| | | <!-- èªå®ä¹å·¥å
·æ --> |
| | | <!-- <template #toolbar></template> --> |
| | | </AuthPageLayout> |
| | | <div class="portal-auth"> |
| | | <header class="auth-masthead"> |
| | | <div class="auth-brand"> |
| | | <img :src="logo" :alt="appName" /> |
| | | <PortalBrandText /> |
| | | </div> |
| | | <span class="auth-motto">弿¾åä½ Â· ä¾¿æ·æå¡ Â· 髿åç</span> |
| | | </header> |
| | | <main class="auth-main"> |
| | | <div class="auth-landscape" aria-hidden="true"></div> |
| | | <div class="auth-content"> |
| | | <section class="auth-intro" aria-labelledby="auth-heading"> |
| | | <h1 id="auth-heading">{{ pageTitle }}</h1> |
| | | <p>{{ pageDescription }}</p> |
| | | <div class="auth-services"> |
| | | <div><IconifyIcon icon="lucide:workflow" /><strong>审æ¹åå</strong><span>æµç¨åç · å¾
åè·è¿</span></div> |
| | | <div><IconifyIcon icon="lucide:book-open-text" /><strong>课é¢ç®¡ç</strong><span>æåæ¥è¯¢ · 课é¢ç³æ¥</span></div> |
| | | <div><IconifyIcon icon="lucide:megaphone" /><strong>ä¿¡æ¯äºé</strong><span>éç¥å
¬å · æ¶æ¯æé</span></div> |
| | | </div> |
| | | </section> |
| | | <section class="auth-form-panel" aria-label="身份认è¯"> |
| | | <RouterView v-slot="{ Component, route }"> |
| | | <KeepAlive :include="['Login']"> |
| | | <component :is="Component" :key="route.path" /> |
| | | </KeepAlive> |
| | | </RouterView> |
| | | </section> |
| | | </div> |
| | | </main> |
| | | <footer class="auth-footer"><span>{{ appName }}</span><span>å»ºè®®ä½¿ç¨ææ°ç Chrome æ Edge æµè§å¨</span></footer> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .portal-auth { --background: 0 0% 100%; --foreground: 213 51% 23%; --muted-foreground: 213 19% 43%; --input-background: 210 75% 99%; --input: 211 29% 81%; --border: 211 37% 89%; display: flex; min-height: 100dvh; flex-direction: column; color-scheme: light; color: #18365a; background: #f5f9fd; } |
| | | .auth-masthead { display: flex; width: 100%; min-height: 120px; align-items: center; justify-content: space-between; gap: 24px; padding: 20px max(4vw, calc((100vw - 1280px) / 2)); border-top: 20px solid #d9ecfb; background: #fff; } |
| | | .auth-brand { display: flex; min-width: 0; align-items: center; gap: 16px; } |
| | | .auth-brand img { width: 56px; height: 56px; object-fit: contain; } |
| | | .auth-motto { color: #426689; font-size: 14px; letter-spacing: .12em; white-space: nowrap; } |
| | | .auth-main { position: relative; display: flex; min-height: 650px; flex: 1; align-items: center; overflow: hidden; background: #e3f0f9; } |
| | | .auth-landscape { position: absolute; inset: 0; background: linear-gradient(90deg, rgb(247 251 255 / 90%), rgb(247 251 255 / 55%) 46%, rgb(247 251 255 / 8%) 75%), url('/static/imgs/portal-hero-v2.png') center 58% / cover; } |
| | | .auth-content { position: relative; display: grid; width: min(1320px, 90%); grid-template-columns: minmax(0, 1fr) 420px; align-items: center; gap: 8%; margin: 52px auto 68px; } |
| | | .auth-intro { align-self: start; max-width: 680px; padding-top: 56px; } |
| | | .auth-intro h1 { max-width: 13em; margin: 0 0 22px; color: #a91e28; font-family: 'KaiTi', 'STKaiti', serif; font-size: clamp(34px, 3.8vw, 54px); font-weight: 800; letter-spacing: .045em; line-height: 1.4; text-wrap: balance; } |
| | | .auth-intro > p { max-width: 32em; margin: 0; color: #294e73; font-size: 17px; line-height: 1.9; } |
| | | .auth-services { display: flex; gap: 32px; margin-top: 42px; } |
| | | .auth-services > div { display: grid; gap: 7px; } |
| | | .auth-services :deep(svg) { width: 25px; height: 25px; margin-bottom: 3px; color: #28689b; } |
| | | .auth-services strong { font-size: 15px; } |
| | | .auth-services span { color: #4c6c88; font-size: 12px; white-space: nowrap; } |
| | | .auth-form-panel { position: relative; padding: 38px 38px 30px; border-radius: 12px; background: #fff; box-shadow: 0 18px 56px rgb(24 62 98 / 15%); } |
| | | .auth-form-panel::before { position: absolute; top: 0; right: 38px; left: 38px; height: 3px; background: #b7242d; content: ''; } |
| | | .auth-footer { display: flex; min-height: 64px; align-items: center; justify-content: center; gap: 28px; padding: 16px 24px; color: #5c7187; background: #fff; font-size: 12px; } |
| | | @media (max-width: 1100px) { .auth-content { grid-template-columns: minmax(0, 1fr) 390px; gap: 5%; } .auth-services { gap: 20px; } .auth-services span { white-space: normal; } .auth-form-panel { padding: 32px; } } |
| | | @media (max-width: 850px) { .auth-masthead { min-height: 90px; padding: 18px 6%; } .auth-motto { display: none; } .auth-content { width: min(460px, 90%); grid-template-columns: 1fr; gap: 25px; margin: 30px auto 42px; } .auth-intro { padding-top: 0; } .auth-intro h1 { margin-bottom: 10px; font-size: 32px; } .auth-intro > p { font-size: 14px; } .auth-services { display: none; } .auth-main { min-height: 0; } .auth-footer { flex-direction: column; gap: 5px; } .auth-landscape { background-position: 35% center; } } |
| | | @media (max-width: 420px) { .auth-form-panel { padding: 28px 24px; } .auth-brand strong { font-size: 22px; } .auth-brand img { width: 46px; height: 46px; } } |
| | | @media (max-width: 850px) { .auth-brand :deep(.shell-brand-main) { gap: 10px; }.auth-brand :deep(.shell-brand strong) { max-width: calc(100vw - 120px); font-size: 18px; white-space: normal; }.auth-brand :deep(.shell-brand em) { display: none; }.auth-brand :deep(.shell-brand small) { font-size: 8px; letter-spacing: 0; } } |
| | | </style> |
| | |
| | | <script lang="ts" setup> |
| | | import type { NotificationItem } from '@vben/layouts'; |
| | | |
| | | import type { SystemTenantApi } from '#/api/system/tenant'; |
| | | |
| | | import { computed, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { useAccess } from '@vben/access'; |
| | | import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui'; |
| | | import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; |
| | | import { isTenantEnable, useTabs, useWatermark } from '@vben/hooks'; |
| | | import { useWatermark } from '@vben/hooks'; |
| | | import { |
| | | AntdProfileOutlined, |
| | | BookOpenText, |
| | |
| | | Help, |
| | | LockScreen, |
| | | Notification, |
| | | TenantDropdown, |
| | | UserDropdown, |
| | | } from '@vben/layouts'; |
| | | import { preferences, usePreferences } from '@vben/preferences'; |
| | | import { useAccessStore, useUserStore } from '@vben/stores'; |
| | | import { formatDateTime, openWindow } from '@vben/utils'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | | import { |
| | | getUnreadNotifyMessageCount, |
| | |
| | | updateAllNotifyMessageRead, |
| | | updateNotifyMessageRead, |
| | | } from '#/api/system/notify/message'; |
| | | import { getSimpleTenantList } from '#/api/system/tenant'; |
| | | import { $t } from '#/locales'; |
| | | import { router } from '#/router'; |
| | | import { useAuthStore } from '#/store'; |
| | | import LoginForm from '#/views/_core/authentication/login.vue'; |
| | | import PortalBrandText from '#/components/portal/portal-brand-text.vue'; |
| | | |
| | | const userStore = useUserStore(); |
| | | const authStore = useAuthStore(); |
| | | const accessStore = useAccessStore(); |
| | | const { hasAccessByCodes } = useAccess(); |
| | | const { destroyWatermark, updateWatermark } = useWatermark(); |
| | | const { closeOtherTabs, refreshTab } = useTabs(); |
| | | |
| | | const notifications = ref<NotificationItem[]>([]); |
| | | const unreadCount = ref(0); |
| | |
| | | }, |
| | | icon: AntdProfileOutlined, |
| | | text: $t('ui.widgets.profile'), |
| | | }, |
| | | { |
| | | handler: () => router.push({ name: 'Profile', query: { section: 'resetPwd' } }), |
| | | icon: 'lucide:shield-check', |
| | | text: 'å¯ç 设置', |
| | | }, |
| | | // { |
| | | // handler: () => { |
| | |
| | | handleNotificationGetUnreadCount(); |
| | | } |
| | | |
| | | // ç§æ·å表 |
| | | const tenants = ref<SystemTenantApi.Tenant[]>([]); |
| | | const tenantEnable = computed( |
| | | () => hasAccessByCodes(['system:tenant:visit']) && isTenantEnable(), |
| | | ); |
| | | |
| | | /** è·åç§æ·å表 */ |
| | | async function handleGetTenantList() { |
| | | if (tenantEnable.value) { |
| | | tenants.value = await getSimpleTenantList(); |
| | | } |
| | | } |
| | | |
| | | /** å¤çç§æ·åæ¢ */ |
| | | async function handleTenantChange(tenant: SystemTenantApi.Tenant) { |
| | | if (!tenant || !tenant.id) { |
| | | message.error('åæ¢ç§æ·å¤±è´¥'); |
| | | return; |
| | | } |
| | | // 设置访é®ç§æ· ID |
| | | accessStore.setVisitTenantId(tenant.id as number); |
| | | // å
³éå
¶ä»æ ç¾é¡µï¼åªä¿çå½å页 |
| | | await closeOtherTabs(); |
| | | // å·æ°å½åé¡µé¢ |
| | | await refreshTab(); |
| | | // æç¤ºåæ¢æå |
| | | message.success(`忢å½åç§æ·ä¸º: ${tenant.name}`); |
| | | } |
| | | |
| | | // ========== åå§å ========== |
| | | onMounted(() => { |
| | | // 馿¬¡å è½½æªè¯»æ°é |
| | | handleNotificationGetUnreadCount(); |
| | | // è·åç§æ·å表 |
| | | handleGetTenantList(); |
| | | // è½®è¯¢å·æ°æªè¯»æ°é |
| | | setInterval( |
| | | () => { |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <BasicLayout @clear-preferences-and-logout="handleLogout"> |
| | | <BasicLayout class="supply-portal-shell" @clear-preferences-and-logout="handleLogout" @click-logo="router.push('/analytics')"> |
| | | <template #logo-text> |
| | | <PortalBrandText /> |
| | | </template> |
| | | <template #user-dropdown> |
| | | <UserDropdown |
| | | show-text |
| | | :avatar |
| | | :menus |
| | | :text="userStore.userInfo?.nickname" |
| | |
| | | @logout="handleLogout" |
| | | @clear-preferences-and-logout="handleLogout" |
| | | /> |
| | | </template> |
| | | <template #header-right-165> |
| | | <button type="button" class="portal-personal-entry" aria-label="个人ä¸å¿" title="个人ä¸å¿" :class="{ 'is-current': router.currentRoute.value.name === 'Profile' }" @click="router.push({ name: 'Profile' })"><IconifyIcon icon="lucide:user-round" /><span>个人ä¸å¿</span></button> |
| | | </template> |
| | | <template #notification> |
| | | <Notification |
| | |
| | | @on-click="handleClick" |
| | | /> |
| | | </template> |
| | | <!-- <template #header-right-1> |
| | | <div v-if="tenantEnable"> |
| | | <TenantDropdown |
| | | class="mr-2" |
| | | :tenant-list="tenants" |
| | | :visit-tenant-id="accessStore.visitTenantId" |
| | | @success="handleTenantChange" |
| | | /> |
| | | </div> |
| | | </template> --> |
| | | <template #header-right-170> |
| | | <template v-if="router.hasRoute('MesWorkbench') && accessStore.accessMenus.some((menu) => /ç产|MES/i.test(menu.name))" #header-right-170> |
| | | <button |
| | | type="button" |
| | | class="flex cursor-pointer items-center gap-1.5 rounded-md border-none bg-primary/15 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/25" |
| | |
| | | import { initPreferences, preferencesManager } from './packages/preferences/src'; |
| | | import { unmountGlobalLoading } from './packages/utils/src'; |
| | | |
| | | import { overridesPreferences, preferencesExtension } from './preferences'; |
| | | import { overridesPreferences, portalAppearancePreferences, preferencesExtension } from './preferences'; |
| | | |
| | | declare const __APP_LOGO__: string; |
| | | declare const __APP_FAVICON__: string; |
| | |
| | | overrides: overridesPreferences, |
| | | }); |
| | | |
| | | // 䏿¬¡æ§è¿ç§»å¤è§ï¼é¿å
å·²æå好ç¼å让æ°çèåå¸å±ä¸çæã |
| | | const appearanceKey = `${namespace}-portal-appearance-v2`; |
| | | if (!localStorage.getItem(appearanceKey)) { |
| | | preferencesManager.updatePreferences(portalAppearancePreferences); |
| | | localStorage.setItem(appearanceKey, '1'); |
| | | } |
| | | |
| | | // 强å¶è¦çå
¬å¸ logo å faviconï¼ç»è¿ localStorage ç¼å |
| | | if (__APP_LOGO__) { |
| | | preferencesManager.updatePreferences({ logo: { source: __APP_LOGO__ } }); |
| | |
| | | :style="style" |
| | | class="top-0 flex w-full flex-[0_0_auto] items-center border-b border-border bg-header pl-2 transition-[margin-top] duration-200" |
| | | > |
| | | <div v-if="slots.logo" :style="logoStyle"> |
| | | <div v-if="slots.logo" :style="logoStyle" class="portal-header-brand"> |
| | | <slot name="logo"></slot> |
| | | </div> |
| | | |
| | |
| | | // 妿䏿¯æ··å导èªï¼tabbar ç宽度为 100% |
| | | if (!isMixedNav.value || props.sidebarHidden) { |
| | | width = '100%'; |
| | | } else if (sidebarEnable.value) { |
| | | // é¼ æ å¨ä¾§è¾¹æ 䏿¶ï¼ä¸ä¾§è¾¹æ å±å¼æ¶ç宽度 |
| | | const onHoveringWidth = sidebarExpandOnHover.value |
| | | ? props.sidebarWidth |
| | | : getSideCollapseWidth.value; |
| | | |
| | | // 设置 marginLeftï¼æ ¹æ®ä¾§è¾¹æ æ¯å¦æå æ¥å³å® |
| | | marginLeft = sidebarCollapse.value |
| | | } else if (sidebarEnable.value && showSidebar.value && !props.isMobile) { |
| | | // æ ç¾æ ä¸å®é
ä¾§æ 对é½ï¼ä¸´æ¶æ¬åå±å¼æ¶æä¿çæå 宽度ã |
| | | const occupiedWidth = sidebarExpandOnHovering.value && !sidebarExpandOnHover.value |
| | | ? getSideCollapseWidth.value |
| | | : onHoveringWidth; |
| | | |
| | | // 设置 tabbar ç宽度ï¼è®¡ç®æ¹å¼ä¸º 100% åå»ä¾§è¾¹æ ç宽度 |
| | | width = `calc(100% - ${sidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`; |
| | | : getSidebarWidth.value; |
| | | marginLeft = occupiedWidth; |
| | | width = `calc(100% - ${occupiedWidth}px)`; |
| | | } else { |
| | | // é»è®¤æ
åµä¸ï¼tabbar ç宽度为 100% |
| | | width = '100%'; |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="relative flex min-h-full w-full"> |
| | | <div class="relative flex h-full min-h-0 w-full"> |
| | | <LayoutSidebar |
| | | v-if="sidebarEnableState" |
| | | v-model:draggable="sidebarDraggable" |
| | |
| | | |
| | | <div |
| | | ref="contentRef" |
| | | class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in" |
| | | class="flex min-h-0 flex-1 flex-col overflow-hidden transition-all duration-300 ease-in" |
| | | > |
| | | <div |
| | | :class="[ |
| | |
| | | <template #toggle-button> |
| | | <VbenIconButton |
| | | v-if="showHeaderToggleButton" |
| | | :aria-label="sidebarCollapse ? 'å±å¼å¯¼èªèå' : 'æ¶èµ·å¯¼èªèå'" |
| | | class="my-0 mr-1 rounded-md" |
| | | @click="handleHeaderToggle" |
| | | > |
| | |
| | | :padding-right="contentPaddingRight" |
| | | :padding-top="contentPaddingTop" |
| | | :style="contentStyle" |
| | | class="transition-[margin-top] duration-200" |
| | | class="min-h-0 transition-[margin-top] duration-200" |
| | | > |
| | | <slot name="content"></slot> |
| | | |
| | |
| | | const computedStyle = getComputedStyle(menuItem); |
| | | const marginLeft = Number.parseInt(computedStyle.marginLeft, 10); |
| | | const marginRight = Number.parseInt(computedStyle.marginRight, 10); |
| | | return menuItem.offsetWidth + marginLeft + marginRight || 0; |
| | | return menuItem.getBoundingClientRect().width + marginLeft + marginRight || 0; |
| | | } |
| | | |
| | | function calcSliceIndex() { |
| | |
| | | (item.nodeName !== '#text' || item.nodeValue), |
| | | ) as HTMLElement[]; |
| | | |
| | | const moreItemWidth = 46; |
| | | const computedMenuStyle = getComputedStyle(menu?.value); |
| | | const moreItemWidth = Number.parseFloat(computedMenuStyle.getPropertyValue('--menu-overflow-width')) || 46; |
| | | |
| | | const paddingLeft = Number.parseInt(computedMenuStyle.paddingLeft, 10); |
| | | const paddingRight = Number.parseInt(computedMenuStyle.paddingRight, 10); |
| | | const menuWidth = menu.value?.clientWidth - paddingLeft - paddingRight; |
| | | |
| | | // Flexible portal items may fill the entire row. Reserve âmoreâ only when |
| | | // the complete menu actually overflows, otherwise the final item is hidden. |
| | | const itemWidths = items.map(calcMenuItemWidth); |
| | | if (itemWidths.reduce((total, width) => total + width, 0) <= menuWidth + 1) { |
| | | return -1; |
| | | } |
| | | |
| | | let calcWidth = 0; |
| | | let sliceIndex = 0; |
| | | items.forEach((item, index) => { |
| | | calcWidth += calcMenuItemWidth(item); |
| | | itemWidths.forEach((width, index) => { |
| | | calcWidth += width; |
| | | if (calcWidth <= menuWidth - moreItemWidth) { |
| | | sliceIndex = index + 1; |
| | | } |
| | |
| | | </template> |
| | | <SubMenu is-sub-menu-more path="sub-menu-more"> |
| | | <template #title> |
| | | <Ellipsis class="size-4" /> |
| | | <Ellipsis class="size-4" aria-hidden="true" /> |
| | | <span v-if="overflowLabel" class="menu-overflow-label">{{ overflowLabel }}</span> |
| | | </template> |
| | | <template v-for="(item, index) in getSlot.slotMore" :key="index"> |
| | | <component :is="item" /> |
| | |
| | | |
| | | import type { MenuItemRegistered, MenuProvider, SubMenuProps } from '../types'; |
| | | |
| | | import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'; |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'; |
| | | |
| | | import { useNamespace } from '../../../../composables/src'; |
| | | import { VbenHoverCard } from '../../../shadcn-ui/src'; |
| | |
| | | const items = ref<MenuProvider['items']>({}); |
| | | const subMenus = ref<MenuProvider['subMenus']>({}); |
| | | const timer = ref<null | ReturnType<typeof setTimeout>>(null); |
| | | const popupRef = ref<HTMLElement>(); |
| | | const popupTriggerRef = ref<InstanceType<typeof SubMenuContent>>(); |
| | | |
| | | function togglePopup() { |
| | | if (props.disabled) return; |
| | | if (timer.value) window.clearTimeout(timer.value); |
| | | if (opened.value) rootMenu.closeMenu(props.path, parentPaths.value); |
| | | else rootMenu.openMenu(props.path, parentPaths.value); |
| | | } |
| | | |
| | | async function handlePopupKeydown(event: KeyboardEvent) { |
| | | if (props.disabled) return; |
| | | if (['Enter', ' ', 'ArrowDown'].includes(event.key)) { |
| | | event.preventDefault(); |
| | | event.stopPropagation(); |
| | | rootMenu.openMenu(props.path, parentPaths.value); |
| | | await nextTick(); |
| | | popupRef.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus(); |
| | | } else if (event.key === 'Escape') { |
| | | event.preventDefault(); |
| | | event.stopPropagation(); |
| | | rootMenu.closeMenu(props.path, parentPaths.value); |
| | | const trigger = popupTriggerRef.value?.$el; |
| | | if (trigger instanceof HTMLElement) trigger.focus(); |
| | | } |
| | | } |
| | | |
| | | createSubMenuContext({ |
| | | addSubMenu, |
| | |
| | | > |
| | | <template #trigger> |
| | | <SubMenuContent |
| | | ref="popupTriggerRef" |
| | | :class="is('active', active)" |
| | | :icon="menuIcon" |
| | | :is-menu-more="isSubMenuMore" |
| | | :is-top-level-menu-submenu="isTopLevelMenuSubmenu" |
| | | :level="currentLevel" |
| | | :path="path" |
| | | @click.stop="handleClick" |
| | | role="menuitem" |
| | | :tabindex="disabled ? -1 : 0" |
| | | aria-haspopup="menu" |
| | | :aria-expanded="opened" |
| | | @click.stop="togglePopup" |
| | | @keydown="handlePopupKeydown" |
| | | > |
| | | <template #title> |
| | | <slot name="title"></slot> |
| | |
| | | </SubMenuContent> |
| | | </template> |
| | | <div |
| | | ref="popupRef" |
| | | :class="[nsMenu.is(mode, true), nsMenu.e('popup')]" |
| | | @keydown.esc="handlePopupKeydown" |
| | | @focus="(e) => handleMouseenter(e, 100)" |
| | | @mouseenter="(e) => handleMouseenter(e, 100)" |
| | | @mouseleave="() => handleMouseleave(true)" |
| | |
| | | <ul |
| | | :class="[nsMenu.b(), is('rounded', rounded)]" |
| | | :style="subMenuStyle" |
| | | role="menu" |
| | | > |
| | | <slot></slot> |
| | | </ul> |
| | |
| | | */ |
| | | mode?: 'horizontal' | 'vertical'; |
| | | |
| | | /** 横åèåæ¶çº³å
¥å£çæåï¼ä¸ºç©ºæ¶ä»
å±ç¤ºå¾æ ã */ |
| | | overflowLabel?: string; |
| | | |
| | | /** |
| | | * @zh_CN æ¯å¦åæ¶¦é£æ ¼ |
| | | * @default true |
| | |
| | | } |
| | | |
| | | export function isTenantEnable(): boolean { |
| | | return import.meta.env.VITE_APP_TENANT_ENABLE === 'true'; |
| | | return import.meta.env.VITE_COMPANY !== 'JSSM' && import.meta.env.VITE_APP_TENANT_ENABLE === 'true'; |
| | | } |
| | | |
| | | export function isCaptchaEnable(): boolean { |
| | |
| | | |
| | | const rightSlots = computed(() => { |
| | | const list: Array<SlotItem> = []; |
| | | if (preferences.widget.refresh) { |
| | | list.push({ index: REFERENCE_VALUE - 10, name: 'refresh' }); |
| | | } |
| | | // å
¨å±æç´¢ |
| | | if (preferences.widget.globalSearch) { |
| | | list.push({ |
| | |
| | | |
| | | const leftSlots = computed(() => { |
| | | const list: Array<SlotItem> = []; |
| | | // å·æ° |
| | | if (preferences.widget.refresh) { |
| | | list.push({ |
| | | index: 0, |
| | | name: 'refresh', |
| | | }); |
| | | } |
| | | |
| | | Object.keys(slots).forEach((key) => { |
| | | // éé
ææ§½åç§°ï¼ä¾å¦ç¬¬ä¸ä¸ªææ§½åï¼header-left-1 |
| | | if (key.startsWith('header-left')) { |
| | |
| | | v-for="slot in leftSlots.filter((item) => item.index < REFERENCE_VALUE)" |
| | | :key="slot.name" |
| | | > |
| | | <slot :name="slot.name"> |
| | | <template v-if="slot.name === 'refresh'"> |
| | | <VbenIconButton class="my-0 mr-1 rounded-md" @click="refresh"> |
| | | <RotateCw class="size-4" /> |
| | | </VbenIconButton> |
| | | </template> |
| | | </slot> |
| | | <slot :name="slot.name"></slot> |
| | | </template> |
| | | <div class="flex-center hidden lg:block"> |
| | | <slot name="breadcrumb"></slot> |
| | | </div> |
| | | <div v-if="preferences.app.layout === 'mixed-nav'" class="portal-header-motto">弿¾åä½ã妿¯ç ç©¶ãæå¡åå±</div> |
| | | <template |
| | | v-for="slot in leftSlots.filter((item) => item.index > REFERENCE_VALUE)" |
| | | :key="slot.name" |
| | |
| | | </template> |
| | | <div |
| | | :class="`menu-align-${preferences.header.menuAlign}`" |
| | | class="flex h-full min-w-0 flex-1 items-center" |
| | | class="portal-header-navigation flex h-full min-w-0 flex-1 items-center" |
| | | > |
| | | <slot name="menu"></slot> |
| | | </div> |
| | | <div class="flex h-full min-w-0 shrink-0 items-center"> |
| | | <div class="portal-header-tools flex h-full min-w-0 shrink-0 items-center"> |
| | | <template v-for="slot in rightSlots" :key="slot.name"> |
| | | <slot :name="slot.name"> |
| | | <template v-if="slot.name === 'global-search'"> |
| | | <template v-if="slot.name === 'refresh'"> |
| | | <VbenIconButton aria-label="å·æ°å½å页é¢" title="å·æ°å½å页é¢" class="my-0 mr-1 rounded-md" @click="refresh"> |
| | | <RotateCw class="size-4" /> |
| | | </VbenIconButton> |
| | | </template> |
| | | <template v-else-if="slot.name === 'global-search'"> |
| | | <div class="portal-header-search"> |
| | | <GlobalSearch |
| | | :presentation="preferences.app.layout === 'mixed-nav' ? 'portal' : 'compact'" |
| | | :enable-shortcut-key="globalSearchShortcutKey" |
| | | :menus="accessStore.accessMenus" |
| | | class="mr-1 sm:mr-4" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <template v-else-if="slot.name === 'database-backup'"> |
| | |
| | | } |
| | | |
| | | const route = useRoute(); |
| | | const isPortalHome = computed(() => route.path === '/analytics'); |
| | | |
| | | onMounted(() => { |
| | | autoCollapseMenuByRouteMeta(route); |
| | |
| | | |
| | | <template> |
| | | <VbenAdminLayout |
| | | :class="{ 'is-portal-home': isPortalHome }" |
| | | v-model:sidebar-extra-visible="sidebarExtraVisible" |
| | | :content-compact="preferences.app.contentCompact" |
| | | :content-compact-width="preferences.app.contentCompactWidth" |
| | | :content-padding="preferences.app.contentPadding" |
| | | :content-padding-bottom="preferences.app.contentPaddingBottom" |
| | | :content-padding-left="preferences.app.contentPaddingLeft" |
| | | :content-padding-right="preferences.app.contentPaddingRight" |
| | | :content-padding-top="preferences.app.contentPaddingTop" |
| | | :content-padding="isPortalHome ? 0 : preferences.app.contentPadding" |
| | | :content-padding-bottom="isPortalHome ? 0 : preferences.app.contentPaddingBottom" |
| | | :content-padding-left="isPortalHome ? 0 : preferences.app.contentPaddingLeft" |
| | | :content-padding-right="isPortalHome ? 0 : preferences.app.contentPaddingRight" |
| | | :content-padding-top="isPortalHome ? 0 : preferences.app.contentPaddingTop" |
| | | :footer-enable="preferences.footer.enable" |
| | | :footer-fixed="preferences.footer.fixed" |
| | | :footer-height="preferences.footer.height" |
| | | :header-height="preferences.header.height" |
| | | :header-height="isMixedNav && !isMobile ? 158 : preferences.header.height" |
| | | :header-hidden="preferences.header.hidden" |
| | | :header-mode="preferences.header.mode" |
| | | :header-theme="headerTheme" |
| | |
| | | :sidebar-theme-sub="sidebarThemeSub" |
| | | :sidebar-width="preferences.sidebar.width" |
| | | :side-collapse-width="preferences.sidebar.collapseWidth" |
| | | :tabbar-enable="preferences.tabbar.enable" |
| | | :tabbar-enable="preferences.tabbar.enable && !isPortalHome" |
| | | :tabbar-height="preferences.tabbar.height" |
| | | :z-index="preferences.app.zIndex" |
| | | @side-mouse-leave="handleSideMouseLeave" |
| | |
| | | <VbenLogo |
| | | v-if="preferences.logo.enable" |
| | | :fit="preferences.logo.fit" |
| | | :logo-size="isMixedNav && !isMobile ? 56 : 32" |
| | | :class="logoClass" |
| | | :collapsed="logoCollapsed" |
| | | :src="preferences.logo.source" |
| | |
| | | :menus="wrapperMenus(headerMenus)" |
| | | :rounded="isMenuRounded" |
| | | :theme="headerTheme" |
| | | class="w-full" |
| | | class="portal-header-menu w-full" |
| | | mode="horizontal" |
| | | overflow-label="æ´å¤" |
| | | @select="handleMenuSelect" |
| | | /> |
| | | </template> |
| | |
| | | :default-active="defaultActive" |
| | | :menus="menus" |
| | | :mode="mode" |
| | | :overflow-label="overflowLabel" |
| | | :rounded="rounded" |
| | | scroll-to-active |
| | | :theme="theme" |
| | |
| | | }); |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ enableShortcutKey?: boolean; menus?: MenuRecordRaw[] }>(), |
| | | defineProps<{ enableShortcutKey?: boolean; menus?: MenuRecordRaw[]; presentation?: 'compact' | 'portal' }>(), |
| | | { |
| | | enableShortcutKey: true, |
| | | menus: () => [], |
| | | presentation: 'compact', |
| | | }, |
| | | ); |
| | | |
| | |
| | | </div> |
| | | </template> |
| | | </Modal> |
| | | <button v-if="presentation === 'portal'" type="button" class="portal-search-trigger" aria-label="æç´¢èåä¸ä¸å¡åè½" aria-haspopup="dialog" :aria-expanded="open" @click="toggleOpen"> |
| | | <span>æç´¢èåä¸ä¸å¡åè½â¦</span><span class="portal-search-submit"><Search class="size-4" /></span> |
| | | </button> |
| | | <div |
| | | v-else |
| | | class="group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-hidden md:bg-accent" |
| | | @click="toggleOpen()" |
| | | > |
| | |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .portal-search-trigger { display: flex; width: 100%; height: 32px; align-items: center; justify-content: space-between; padding: 0 0 0 12px; border: 1px solid #d4e2ee; border-radius: 5px; color: #5c7187; background: #fff; font-size: 12px; text-align: left; cursor: pointer; } |
| | | .portal-search-submit { display: grid; width: 42px; height: 32px; place-items: center; border-radius: 0 5px 5px 0; color: #fff; background: #b7242d; } |
| | | .portal-search-trigger:hover { border-color: #95b3cf; } |
| | | .portal-search-trigger:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; } |
| | | @media (max-width: 767px) { .portal-search-trigger { width: 30px; height: 28px; padding: 0; border: 0; background: transparent; } .portal-search-trigger > span:first-child { display: none; } .portal-search-submit { width: 28px; height: 28px; border-radius: 4px; } } |
| | | </style> |
| | |
| | | const loginDescription = ref(companyConfigStore.loginDescription); |
| | | const logoUrl = ref(companyConfigStore.logoUrl); |
| | | const uploading = ref(false); |
| | | const fixedPortalName = import.meta.env.VITE_COMPANY === 'JSSM'; |
| | | |
| | | // æå¼åºåæ¶ä»å端æåææ°å
¬å¸é
置并å填表åï¼store åå¼å¯è½ä¸ºç©ºï¼å å¯å¨å è½½èµ°çæ¯çº¯å½æ°ï¼ |
| | | onMounted(async () => { |
| | |
| | | </span> |
| | | <Input |
| | | v-model:value="companyName" |
| | | :disabled="fixedPortalName" |
| | | :placeholder="$t('preferences.company.companyNamePlaceholder')" |
| | | class="w-41.25" |
| | | allow-clear |
| | |
| | | * ææ¬ |
| | | */ |
| | | text?: string; |
| | | /** Display the account name beside the avatar in a portal toolbar. */ |
| | | showText?: boolean; |
| | | /** è§¦åæ¹å¼ */ |
| | | trigger?: 'both' | 'click' | 'hover'; |
| | | /** hoverè§¦åæ¶ï¼å»¶è¿ååºçæ¶é´ */ |
| | |
| | | showShortcutKey: true, |
| | | tagText: '', |
| | | text: '', |
| | | showText: false, |
| | | trigger: 'click', |
| | | hoverDelay: 500, |
| | | }); |
| | |
| | | /> |
| | | |
| | | <DropdownMenu v-model:open="openPopover"> |
| | | <DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'"> |
| | | <div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent"> |
| | | <DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'" :aria-label="`è´¦å·èåï¼${text || tagText || 'å½åç¨æ·'}`"> |
| | | <div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent" :class="{ 'portal-account-trigger': showText }"> |
| | | <div class="flex-center hover:text-accent-foreground"> |
| | | <VbenAvatar :alt="text" :src="avatar" class="size-8" dot /> |
| | | </div> |
| | | <span v-if="showText" class="portal-account-name">{{ text || tagText || 'æçè´¦å·' }}</span><span v-if="showText" class="portal-account-chevron" aria-hidden="true">â</span> |
| | | </div> |
| | | </DropdownMenuTrigger> |
| | | <DropdownMenuContent class="mr-2 min-w-60 p-0 pb-1"> |
| | |
| | | </DropdownMenuContent> |
| | | </DropdownMenu> |
| | | </template> |
| | | <style scoped> |
| | | .portal-account-trigger { display: flex; height: 28px; align-items: center; gap: 7px; padding: 2px 7px; border-radius: 4px; }.portal-account-trigger :deep(.size-8) { width: 22px; height: 22px; }.portal-account-name { max-width: 110px; overflow: hidden; color: var(--portal-ink, currentColor); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.portal-account-chevron { color: #4b6d8b; font-size: 14px; }@media(max-width:767px) { .portal-account-name { max-width: 68px; }.portal-account-trigger { gap: 5px; } } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /* 鍿·å
±ç¨è²æ¿ï¼ä¸å¡é¡µé¢å认è¯é¡µé¢å
±äº«åçä¸è¡¨é¢å±çº§ã */ |
| | | :root:not(.dark) { |
| | | --background-deep: 210 50% 97%; |
| | | --foreground: 213 51% 23%; |
| | | --muted: 210 43% 96%; |
| | | --muted-foreground: 213 19% 43%; |
| | | --border: 211 37% 89%; |
| | | --input: 211 29% 81%; |
| | | --input-placeholder: 214 13% 47%; |
| | | --accent: 210 55% 95%; |
| | | --accent-foreground: 213 51% 23%; |
| | | --header: 0 0% 100%; |
| | | --sidebar: 0 0% 100%; |
| | | --sidebar-deep: 211 42% 96%; |
| | | --portal-blue: #1b4f80; |
| | | --portal-red: #b7242d; |
| | | --portal-ink: #18365a; |
| | | --portal-muted: #5c7187; |
| | | --portal-surface: #fff; |
| | | } |
| | | |
| | | :root.dark { |
| | | --portal-blue: #82b4e5; |
| | | --portal-red: #f38f96; |
| | | --portal-ink: #e8eef7; |
| | | --portal-muted: #a7bbd0; |
| | | --portal-surface: #14283e; |
| | | } |
| | | |
| | | .supply-portal-shell { color: var(--portal-ink); } |
| | | .shell-brand { display: flex; min-width: 0; flex-direction: column; gap: 5px; } |
| | | .shell-brand-main { display: flex; min-width: 0; align-items: center; gap: 20px; } |
| | | .shell-brand strong { overflow: hidden; color: var(--portal-ink); font-size: 25px; font-weight: 750; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } |
| | | .shell-brand em { flex: none; padding-left: 20px; border-left: 1px solid #6c89a3; color: #234c74; font-size: 20px; font-style: normal; font-weight: 650; white-space: nowrap; } |
| | | .shell-brand small { color: #355f86; font-size: 10px; font-weight: 400; letter-spacing: .045em; } |
| | | .portal-header-motto { display: none; } |
| | | .supply-portal-shell .portal-header-tools { gap: 2px; } |
| | | .supply-portal-shell .portal-header-search { width: 270px; } |
| | | .portal-personal-entry { display: inline-flex; min-height: 28px; align-items: center; gap: 5px; margin: 0 8px 0 4px; padding: 3px 8px; border: 0; border-radius: 4px; color: #315a80; background: transparent; font-size: 12px; cursor: pointer; }.portal-personal-entry svg { width: 14px; height: 14px; }.portal-personal-entry:hover,.portal-personal-entry.is-current { color: #a81d27; background: #fff0f0; }.portal-personal-entry:focus-visible { outline: 2px solid #2c70b2; outline-offset: 1px; } |
| | | @media (min-width: 768px) { |
| | | .supply-portal-shell header.bg-header { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 32px 80px 46px; padding: 0; border-bottom: 1px solid #d3e3f0; background: #fff; box-shadow: 0 3px 10px rgb(28 72 113 / 4%); } |
| | | .supply-portal-shell header.bg-header::after { position: absolute; right: 0; bottom: 0; left: 0; height: 46px; border-top: 1px solid #e1ebf4; background: #f5f9fd; content: ''; } |
| | | .supply-portal-shell header .portal-header-brand { z-index: 1; grid-area: 2 / 1; justify-self: center; width: min(1280px, calc(100% - 80px)); max-width: none; height: 80px; margin: 0; border: 0; } |
| | | .supply-portal-shell header .portal-header-brand > div > a { max-width: calc(100% - 350px); gap: 16px; padding: 0; } |
| | | .supply-portal-shell .portal-header-navigation { z-index: 1; grid-area: 3 / 1; justify-self: center; width: min(1280px, calc(100% - 80px)); height: 46px; padding: 0; } |
| | | .supply-portal-shell .portal-header-tools { z-index: 2; grid-area: 1 / 1; width: 100%; height: 32px; justify-content: flex-end; gap: 2px; padding: 0 max(40px, calc((100vw - 1280px) / 2)); border: 0; background: #e4f1fb; } |
| | | .supply-portal-shell .portal-header-tools button { max-height: 28px; } |
| | | .supply-portal-shell .portal-header-search { position: absolute; top: 70px; right: max(40px, calc((100vw - 1280px) / 2)); width: 300px; } |
| | | .supply-portal-shell .portal-header-tools .portal-header-search button { max-height: none; } |
| | | .supply-portal-shell .portal-header-motto { position: absolute; top: 42px; right: max(40px, calc((100vw - 1280px) / 2)); z-index: 1; display: block; color: #254e76; font-family: 'KaiTi', 'STKaiti', serif; font-size: 16px; letter-spacing: .16em; } |
| | | .supply-portal-shell header .portal-header-menu.is-horizontal { --menu-overflow-width: 92px; --menu-font-size: 14px; --menu-item-height: 46px; --menu-item-active-color: #fff; --menu-item-active-background-color: #b7242d; --menu-submenu-active-color: #fff; --menu-submenu-active-background-color: #b7242d; justify-content: space-between; gap: 0; width: 100%; background: transparent; } |
| | | .supply-portal-shell header .portal-header-menu > .vben-menu-item, .supply-portal-shell header .portal-header-menu > .vben-sub-menu { flex: 1 0 auto; margin: 0; } |
| | | .supply-portal-shell header .portal-header-menu > .vben-menu-item, .supply-portal-shell header .portal-header-menu > .vben-sub-menu .vben-sub-menu-content { min-width: 96px; height: 46px; justify-content: center; margin: 0; padding: 0 18px; border-radius: 0; color: #18365a; background: transparent; font-weight: 650; } |
| | | .supply-portal-shell header .portal-header-menu .vben-menu-item__content { height: 100%; justify-content: center; color: inherit; } |
| | | .supply-portal-shell header .portal-header-menu > .vben-menu-item .vben-menu__icon { display: none; } |
| | | .supply-portal-shell header .portal-header-menu > .vben-menu-item.is-active, .supply-portal-shell header .portal-header-menu > .vben-sub-menu .vben-sub-menu-content.is-active { color: #fff; background: #b7242d; } |
| | | .supply-portal-shell header .portal-header-menu > .vben-menu-item:not(.is-active):hover, .supply-portal-shell header .portal-header-menu > .vben-sub-menu .vben-sub-menu-content:not(.is-active):hover { color: #a31d28; background: #eaf2fa; } |
| | | .supply-portal-shell header .portal-header-menu > .vben-sub-menu:has(.is-more) { flex: 0 0 92px; } |
| | | .supply-portal-shell header .portal-header-menu .vben-sub-menu-content.is-more { min-width: 88px; gap: 6px; } |
| | | .supply-portal-shell header .portal-header-menu .is-more .vben-sub-menu-content__title { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } |
| | | .supply-portal-shell header .menu-overflow-label { color: inherit; font-size: 13px; } |
| | | .dark .supply-portal-shell header.bg-header { background: #14283e; } |
| | | .dark .supply-portal-shell header.bg-header::after { background: #19344f; border-color: #36516a; } |
| | | .dark .supply-portal-shell .portal-header-tools { background: #0f2236; } |
| | | .dark .supply-portal-shell .portal-header-motto, .dark .supply-portal-shell .shell-brand em, .dark .supply-portal-shell .shell-brand small { color: #c1d7ec; } |
| | | .dark .supply-portal-shell header .portal-header-menu > .vben-menu-item, .dark .supply-portal-shell header .portal-header-menu > .vben-sub-menu .vben-sub-menu-content { color: #e2eef9; } |
| | | } |
| | | .supply-portal-shell aside > div:first-child { border-right-color: #dbe6f0; } |
| | | @media(min-width:768px) and (max-width:1100px) { .supply-portal-shell header .shell-brand em { display: none; }.supply-portal-shell header .shell-brand strong { font-size: 21px; }.supply-portal-shell .portal-header-search { width: 250px; }.supply-portal-shell header .portal-header-brand > div > a { max-width: calc(100% - 280px); }.supply-portal-shell .portal-header-motto { font-size: 13px; letter-spacing: .1em; } } |
| | | .supply-portal-shell aside .vben-menu { padding-right: 9px; padding-left: 9px; } |
| | | .supply-portal-shell aside .vben-menu .vben-menu { padding-right: 0; padding-left: 0; } |
| | | .supply-portal-shell aside .vben-menu-item, |
| | | .supply-portal-shell aside .vben-sub-menu-content { min-height: 42px; margin-top: 3px; margin-bottom: 3px; border-radius: 5px; font-size: 13px; } |
| | | .supply-portal-shell aside .vben-menu.is-vertical .vben-menu-item.is-active { color: #fff; background: #1b4f80; } |
| | | .supply-portal-shell aside .vben-menu.is-vertical .vben-menu-item.is-active .vben-menu-item__content, |
| | | .supply-portal-shell aside .vben-menu.is-vertical .vben-menu-item.is-active .vben-menu__icon { color: #fff; } |
| | | .supply-portal-shell aside .vben-menu.is-vertical .vben-sub-menu-content.is-active { color: #1b4f80; background: #eaf2fa; font-weight: 600; } |
| | | .supply-portal-shell .vben-menu-item:focus-visible, |
| | | .supply-portal-shell .vben-sub-menu-content:focus-visible { outline: 2px solid #2c70b2; outline-offset: -2px; } |
| | | .supply-portal-shell .tab-item.is-active { color: #b7242d; background: #fff5f5; box-shadow: inset 0 -2px 0 #b7242d; } |
| | | .supply-portal-shell .ant-card { border-color: #e1eaf3; border-radius: 8px; } |
| | | .supply-portal-shell .ant-table-thead > tr > th { color: #254b70; background: #f1f6fb; font-weight: 600; } |
| | | .supply-portal-shell .ant-table-tbody > tr > td { border-bottom-color: #edf2f7; } |
| | | .supply-portal-shell .ant-table-tbody > tr.ant-table-row:hover > td { background: #f5f9fd; } |
| | | .supply-portal-shell .ant-btn-primary:not(:disabled) { box-shadow: 0 2px 4px rgb(130 25 35 / 8%); } |
| | | .supply-portal-shell .portal-home { max-width: none; padding-top: 0; padding-right: 0; padding-left: 0; } |
| | | .supply-portal-shell .portal-home .elite-card, |
| | | .supply-portal-shell .portal-home .elite-section { border-color: #e1eaf3; border-radius: 8px; background: var(--portal-surface); box-shadow: 0 4px 14px rgb(25 69 111 / 4%); } |
| | | .supply-portal-shell .portal-home .elite-bar { width: 3px; background: #b7242d; } |
| | | .supply-portal-shell .portal-home .elite-icon-tile { border: 0; color: #2468ac; background: #eaf3fd; box-shadow: none; } |
| | | .supply-portal-shell .portal-home .elite-title, |
| | | .supply-portal-shell .portal-home .elite-value, |
| | | .supply-portal-shell .portal-home .elite-row-name { color: var(--portal-ink); } |
| | | .supply-portal-shell .portal-home .elite-label, |
| | | .supply-portal-shell .portal-home .elite-meta, |
| | | .supply-portal-shell .portal-home .elite-row-time { color: var(--portal-muted); } |
| | | .supply-portal-shell .portal-home .elite-more { color: #2b639a; } |
| | | .supply-portal-shell .portal-home .elite-dot { background: #b7242d; } |
| | | .dark .supply-portal-shell .ant-table-thead > tr > th { color: #d0e3f6; background: #19334d; } |
| | | .dark .supply-portal-shell header .vben-menu-item:not(.is-active):hover, |
| | | .dark .supply-portal-shell header .vben-sub-menu-content:not(.is-active):hover { color: #d2e6f9; background: #223e59; } |
| | | .dark .supply-portal-shell header .portal-header-menu > .vben-menu-item.is-active, |
| | | .dark .supply-portal-shell header .portal-header-menu > .vben-sub-menu .vben-sub-menu-content.is-active { color: #d2e6f9; background: #223e59; } |
| | | .dark .supply-portal-shell header .portal-header-menu .is-active .vben-menu__icon { color: #a7c9e9; } |
| | | .dark .supply-portal-shell .tab-item.is-active { color: #ffc1c5; background: #462831; } |
| | | @media (max-width: 767px) { |
| | | .shell-brand strong { max-width: 160px; font-size: 16px; } |
| | | .shell-brand small { font-size: 10px; } |
| | | .supply-portal-shell header.bg-header { padding-right: 8px; } |
| | | .supply-portal-shell header .portal-header-brand { max-width: none; margin-right: 4px; border-right: 0; } |
| | | .supply-portal-shell .portal-header-navigation { padding-right: 0; } |
| | | .supply-portal-shell .portal-header-tools { gap: 0; padding-left: 4px; border-left: 0; } |
| | | .supply-portal-shell .portal-header-search { width: auto; } |
| | | .portal-personal-entry { width: 30px; margin: 0 2px; justify-content: center; padding: 0; }.portal-personal-entry span { display: none; } |
| | | .shell-brand em { display: none; } |
| | | } |
| | |
| | | tenantMode: 'multi' | 'single'; |
| | | } |
| | | |
| | | /** 鍿·å¤è§ï¼é¦æ¬¡å级æ¶åæ¥å°å·²æç¨æ·ï¼åç»ä¿çç¨æ·èªå·±çå好ã */ |
| | | export const portalAppearancePreferences = defineOverridesPreferences({ |
| | | app: { |
| | | layout: 'mixed-nav', |
| | | defaultAvatar: '/static/imgs/portal-avatar.svg', |
| | | contentPadding: 16, |
| | | contentPaddingTop: 16, |
| | | contentPaddingRight: 16, |
| | | contentPaddingBottom: 16, |
| | | contentPaddingLeft: 16, |
| | | }, |
| | | header: { height: 72, menuAlign: 'start', mode: 'fixed' }, |
| | | navigation: { split: true, accordion: true, styleType: 'rounded' }, |
| | | sidebar: { width: 228, autoActivateChild: true, expandOnHover: false, collapsed: false, hidden: false, enable: true }, |
| | | tabbar: { height: 40, styleType: 'plain', persist: false }, |
| | | theme: { |
| | | builtinType: 'custom', |
| | | colorPrimary: '#b7242d', |
| | | mode: 'light', |
| | | radius: '0.5', |
| | | semiDarkHeader: false, |
| | | semiDarkSidebar: false, |
| | | semiDarkSidebarSub: false, |
| | | }, |
| | | widget: { globalSearch: true, languageToggle: false, timezone: false }, |
| | | }); |
| | | |
| | | /** |
| | | * @description 项ç®é
ç½®æä»¶ |
| | | * åªéè¦è¦ç项ç®ä¸çä¸é¨åé
ç½®ï¼ä¸éè¦çé
ç½®ä¸ç¨è¦çï¼ä¼èªå¨ä½¿ç¨é»è®¤é
ç½® |
| | | * !!! æ´æ¹é
ç½®å请æ¸
空ç¼åï¼å¦åå¯è½ä¸çæ |
| | | */ |
| | | export const overridesPreferences = defineOverridesPreferences({ |
| | | ...portalAppearancePreferences, |
| | | // overrides |
| | | app: { |
| | | ...portalAppearancePreferences.app, |
| | | /** åç«¯è·¯ç±æ¨¡å¼ */ |
| | | accessMode: 'backend', |
| | | name: __APP_NAME__ || import.meta.env.VITE_APP_TITLE, |
| | |
| | | source: __APP_LOGO__ || undefined, |
| | | }, |
| | | tabbar: { |
| | | ...portalAppearancePreferences.tabbar, |
| | | /** å·æ°æµè§å¨ååªä¿çå½å页é¢ï¼ä¸æä¹
åæææ ç¾é¡µ */ |
| | | persist: false, |
| | | }, |
| | |
| | | label: 'preferences.antd.fields.reportTitle.label', |
| | | placeholder: 'preferences.antd.fields.reportTitle.placeholder', |
| | | }, |
| | | ], |
| | | ].filter((field) => import.meta.env.VITE_COMPANY !== 'JSSM' || field.key !== 'tenantMode'), |
| | | }); |
| | |
| | | import { convertServerMenuToRouteRecordStringComponent } from '@vben/utils'; |
| | | |
| | | import { BasicLayout, IFrameView } from '#/layouts'; |
| | | import { portalSystemMenus } from './portal-system-menus'; |
| | | |
| | | const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue'); |
| | | |
| | |
| | | // ç±äº yudao éè¿ accessStore 读åï¼æä»¥ä¸å¨è¿è¡ message.loading æç¤º |
| | | // è¡¥å
说æï¼accessStore.accessMenus ä¸å¼å§æ¯ AppRouteRecordRaw ç±»åï¼å端å è½½ï¼ï¼åé¢è¢«èµå¼æ MenuRecordRaw ç±»åï¼å端转æ¢ï¼ |
| | | const accessMenus = accessStore.accessMenus as AppRouteRecordRaw[]; |
| | | return convertServerMenuToRouteRecordStringComponent(accessMenus); |
| | | const portalMenus = import.meta.env.VITE_COMPANY === 'JSSM' |
| | | ? portalSystemMenus(accessMenus, accessStore.accessCodes, options.roles ?? []) |
| | | : accessMenus; |
| | | return convertServerMenuToRouteRecordStringComponent(portalMenus); |
| | | }, |
| | | // å¯ä»¥æå®æ²¡ææé跳转403é¡µé¢ |
| | | forbiddenComponent, |
| | |
| | | const dictStore = useDictStore(); |
| | | |
| | | // åºæ¬è·¯ç±ï¼è¿äºè·¯ç±ä¸éè¦è¿å
¥æéæ¦æª |
| | | if (coreRouteNames.includes(to.name as string)) { |
| | | // Personal settings are available to every signed-in account and still |
| | | // pass through token validation and user/menu initialization below. |
| | | if (to.name === 'Profile') to.meta.fullPathKey = false; |
| | | if (coreRouteNames.includes(to.name as string) && to.name !== 'Profile') { |
| | | if (to.path === LOGIN_PATH && accessStore.accessToken) { |
| | | return decodeURIComponent( |
| | | (to.query?.redirect as string) || |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { AppRouteRecordRaw } from '@vben/types'; |
| | | |
| | | /** åç»ç»é¨æ·åªå±ç¤ºå端已ææçç³»ç»åè½ï¼ä¸æç§æ·é¡µå½ä½ç³»ç»ç®¡çå
¥å£ã */ |
| | | const systemPages = [ |
| | | { name: 'ç¨æ·ç®¡ç', path: 'user', component: 'system/user/index', componentName: 'SystemUser', icon: 'lucide:users-round', permission: 'system:user:list' }, |
| | | { name: 'è§è²ç®¡ç', path: 'role', component: 'system/role/index', componentName: 'SystemRole', icon: 'lucide:shield-check', permission: 'system:role:query' }, |
| | | { name: 'é¨é¨ç®¡ç', path: 'dept', component: 'system/dept/index', componentName: 'SystemDept', icon: 'lucide:network', permission: 'system:dept:query' }, |
| | | { name: 'å²ä½ç®¡ç', path: 'post', component: 'system/post/index', componentName: 'SystemPost', icon: 'lucide:briefcase-business', permission: 'system:post:query' }, |
| | | { name: 'èå管ç', path: 'menu', component: 'system/menu/index', componentName: 'SystemMenu', icon: 'lucide:panels-top-left', permission: 'system:menu:query' }, |
| | | { name: 'åå
¸ç®¡ç', path: 'dict', component: 'system/dict/index', componentName: 'SystemDictType', icon: 'lucide:book-open', permission: 'system:dict:query' }, |
| | | { name: 'éç¥å
Œ', path: 'notice', component: 'system/notice/index', componentName: 'SystemNotice', icon: 'lucide:megaphone', permission: 'system:notice:query' }, |
| | | ] as const; |
| | | |
| | | function withoutTenantMenus(menus: AppRouteRecordRaw[]): AppRouteRecordRaw[] { |
| | | return menus.flatMap((menu) => { |
| | | if (/ç§æ·/.test(menu.name) || /(^|\/)tenant(?:Package)?(?:\/|$)/i.test(`${menu.path} ${menu.component ?? ''}`)) return []; |
| | | const children = menu.children ? withoutTenantMenus(menu.children) : undefined; |
| | | if (menu.children?.length && !children?.length) return []; |
| | | return [{ ...menu, children }]; |
| | | }); |
| | | } |
| | | |
| | | function hasPage(menus: AppRouteRecordRaw[], component: string): boolean { |
| | | return menus.some((menu) => menu.component === component || !!menu.children?.length && hasPage(menu.children, component)); |
| | | } |
| | | |
| | | export function portalSystemMenus( |
| | | source: AppRouteRecordRaw[], |
| | | permissions: string[], |
| | | roles: string[], |
| | | ): AppRouteRecordRaw[] { |
| | | const menus = withoutTenantMenus(source); |
| | | const system = menus.find((menu) => menu.path === '/system'); |
| | | const isAdmin = roles.includes('super_admin') || permissions.includes('*:*:*'); |
| | | const additions = systemPages.filter((page) => |
| | | (isAdmin || permissions.includes(page.permission)) && !hasPage(menus, page.component), |
| | | ); |
| | | if (!additions.length) return menus; |
| | | |
| | | const children = additions.map((page, index) => ({ |
| | | id: -100 - index, |
| | | parentId: 1, |
| | | path: page.path, |
| | | name: page.name, |
| | | component: page.component, |
| | | componentName: page.componentName, |
| | | icon: page.icon, |
| | | sort: index + 1, |
| | | visible: true, |
| | | meta: { title: page.name }, |
| | | })) as AppRouteRecordRaw[]; |
| | | if (system) { |
| | | system.children = [...(system.children ?? []), ...children]; |
| | | } else { |
| | | menus.push({ |
| | | id: -1, |
| | | parentId: 0, |
| | | path: '/system', |
| | | name: 'ç³»ç»ç®¡ç', |
| | | component: 'BasicLayout', |
| | | icon: 'lucide:settings-2', |
| | | sort: 90, |
| | | visible: true, |
| | | meta: { title: 'ç³»ç»ç®¡ç' }, |
| | | children, |
| | | } as AppRouteRecordRaw); |
| | | } |
| | | return menus; |
| | | } |
| | |
| | | name: 'Root', |
| | | path: '/', |
| | | redirect: preferences.app.defaultHomePath, |
| | | children: [], |
| | | children: [ |
| | | { |
| | | path: 'profile', |
| | | name: 'Profile', |
| | | component: () => import('#/views/_core/profile/index.vue'), |
| | | meta: { title: '个人ä¸å¿', icon: 'lucide:user-round', hideInMenu: true, fullPathKey: false }, |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| | | component: AuthPageLayout, |
| | |
| | | getCompanyConfig, |
| | | saveCompanyConfig as saveCompanyConfigApi, |
| | | } from '#/api/system/company-config'; |
| | | import { applyCompanyConfig } from '#/utils/company-config'; |
| | | import { applyCompanyConfig, displayCompanyName } from '#/utils/company-config'; |
| | | |
| | | /** |
| | | * å
¬å¸åçä¿¡æ¯å
¨å±é
ç½® |
| | |
| | | try { |
| | | const config = await getCompanyConfig(); |
| | | if (config) { |
| | | companyName.value = config.companyName ?? ''; |
| | | companyName.value = displayCompanyName(config.companyName) ?? ''; |
| | | logoUrl.value = config.logoUrl ?? ''; |
| | | loginTitle.value = config.loginTitle ?? ''; |
| | | loginDescription.value = config.loginDescription ?? ''; |
| | |
| | | loginTitle?: string; |
| | | loginDescription?: string; |
| | | }) { |
| | | await saveCompanyConfigApi(data); |
| | | companyName.value = data.companyName; |
| | | const normalized = { ...data, companyName: displayCompanyName(data.companyName) ?? data.companyName }; |
| | | await saveCompanyConfigApi(normalized); |
| | | companyName.value = normalized.companyName; |
| | | logoUrl.value = data.logoUrl ?? ''; |
| | | loginTitle.value = data.loginTitle ?? ''; |
| | | loginDescription.value = data.loginDescription ?? ''; |
| | |
| | | loginDescription?: string; |
| | | } |
| | | |
| | | export const societyPortalName = 'æ±èçä¾éåä½ç»æµå¦ä¼èå鍿·'; |
| | | |
| | | export function displayCompanyName(name?: string) { |
| | | return import.meta.env.VITE_COMPANY === 'JSSM' ? societyPortalName : name; |
| | | } |
| | | |
| | | /** |
| | | * å°å
¬å¸åçä¿¡æ¯åºç¨å°å
¨å±ï¼ |
| | | * - å
¬å¸å â preferences.app.name / copyright.companyNameï¼èå¨èåæ ãç»å½é¡µãç½é¡µæ é¢ãçæï¼ |
| | |
| | | */ |
| | | export function applyCompanyConfig(config: CompanyConfigData) { |
| | | const updates: Record<string, any> = {}; |
| | | const companyName = displayCompanyName(config.companyName); |
| | | if (config.logoUrl) { |
| | | updates.logo = { source: config.logoUrl }; |
| | | // 忥ç½é¡µæ é¢å¾æ ï¼faviconï¼ä¸ºä¸ä¼ ç logo |
| | | const faviconLink = document.querySelector('link[rel="icon"]'); |
| | | faviconLink?.setAttribute('href', config.logoUrl); |
| | | } |
| | | if (config.companyName) { |
| | | updates.app = { name: config.companyName }; |
| | | updates.copyright = { companyName: config.companyName }; |
| | | if (companyName) { |
| | | updates.app = { name: companyName }; |
| | | updates.copyright = { companyName }; |
| | | } |
| | | if (Object.keys(updates).length > 0) { |
| | | preferencesManager.updatePreferences(updates); |
| | | } |
| | | if (config.companyName) { |
| | | document.title = config.companyName; |
| | | if (companyName) { |
| | | document.title = companyName; |
| | | } |
| | | } |
| | |
| | | |
| | | defineOptions({ name: 'Login' }); |
| | | |
| | | // vite.config.ts 注å
¥çå
¬å¸ Logo è·¯å¾ |
| | | declare const __APP_LOGO__: string; |
| | | |
| | | const authStore = useAuthStore(); |
| | | const accessStore = useAccessStore(); |
| | | const tenantEnable = isTenantEnable(); |
| | |
| | | const verifyRef = ref(); |
| | | |
| | | const captchaType = 'blockPuzzle'; // éªè¯ç ç±»åï¼'blockPuzzle' | 'clickWord' |
| | | |
| | | /** åºç¨æ é¢ï¼ç»å½é¡µåçæåï¼ */ |
| | | const appTitle = import.meta.env.VITE_APP_TITLE || 'æ±èåè´¸'; |
| | | |
| | | /** åçå¾½æ ï¼å¤ç¨ vite 注å
¥çå
¬å¸ Logoï¼ç¼ºçåè½å°å
ç½®å¾æ */ |
| | | const brandLogo = __APP_LOGO__ || '/logos/JSSM.svg'; |
| | | |
| | | /** è·åç§æ·å表 */ |
| | | const tenantList = ref<AuthApi.TenantResult[]>([]); // ç§æ·å表 |
| | |
| | | { |
| | | component: 'VbenInput', |
| | | componentProps: { |
| | | autocomplete: 'username', |
| | | placeholder: $t('authentication.usernameTip'), |
| | | }, |
| | | hideLabel: false, |
| | | labelWidth: 44, |
| | | fieldName: 'username', |
| | | label: $t('authentication.username'), |
| | | rules: z |
| | |
| | | { |
| | | component: 'VbenInputPassword', |
| | | componentProps: { |
| | | autocomplete: 'current-password', |
| | | placeholder: $t('authentication.passwordTip'), |
| | | }, |
| | | hideLabel: false, |
| | | labelWidth: 44, |
| | | fieldName: 'password', |
| | | label: $t('authentication.password'), |
| | | rules: z |
| | |
| | | |
| | | <template> |
| | | <div class="academic-login-wrapper"> |
| | | <!-- é¡¶é¨åçæ è¯ï¼å¾½æ + å¹³å°å + éè²åé线 --> |
| | | <div class="academic-login-header"> |
| | | <img :alt="appTitle" :src="brandLogo" class="academic-brand-logo" /> |
| | | <h2 class="academic-brand-name">{{ appTitle }}</h2> |
| | | <div class="academic-decoration-line"></div> |
| | | </div> |
| | | |
| | | <AuthenticationLogin |
| | | ref="loginRef" |
| | | :form-schema="formSchema" |
| | |
| | | <!-- èªå®ä¹æ é¢ï¼è¦ç欢è¿è¯ --> |
| | | <template #title> |
| | | <div class="academic-title-area"> |
| | | <h3 class="academic-title">è´¦å·ç»å½</h3> |
| | | <p class="academic-subtitle">{{ $t('authentication.loginSubtitle') }}</p> |
| | | <h2 class="academic-title">è´¦å·ç»å½</h2> |
| | | <p class="academic-subtitle">欢è¿åæ¥ï¼è¯·ä½¿ç¨æ¨çè´¦å·ç»å½</p> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ========== 妿¯ç§ç 飿 ¼ç»å½é¡µæ ·å¼ ========== */ |
| | | .academic-login-wrapper { |
| | | @apply relative flex flex-col w-full; |
| | | min-height: 460px; |
| | | } |
| | | |
| | | /* ---- é¡¶é¨åçæ è¯ ---- */ |
| | | .academic-login-header { |
| | | @apply flex flex-col items-center mb-8; |
| | | } |
| | | |
| | | .academic-brand-logo { |
| | | width: 52px; |
| | | height: 52px; |
| | | margin-bottom: 16px; |
| | | filter: drop-shadow(0 3px 10px rgba(15, 36, 64, 0.14)); |
| | | } |
| | | |
| | | .academic-brand-name { |
| | | font-size: 19px; |
| | | font-weight: 600; |
| | | letter-spacing: 5px; |
| | | /* æ«åå³ä¾§ä¼å¤åºä¸ä»½åè·ï¼ç¨çéç¼©è¿æµæ¶ï¼è§è§æçæ£å±
ä¸ */ |
| | | text-indent: 5px; |
| | | color: #0f2440; |
| | | margin: 0 0 14px; |
| | | } |
| | | |
| | | .academic-decoration-line { |
| | | position: relative; |
| | | width: 58px; |
| | | height: 2px; |
| | | background: linear-gradient( |
| | | 90deg, |
| | | transparent, |
| | | #d4af37 32%, |
| | | #d4af37 68%, |
| | | transparent |
| | | ); |
| | | border-radius: 2px; |
| | | } |
| | | |
| | | /* é线ä¸å¤®çè±å½¢ç¹ç¼ */ |
| | | .academic-decoration-line::after { |
| | | content: ''; |
| | | position: absolute; |
| | | top: 50%; |
| | | left: 50%; |
| | | width: 6px; |
| | | height: 6px; |
| | | background: #d4af37; |
| | | border-radius: 1px; |
| | | transform: translate(-50%, -50%) rotate(45deg); |
| | | } |
| | | |
| | | /* ---- èªå®ä¹æ é¢ ---- */ |
| | | .academic-title-area { |
| | | @apply text-left mb-6; |
| | | } |
| | | |
| | | .academic-title { |
| | | font-size: 26px; |
| | | font-weight: 600; |
| | | color: #0f2440; |
| | | letter-spacing: 2px; |
| | | margin: 0 0 8px 0; |
| | | line-height: 1.35; |
| | | } |
| | | |
| | | .academic-subtitle { |
| | | font-size: 13.5px; |
| | | color: #8a94a6; |
| | | margin: 0; |
| | | letter-spacing: 0.3px; |
| | | } |
| | | |
| | | /* ---- è¦çé»è®¤æ é¢ ---- */ |
| | | :deep(.academic-login-form .mb-7) { |
| | | display: none; |
| | | } |
| | | |
| | | /* ---- è¾å
¥æ¡ï¼æç½åº + éè²èç¦ ---- */ |
| | | :deep(.academic-login-form .vben-input) { |
| | | border-radius: 8px; |
| | | border-color: #e6e2d8; |
| | | background: #fdfcfa; |
| | | transition: all 0.25s ease; |
| | | height: 48px; |
| | | } |
| | | |
| | | :deep(.academic-login-form .vben-input:hover) { |
| | | border-color: #c9b77a; |
| | | } |
| | | |
| | | :deep(.academic-login-form .vben-input:focus-within) { |
| | | border-color: #d4af37; |
| | | background: #fff; |
| | | box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.13); |
| | | } |
| | | |
| | | /* ---- ç»å½æé®ï¼è鿏å + éè²æè¾¹ä¸æå ---- */ |
| | | :deep(.academic-login-form .w-full > button), |
| | | :deep(.academic-login-form button[aria-label="login"]) { |
| | | height: 48px; |
| | | border-radius: 8px; |
| | | background: linear-gradient(135deg, #1e4571, #0f2440) !important; |
| | | border: 1px solid rgba(212, 175, 55, 0.45) !important; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | letter-spacing: 6px; |
| | | text-indent: 6px; |
| | | box-shadow: 0 6px 18px rgba(15, 36, 64, 0.22); |
| | | transition: all 0.3s ease; |
| | | color: #f5e7c0 !important; |
| | | } |
| | | |
| | | /* æ¬ååªå æ·±æè¾¹ä¸æå½±ï¼ä¸åä½ç§»ï¼ä¿æå
å¶ */ |
| | | :deep(.academic-login-form .w-full > button:hover), |
| | | :deep(.academic-login-form button[aria-label="login"]:hover) { |
| | | box-shadow: |
| | | 0 8px 22px rgba(15, 36, 64, 0.26), |
| | | 0 0 0 1px rgba(212, 175, 55, 0.6); |
| | | } |
| | | |
| | | :deep(.academic-login-form .w-full > button:active), |
| | | :deep(.academic-login-form button[aria-label="login"]:active) { |
| | | box-shadow: 0 3px 12px rgba(15, 36, 64, 0.24); |
| | | } |
| | | |
| | | /* ---- è®°ä½è´¦å· / å¿è®°å¯ç ---- */ |
| | | :deep(.academic-login-form .vben-link) { |
| | | color: #b8901f; |
| | | font-weight: 500; |
| | | transition: color 0.2s; |
| | | } |
| | | |
| | | :deep(.academic-login-form .vben-link:hover) { |
| | | color: #8a6a12; |
| | | } |
| | | |
| | | /* ---- ç¬¬ä¸æ¹ç»å½ ---- */ |
| | | .academic-third-party { |
| | | @apply mt-5; |
| | | } |
| | | |
| | | .academic-divider { |
| | | @apply flex items-center gap-3 mb-4; |
| | | } |
| | | |
| | | .academic-divider-line { |
| | | flex: 1; |
| | | height: 1px; |
| | | background: linear-gradient(90deg, transparent, #e2e8f0, transparent); |
| | | } |
| | | |
| | | .academic-divider-text { |
| | | @apply text-xs; |
| | | color: #94a3b8; |
| | | white-space: nowrap; |
| | | letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .academic-wxcp-btn { |
| | | @apply w-full rounded-lg; |
| | | height: 44px; |
| | | border-color: #e6e2d8; |
| | | color: #44546a; |
| | | font-weight: 500; |
| | | transition: all 0.3s; |
| | | } |
| | | |
| | | .academic-wxcp-btn:hover { |
| | | border-color: #d4af37; |
| | | color: #0f2440; |
| | | background: rgba(212, 175, 55, 0.06); |
| | | } |
| | | |
| | | /* ---- æè²éé
---- */ |
| | | :root.dark .academic-title { |
| | | color: #f0ead8; |
| | | } |
| | | |
| | | :root.dark .academic-brand-name { |
| | | color: #f0ead8; |
| | | } |
| | | |
| | | :root.dark .academic-subtitle { |
| | | color: #8a94a6; |
| | | } |
| | | |
| | | :root.dark .academic-decoration-line { |
| | | background: linear-gradient(90deg, #8bb4f0, #4a7ad4); |
| | | } |
| | | |
| | | :root.dark .academic-footer-text { |
| | | color: #64748b; |
| | | } |
| | | .academic-login-wrapper { width: 100%; } |
| | | .academic-title-area { margin-bottom: 28px; } |
| | | .academic-title { margin: 0 0 10px; color: #18365a; font-size: 26px; font-weight: 750; line-height: 1.4; } |
| | | .academic-subtitle { margin: 0; color: #5c7187; font-size: 13px; line-height: 1.7; } |
| | | :deep(.academic-login-form label) { color: #294d70; font-size: 13px; font-weight: 600; } |
| | | :deep(.academic-login-form input:not([type=checkbox])) { min-height: 46px; border-radius: 6px; border-color: #d6e1ec; background: #f8fbfe; color: #18365a; font-size: 14px; } |
| | | :deep(.academic-login-form input::placeholder) { color: #5c7187; } |
| | | :deep(.academic-login-form input:focus-visible) { border-color: #3476b5; outline-color: #3476b5; background: #fff; } |
| | | :deep(.academic-login-form button[aria-label=login]) { height: 46px; border: 0; border-radius: 6px; color: #fff; background: #b7242d; font-size: 15px; font-weight: 650; letter-spacing: .16em; box-shadow: 0 4px 12px rgb(150 30 40 / 12%); } |
| | | :deep(.academic-login-form button[aria-label=login]:hover) { background: #9f1d27; } |
| | | :deep(.academic-login-form button[aria-label=login]:disabled) { opacity: .65; cursor: wait; } |
| | | :deep(.academic-login-form .vben-link) { color: #28659d; } |
| | | .academic-third-party { margin-top: 24px; } |
| | | .academic-divider { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } |
| | | .academic-divider-line { height: 1px; flex: 1; background: #e4ecf3; } |
| | | .academic-divider-text { color: #5c7187; font-size: 12px; white-space: nowrap; } |
| | | .academic-wxcp-btn { width: 100%; height: 44px; border: 1px solid #d8e4ee; border-radius: 6px; color: #315777; background: #fff; font-size: 13px; } |
| | | .academic-wxcp-btn:hover { border-color: #8cb5d5; background: #f3f8fc; } |
| | | :deep(button:focus-visible) { outline: 2px solid #2c70b2; outline-offset: 3px; } |
| | | </style> |
| | |
| | | <script setup lang="ts"> |
| | | import type { SystemUserProfileApi } from '#/api/system/user/profile'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { onMounted, ref, watch } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | |
| | | import { Card, Tabs } from 'ant-design-vue'; |
| | | |
| | | import { Skeleton, Tabs } from 'ant-design-vue'; |
| | | import { getAuthPermissionInfoApi } from '#/api'; |
| | | import { getUserProfile } from '#/api/system/user/profile'; |
| | | |
| | | import BaseInfo from './modules/base-info.vue'; |
| | | import ProfileUser from './modules/profile-user.vue'; |
| | | import ResetPwd from './modules/reset-pwd.vue'; |
| | | import UserSocial from './modules/user-social.vue'; |
| | | |
| | | defineOptions({ name: 'Profile' }); |
| | | const userStore = useUserStore(); |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const activeName = ref('basicInfo'); |
| | | |
| | | /** å è½½ä¸ªäººä¿¡æ¯ */ |
| | | const profile = ref<SystemUserProfileApi.UserProfileRespVO>(); |
| | | const loading = ref(true); |
| | | const failed = ref(false); |
| | | const refreshing = ref(false); |
| | | const sections = [ |
| | | { key: 'basicInfo', title: 'åºæ¬èµæ', text: 'å®å个人èç³»æ¹å¼ï¼ä¾¿äºæ¥æ¶ä¸å¡éç¥ä¸æ¥å¸¸èç³»ã', icon: 'lucide:user-round-pen' }, |
| | | { key: 'resetPwd', title: 'å¯ç 设置', text: '使ç¨å½åå¯ç éªè¯èº«ä»½åï¼è®¾ç½®æ°çç»å½å¯ç ã', icon: 'lucide:shield-check' }, |
| | | { key: 'userSocial', title: 'è´¦å·ç»å®', text: '管çç¬¬ä¸æ¹è´¦å·ï¼ä¾¿äºä½¿ç¨å·²ç»å®çå¹³å°ç»å½ã', icon: 'lucide:link' }, |
| | | ]; |
| | | watch(() => route.query.section, (value) => { |
| | | activeName.value = ['basicInfo', 'resetPwd', 'userSocial'].includes(String(value)) ? String(value) : 'basicInfo'; |
| | | }, { immediate: true }); |
| | | function selectSection(value: string | number) { |
| | | activeName.value = String(value); |
| | | } |
| | | async function loadProfile() { |
| | | profile.value = await getUserProfile(); |
| | | loading.value = true; failed.value = false; |
| | | try { profile.value = await getUserProfile(); } |
| | | catch { failed.value = true; } |
| | | finally { loading.value = false; } |
| | | } |
| | | |
| | | /** å·æ°ä¸ªäººä¿¡æ¯ */ |
| | | async function refreshProfile() { |
| | | // å è½½ä¸ªäººä¿¡æ¯ |
| | | await loadProfile(); |
| | | |
| | | // æ´æ° store |
| | | const authPermissionInfo = await getAuthPermissionInfoApi(); |
| | | userStore.setUserInfo(authPermissionInfo.user); |
| | | refreshing.value = true; |
| | | try { |
| | | profile.value = await getUserProfile(); |
| | | const info = await getAuthPermissionInfoApi(); |
| | | userStore.setUserInfo(info.user); |
| | | } finally { refreshing.value = false; } |
| | | } |
| | | |
| | | /** åå§å */ |
| | | onMounted(loadProfile); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <div class="flex"> |
| | | <!-- 左侧 ä¸ªäººä¿¡æ¯ --> |
| | | <Card class="w-2/5" title="个人信æ¯"> |
| | | <ProfileUser :profile="profile" @success="refreshProfile" /> |
| | | </Card> |
| | | |
| | | <!-- å³ä¾§ æ ç¾é¡µ --> |
| | | <Card class="ml-3 w-3/5"> |
| | | <Tabs v-model:active-key="activeName" class="-mt-4"> |
| | | <Tabs.TabPane key="basicInfo" tab="åºæ¬è®¾ç½®"> |
| | | <BaseInfo :profile="profile" @success="refreshProfile" /> |
| | | <div class="portal-profile"> |
| | | <header class="profile-page-heading"> |
| | | <div><h1>个人ä¸å¿</h1><span>管çä¸ªäººèµæãç»å½å¯ç ä¸ç¬¬ä¸æ¹è´¦å·ã</span></div> |
| | | <button type="button" @click="router.push('/analytics')"><IconifyIcon icon="lucide:house" />è¿åé¦é¡µ</button> |
| | | </header> |
| | | <div v-if="loading" class="profile-loading" aria-live="polite"><Skeleton active avatar :paragraph="{ rows: 7 }" /><span>æ£å¨å è½½ä¸ªäººèµæâ¦</span></div> |
| | | <div v-else-if="failed || !profile" class="profile-load-error" role="status"><IconifyIcon icon="lucide:cloud-off" /><h2>ä¸ªäººèµæææ¶æ æ³å è½½</h2><p>è¯·æ£æ¥ç½ç»è¿æ¥åéè¯ã</p><button type="button" @click="loadProfile">éæ°å è½½</button></div> |
| | | <div v-else class="profile-layout"> |
| | | <aside class="profile-identity"><ProfileUser :profile="profile" @success="refreshProfile" /></aside> |
| | | <section class="profile-editor" aria-label="个人账å·è®¾ç½®" :aria-busy="refreshing"> |
| | | <Tabs :active-key="activeName" @change="selectSection"> |
| | | <Tabs.TabPane v-for="section in sections" :key="section.key" force-render> |
| | | <template #tab><span class="profile-tab"><IconifyIcon :icon="section.icon" />{{ section.title }}</span></template> |
| | | <div class="profile-section-heading"><span class="profile-section-icon"><IconifyIcon :icon="section.icon" /></span><div><h2>{{ section.title }}</h2><p>{{ section.text }}</p></div></div> |
| | | <BaseInfo v-if="section.key === 'basicInfo'" :profile="profile" @success="refreshProfile" /> |
| | | <ResetPwd v-else-if="section.key === 'resetPwd'" /> |
| | | <UserSocial v-else @update:active-name="selectSection" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="resetPwd" tab="å¯ç 设置"> |
| | | <ResetPwd /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="userSocial" tab="社交ç»å®" force-render> |
| | | <UserSocial @update:active-name="activeName = $event" /> |
| | | </Tabs.TabPane> |
| | | <!-- TODO @èè¿ï¼å¨çº¿è®¾å¤ --> |
| | | </Tabs> |
| | | </Card> |
| | | </section> |
| | | </div> |
| | | </Page> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .portal-profile { width: min(1240px, 100%); margin: 0 auto; padding: 10px 8px 32px; color: var(--portal-ink); } |
| | | .profile-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 12px 0 24px; }.profile-page-heading p { margin: 0 0 5px; color: var(--portal-muted); font-size: 12px; letter-spacing: .12em; }.profile-page-heading h1 { margin: 0 0 8px; font-size: 29px; font-weight: 700; }.profile-page-heading span { color: var(--portal-muted); font-size: 13px; }.profile-page-heading button { display: flex; min-height: 36px; align-items: center; gap: 7px; padding: 6px 12px; border: 1px solid #d8e5f0; border-radius: 5px; color: #315a80; background: var(--portal-surface); font-size: 12px; white-space: nowrap; }.profile-page-heading button svg { width: 15px; } |
| | | .profile-layout { display: grid; grid-template-columns: 282px minmax(0, 1fr); align-items: start; gap: 20px; }.profile-identity,.profile-editor { min-width: 0; overflow: hidden; border: 1px solid #dfebf4; border-radius: 7px; background: var(--portal-surface); box-shadow: 0 3px 10px rgb(24 66 106 / 3%); }.profile-editor { min-height: 540px; padding: 0 28px 32px; }.profile-editor :deep(.ant-tabs-nav) { margin-bottom: 24px; }.profile-editor :deep(.ant-tabs-tab) { padding: 19px 0 16px; }.profile-tab { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; }.profile-tab svg { width: 16px; height: 16px; }.profile-section-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 28px; }.profile-section-icon { display: grid; width: 42px; height: 42px; flex: none; place-items: center; border-radius: 9px; color: #2b6599; background: #edf5fc; }.profile-section-icon svg { width: 21px; }.profile-section-heading h2 { margin: 0 0 5px; font-size: 18px; font-weight: 650; }.profile-section-heading p { margin: 0; color: var(--portal-muted); font-size: 12px; line-height: 1.7; }.profile-loading,.profile-load-error { padding: 36px; border: 1px solid #dfeaf4; border-radius: 7px; background: var(--portal-surface); }.profile-loading > span { color: var(--portal-muted); font-size: 13px; }.profile-load-error { display: grid; min-height: 330px; justify-items: center; align-content: center; gap: 12px; text-align: center; }.profile-load-error > svg { width: 36px; height: 36px; color: #7695b0; }.profile-load-error h2 { margin: 0; font-size: 18px; }.profile-load-error p { margin: 0; color: var(--portal-muted); }.profile-load-error button { padding: 8px 18px; border-radius: 5px; color: #fff; background: #b7242d; }button { cursor: pointer; }button:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; } |
| | | @media(max-width:1000px) { .profile-layout { grid-template-columns: 240px minmax(0,1fr); gap: 14px; }.profile-editor { padding: 0 20px 26px; } } |
| | | @media(max-width:767px) { .portal-profile { padding: 0 0 24px; }.profile-page-heading { align-items: flex-start; padding-top: 8px; }.profile-page-heading h1 { font-size: 25px; }.profile-page-heading span { display: block; max-width: 22em; line-height: 1.7; }.profile-page-heading button { padding: 6px 9px; }.profile-layout { grid-template-columns: 1fr; }.profile-editor { min-height: 0; padding: 0 18px 26px; }.profile-tab { gap: 5px; font-size: 13px; }.profile-editor :deep(.ant-tabs-tab + .ant-tabs-tab) { margin-left: 22px; }.profile-section-heading { margin-bottom: 24px; } } |
| | | </style> |
| | | |
| | |
| | | }>(); |
| | | |
| | | const [Form, formApi] = useVbenForm({ |
| | | wrapperClass: 'grid-cols-1', |
| | | actionPosition: 'left', |
| | | actionLayout: 'newLine', |
| | | commonConfig: { |
| | | labelWidth: 70, |
| | | labelWidth: 84, |
| | | }, |
| | | schema: [ |
| | | { |
| | | label: 'ç¨æ·æµç§°', |
| | | label: 'æ¾ç¤ºåç§°', |
| | | fieldName: 'nickname', |
| | | component: 'Input', |
| | | componentProps: { |
| | |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | label: 'ç¨æ·ææº', |
| | | label: 'ææºå·ç ', |
| | | fieldName: 'mobile', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥ç¨æ·ææº', |
| | | autocomplete: 'tel', |
| | | }, |
| | | rules: z.string(), |
| | | rules: z.string().optional(), |
| | | }, |
| | | { |
| | | label: 'ç¨æ·é®ç®±', |
| | | label: 'çµåé®ç®±', |
| | | fieldName: 'email', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥ç¨æ·é®ç®±', |
| | | autocomplete: 'email', |
| | | }, |
| | | rules: z.string().email('请è¾å
¥æ£ç¡®çé®ç®±'), |
| | | rules: z.string().email('请è¾å
¥æ£ç¡®çé®ç®±').or(z.literal('')).optional(), |
| | | }, |
| | | { |
| | | label: 'ç¨æ·æ§å«', |
| | |
| | | buttonStyle: 'solid', |
| | | optionType: 'button', |
| | | }, |
| | | rules: z.number(), |
| | | rules: z.number().optional(), |
| | | }, |
| | | ], |
| | | resetButtonOptions: { |
| | | show: false, |
| | | }, |
| | | submitButtonOptions: { |
| | | content: 'æ´æ°ä¿¡æ¯', |
| | | content: 'ä¿åä¿®æ¹', |
| | | }, |
| | | handleSubmit, |
| | | }); |
| | |
| | | () => props.profile, |
| | | (newProfile) => { |
| | | if (newProfile) { |
| | | formApi.setValues(newProfile); |
| | | formApi.setValues({ nickname: newProfile.nickname, mobile: newProfile.mobile ?? '', email: newProfile.email ?? '', sex: newProfile.sex }); |
| | | } |
| | | }, |
| | | { immediate: true }, |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="mt-4 md:w-full lg:w-1/2 2xl:w-2/5"> |
| | | <div class="profile-form"> |
| | | <Form /> |
| | | <p class="profile-form-note">è´¦å·ãé¨é¨åå²ä½ç±ç®¡çåç»´æ¤ãå¦éè°æ´ï¼è¯·è系管çåã</p> |
| | | </div> |
| | | </template> |
| | | <style scoped> |
| | | .profile-form { max-width: 560px; }.profile-form :deep(.ant-input),.profile-form :deep(.ant-input-affix-wrapper) { min-height: 38px; }.profile-form-note { margin: 18px 0 0 84px; color: var(--portal-muted); font-size: 12px; line-height: 1.8; }@media(max-width:520px) { .profile-form-note { margin-left: 0; } } |
| | | </style> |
| | |
| | | <script setup lang="ts"> |
| | | import type { SystemUserProfileApi } from '#/api/system/user/profile'; |
| | | |
| | | import { computed } from 'vue'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { preferences } from '@vben/preferences'; |
| | | import { formatDateTime } from '@vben/utils'; |
| | | |
| | | import { Descriptions, DescriptionsItem, Tooltip } from 'ant-design-vue'; |
| | | |
| | | import { updateUserProfile } from '#/api/system/user/profile'; |
| | | import { CropperAvatar } from '#/components/cropper'; |
| | | import { useUpload } from '#/components/upload/use-upload'; |
| | | |
| | | const props = defineProps<{ |
| | | profile?: SystemUserProfileApi.UserProfileRespVO; |
| | | }>(); |
| | | |
| | | const emit = defineEmits<{ |
| | | (e: 'success'): void; |
| | | }>(); |
| | | |
| | | const avatar = computed( |
| | | () => props.profile?.avatar || preferences.app.defaultAvatar, |
| | | ); |
| | | |
| | | async function handelUpload({ |
| | | file, |
| | | filename, |
| | | }: { |
| | | file: Blob; |
| | | filename: string; |
| | | }) { |
| | | // 1. ä¸ä¼ 头åï¼è·å URL |
| | | const props = defineProps<{ profile: SystemUserProfileApi.UserProfileRespVO }>(); |
| | | const emit = defineEmits<{ success: [] }>(); |
| | | const avatar = computed(() => props.profile.avatar || preferences.app.defaultAvatar); |
| | | const details = computed(() => [ |
| | | { label: 'ç¨æ·è´¦å·', value: props.profile.username, icon: 'lucide:user-round' }, |
| | | { label: 'æå±é¨é¨', value: props.profile.dept?.name, icon: 'lucide:building-2' }, |
| | | { label: 'æå±å²ä½', value: props.profile.posts?.map(p => p.name).join('ã'), icon: 'lucide:briefcase-business' }, |
| | | { label: 'ææºå·ç ', value: props.profile.mobile, icon: 'lucide:smartphone' }, |
| | | { label: 'çµåé®ç®±', value: props.profile.email, icon: 'lucide:mail' }, |
| | | ]); |
| | | async function handleUpload({ file, filename }: { file: Blob; filename: string }) { |
| | | const { httpRequest } = useUpload(); |
| | | // å° Blob 转æ¢ä¸º File |
| | | const fileObj = new File([file], filename, { type: file.type }); |
| | | const avatar = await httpRequest(fileObj); |
| | | // 2. æ´æ°ç¨æ·å¤´å |
| | | await updateUserProfile({ avatar }); |
| | | const result = await httpRequest(new File([file], filename, { type: file.type })); |
| | | if (!result.url) throw new Error('头åä¸ä¼ æªè¿åå¾çå°åï¼è¯·éè¯'); |
| | | await updateUserProfile({ avatar: result.url }); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div v-if="profile"> |
| | | <div class="flex flex-col items-center"> |
| | | <Tooltip title="ç¹å»ä¸ä¼ 头å"> |
| | | <CropperAvatar |
| | | :show-btn="false" |
| | | :upload-api="handelUpload" |
| | | :value="avatar" |
| | | :width="120" |
| | | @change="emit('success')" |
| | | /> |
| | | </Tooltip> |
| | | </div> |
| | | <div class="mt-8"> |
| | | <Descriptions :column="2"> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon icon="ant-design:user-outlined" class="mr-1" /> |
| | | ç¨æ·è´¦å· |
| | | <div class="profile-user"> |
| | | <div class="profile-user-banner" aria-hidden="true"></div> |
| | | <div class="profile-avatar"><CropperAvatar :value="avatar" :width="84" :upload-api="handleUpload" :show-btn="true" btn-text="æ´æ¢å¤´å" :btn-props="{ type: 'link', size: 'small' }" @change="emit('success')" /></div> |
| | | <div class="profile-user-name"><h2>{{ profile.nickname || profile.username }}</h2><p>{{ profile.dept?.name || '个人账å·' }}</p><div v-if="profile.roles?.length" class="profile-roles"><span v-for="role in profile.roles" :key="role.id ?? role.name">{{ role.name }}</span></div></div> |
| | | <dl class="profile-facts"><div v-for="item in details" :key="item.label"><dt><IconifyIcon :icon="item.icon" />{{ item.label }}</dt><dd>{{ item.value || 'æªè®¾ç½®' }}</dd></div></dl> |
| | | <div class="profile-login"><span><IconifyIcon icon="lucide:clock-3" />æè¿ç»å½</span><time>{{ profile.loginDate ? formatDateTime(profile.loginDate) : 'ææ è®°å½' }}</time><small v-if="profile.createTime">å å
¥æ¶é´ï¼{{ formatDateTime(profile.createTime)?.slice(0, 10) }}</small></div> |
| | | </div> |
| | | </template> |
| | | {{ profile.username }} |
| | | </DescriptionsItem> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon |
| | | icon="ant-design:user-switch-outlined" |
| | | class="mr-1" |
| | | /> |
| | | æå±è§è² |
| | | </div> |
| | | </template> |
| | | {{ profile.roles.map((role) => role.name).join(',') }} |
| | | </DescriptionsItem> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon icon="ant-design:phone-outlined" class="mr-1" /> |
| | | ææºå·ç |
| | | </div> |
| | | </template> |
| | | {{ profile.mobile }} |
| | | </DescriptionsItem> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon icon="ant-design:mail-outlined" class="mr-1" /> |
| | | ç¨æ·é®ç®± |
| | | </div> |
| | | </template> |
| | | {{ profile.email }} |
| | | </DescriptionsItem> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon icon="ant-design:team-outlined" class="mr-1" /> |
| | | æå±é¨é¨ |
| | | </div> |
| | | </template> |
| | | {{ profile.dept?.name }} |
| | | </DescriptionsItem> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon |
| | | icon="ant-design:usergroup-add-outlined" |
| | | class="mr-1" |
| | | /> |
| | | æå±å²ä½ |
| | | </div> |
| | | </template> |
| | | {{ |
| | | profile.posts && profile.posts.length > 0 |
| | | ? profile.posts.map((post) => post.name).join(',') |
| | | : '-' |
| | | }} |
| | | </DescriptionsItem> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon |
| | | icon="ant-design:clock-circle-outlined" |
| | | class="mr-1" |
| | | /> |
| | | å建æ¶é´ |
| | | </div> |
| | | </template> |
| | | {{ formatDateTime(profile.createTime) }} |
| | | </DescriptionsItem> |
| | | <DescriptionsItem> |
| | | <template #label> |
| | | <div class="flex items-center"> |
| | | <IconifyIcon icon="ant-design:login-outlined" class="mr-1" /> |
| | | ç»å½æ¶é´ |
| | | </div> |
| | | </template> |
| | | {{ formatDateTime(profile.loginDate) }} |
| | | </DescriptionsItem> |
| | | </Descriptions> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <style scoped> |
| | | .profile-user-banner { height: 78px; background: linear-gradient(100deg, rgb(24 74 116 / 92%), rgb(43 105 154 / 38%)), url('/static/imgs/portal-hero-v2.png') center 60% / cover; }.profile-avatar { position: relative; display: flex; justify-content: center; margin-top: -42px; }.profile-avatar :deep(.group) { border: 4px solid #fff; box-shadow: 0 3px 8px rgb(24 66 106 / 9%); }.profile-avatar :deep(.ant-btn) { font-size: 11px; }.profile-user-name { padding: 5px 20px 20px; text-align: center; }.profile-user-name h2 { margin: 0 0 5px; font-size: 21px; font-weight: 650; overflow-wrap: anywhere; }.profile-user-name p { margin: 0; color: var(--portal-muted); font-size: 12px; }.profile-roles { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 12px; }.profile-roles span { padding: 3px 8px; border-radius: 4px; color: #2d618b; background: #edf5fc; font-size: 11px; }.profile-facts { margin: 0 20px; padding: 5px 0 10px; border-top: 1px solid #e7eff6; }.profile-facts > div { padding: 12px 0; border-bottom: 1px solid #edf2f7; }.profile-facts dt { display: flex; align-items: center; gap: 7px; margin-bottom: 6px; color: var(--portal-muted); font-size: 11px; }.profile-facts svg,.profile-login svg { width: 14px; height: 14px; }.profile-facts dd { margin: 0; padding-left: 21px; color: var(--portal-ink); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }.profile-login { display: grid; gap: 6px; padding: 10px 20px 22px; color: var(--portal-muted); font-size: 11px; }.profile-login > span { display: flex; align-items: center; gap: 7px; }.profile-login time { padding-left: 21px; }.profile-login small { padding-left: 21px; font-size: 10px; } |
| | | @media(max-width:767px) { .profile-facts { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); column-gap: 16px; }.profile-login { grid-template-columns: auto 1fr; }.profile-login time { padding: 0; }.profile-login small { grid-column: 1 / -1; } } |
| | | </style> |
| | | |
| | |
| | | import { updateUserPassword } from '#/api/system/user/profile'; |
| | | |
| | | const [Form, formApi] = useVbenForm({ |
| | | wrapperClass: 'grid-cols-1', |
| | | actionPosition: 'left', |
| | | actionLayout: 'newLine', |
| | | commonConfig: { |
| | | labelWidth: 70, |
| | | labelWidth: 84, |
| | | }, |
| | | schema: [ |
| | | { |
| | | component: 'InputPassword', |
| | | fieldName: 'oldPassword', |
| | | label: 'æ§å¯ç ', |
| | | label: 'å½åå¯ç ', |
| | | componentProps: { autocomplete: 'current-password', placeholder: '请è¾å
¥å½åç»å½å¯ç ' }, |
| | | rules: z |
| | | .string({ message: '请è¾å
¥å¯ç ' }) |
| | | .min(5, 'å¯ç é¿åº¦ä¸è½å°äº 5 个å符') |
| | |
| | | }, |
| | | fieldName: 'newPassword', |
| | | label: 'æ°å¯ç ', |
| | | componentProps: { autocomplete: 'new-password', placeholder: '请è¾å
¥ 5â20 使°å¯ç ' }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | |
| | | }, |
| | | fieldName: 'confirmPassword', |
| | | label: '确认å¯ç ', |
| | | componentProps: { autocomplete: 'new-password', placeholder: 'è¯·åæ¬¡è¾å
¥æ°å¯ç ' }, |
| | | rules: 'required', |
| | | }, |
| | | ], |
| | |
| | | newPassword: values.newPassword, |
| | | }); |
| | | message.success($t('ui.actionMessage.operationSuccess')); |
| | | await formApi.resetForm(); |
| | | } catch (error) { |
| | | console.error(error); |
| | | } finally { |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="mt-4 md:w-full lg:w-1/2 2xl:w-2/5"> |
| | | <div class="profile-password-form"> |
| | | <p class="password-note">å¯ç é¿åº¦ä¸º 5â20 个åç¬¦ï¼æ°å¯ç éä¸å½åå¯ç ä¸åã请å¿ä¸å
¶ä»å¹³å°ä½¿ç¨ç¸åå¯ç ã</p> |
| | | <Form /> |
| | | </div> |
| | | </template> |
| | | <style scoped> |
| | | .profile-password-form { max-width: 560px; }.password-note { margin: 0 0 24px; padding: 13px 16px; color: #365e82; background: #f0f6fc; font-size: 12px; line-height: 1.8; }.profile-password-form :deep(.ant-input-affix-wrapper) { min-height: 38px; } |
| | | </style> |
| | | |
| | |
| | | <script setup lang="tsx"> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | <script setup lang="ts"> |
| | | import type { SystemSocialUserApi } from '#/api/system/social/user'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { useRoute } from 'vue-router'; |
| | | |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | import { confirm } from '@vben/common-ui'; |
| | | import { DICT_TYPE, SystemUserSocialTypeEnum } from '@vben/constants'; |
| | | import { getDictLabel } from '@vben/hooks'; |
| | | import { SystemUserSocialTypeEnum } from '@vben/constants'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { getUrlValue } from '@vben/utils'; |
| | | |
| | | import { Button, Card, Image, message } from 'ant-design-vue'; |
| | | |
| | | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { message } from 'ant-design-vue'; |
| | | import { socialAuthRedirect } from '#/api/core/auth'; |
| | | import { |
| | | getBindSocialUserList, |
| | | socialBind, |
| | | socialUnbind, |
| | | } from '#/api/system/social/user'; |
| | | import { $t } from '#/locales'; |
| | | import { getBindSocialUserList, socialBind, socialUnbind } from '#/api/system/social/user'; |
| | | |
| | | const emit = defineEmits<{ |
| | | (e: 'update:activeName', v: string): void; |
| | | }>(); |
| | | |
| | | const emit = defineEmits<{ 'update:activeName': [string] }>(); |
| | | const route = useRoute(); |
| | | /** å·²ç»ç»å®çå¹³å° */ |
| | | const router = useRouter(); |
| | | const bindList = ref<SystemSocialUserApi.SocialUser[]>([]); |
| | | const allBindList = computed<any[]>(() => { |
| | | return Object.values(SystemUserSocialTypeEnum).map((social) => { |
| | | const socialUser = bindList.value.find((item) => item.type === social.type); |
| | | return { |
| | | ...social, |
| | | socialUser, |
| | | }; |
| | | }); |
| | | }); |
| | | |
| | | function useGridColumns(): VxeTableGridOptions['columns'] { |
| | | return [ |
| | | { |
| | | field: 'type', |
| | | title: 'ç»å®å¹³å°', |
| | | minWidth: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE }, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'openid', |
| | | title: 'æ è¯', |
| | | minWidth: 180, |
| | | }, |
| | | { |
| | | field: 'nickname', |
| | | title: 'æµç§°', |
| | | minWidth: 180, |
| | | }, |
| | | { |
| | | field: 'operation', |
| | | title: 'æä½', |
| | | minWidth: 80, |
| | | align: 'center', |
| | | fixed: 'right', |
| | | slots: { |
| | | default: ({ row }: { row: SystemSocialUserApi.SocialUser }) => { |
| | | return ( |
| | | <Button onClick={() => onUnbind(row)} type="link"> |
| | | è§£ç» |
| | | </Button> |
| | | ); |
| | | }, |
| | | }, |
| | | }, |
| | | ]; |
| | | const loading = ref(true); |
| | | const failed = ref(false); |
| | | const busyType = ref<number>(); |
| | | const callbackFailed = ref(false); |
| | | const allBindList = computed(() => Object.values(SystemUserSocialTypeEnum).map(platform => ({ |
| | | ...platform, user: bindList.value.find(item => item.type === platform.type), |
| | | }))); |
| | | async function load() { |
| | | loading.value = true; failed.value = false; |
| | | try { bindList.value = await getBindSocialUserList(); } |
| | | catch { failed.value = true; } |
| | | finally { loading.value = false; } |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | gridOptions: { |
| | | columns: useGridColumns(), |
| | | minHeight: 0, |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async () => { |
| | | bindList.value = await getBindSocialUserList(); |
| | | return bindList.value; |
| | | }, |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | }, |
| | | pagerConfig: { |
| | | enabled: false, |
| | | }, |
| | | toolbarConfig: { |
| | | enabled: false, |
| | | }, |
| | | }, |
| | | }); |
| | | |
| | | /** è§£ç»è´¦å· */ |
| | | function onUnbind(row: SystemSocialUserApi.SocialUser) { |
| | | confirm(`ç¡®å®è§£ç»[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]å¹³å°ç[${row.openid}]è´¦å·åï¼`).then(async () => { |
| | | await socialUnbind({ type: row.type, openid: row.openid }); |
| | | // æç¤ºæå |
| | | message.success($t('ui.actionMessage.operationSuccess')); |
| | | await gridApi.reload(); |
| | | }); |
| | | } |
| | | |
| | | /** ç»å®è´¦å·ï¼è·³è½¬ææé¡µé¢ï¼ */ |
| | | async function onBind(bind: any) { |
| | | const type = bind.type; |
| | | if (type <= 0) { |
| | | return; |
| | | } |
| | | async function unbind(user: SystemSocialUserApi.SocialUser, title: string) { |
| | | try { await confirm('è§£é¤ä¸' + title + 'çç»å®åï¼å°æ æ³éè¿è¯¥è´¦å·ç»å½ãç¡®å®è§£é¤ç»å®åï¼'); } |
| | | catch { return; } |
| | | busyType.value = user.type; |
| | | try { |
| | | // è®¡ç® redirectUri |
| | | // tricky: type éè¦å
encode 䏿¬¡ï¼å¦åééåè°ä¼ä¸¢å¤±ãé
å getUrlValue() ä½¿ç¨ |
| | | // hash è·¯ç±ï¼ç产æå»ºï¼ä¸å¿
须带 # åç¼ï¼å¦ååè·³å vue-router è§£æå° /ï¼è¿ä¸äºç»å®æµç¨ |
| | | await socialUnbind({ type: user.type, openid: user.openid }); |
| | | message.success('已解é¤ç»å®'); |
| | | await load(); |
| | | } finally { busyType.value = undefined; } |
| | | } |
| | | async function bind(type: number) { |
| | | busyType.value = type; |
| | | try { |
| | | const hashPrefix = import.meta.env.VITE_ROUTER_HISTORY === 'hash' ? '/#' : ''; |
| | | const redirectUri = `${location.origin}${hashPrefix}/profile?${encodeURIComponent(`type=${type}`)}`; |
| | | |
| | | // è¿è¡è·³è½¬ |
| | | const redirectUri = location.origin + hashPrefix + '/profile?' + encodeURIComponent('type=' + type); |
| | | window.location.href = await socialAuthRedirect(type, redirectUri); |
| | | } catch (error) { |
| | | console.error('社交ç»å®å¤ç失败:', error); |
| | | } finally { busyType.value = undefined; } |
| | | } |
| | | } |
| | | |
| | | /** çå¬è·¯ç±ååï¼å¤ç社交ç»å®åè° */ |
| | | async function bindSocial() { |
| | | // 社交ç»å® |
| | | const type = Number(getUrlValue('type')); |
| | | const code = route.query.code as string; |
| | | const state = route.query.state as string; |
| | | if (!code) { |
| | | return; |
| | | } |
| | | await socialBind({ type, code, state }); |
| | | // æç¤ºæå |
| | | message.success('ç»å®æå'); |
| | | onMounted(async () => { |
| | | const code = route.query.code; |
| | | if (typeof code === 'string' && code) { |
| | | emit('update:activeName', 'userSocial'); |
| | | await gridApi.reload(); |
| | | // æ¸
ç URL åæ°ï¼é¿å
å·æ°éå¤è§¦å |
| | | window.history.replaceState({}, '', location.pathname); |
| | | const type = Number(getUrlValue('type')); |
| | | try { |
| | | await socialBind({ type, code, state: String(route.query.state ?? '') }); |
| | | message.success('ç»å®æå'); |
| | | const query: Record<string, any> = { ...route.query, section: 'userSocial' }; |
| | | delete query.code; delete query.state; delete query.type; |
| | | await router.replace({ query }); |
| | | } catch { callbackFailed.value = true; } |
| | | } |
| | | |
| | | /** åå§å */ |
| | | onMounted(() => { |
| | | bindSocial(); |
| | | await load(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="flex flex-col"> |
| | | <Grid /> |
| | | <div class="social-settings"> |
| | | <p v-if="callbackFailed" class="social-error" role="alert">è´¦å·ç»å®æªå®æï¼è¯·éæ°åèµ·ç»å®ã</p> |
| | | <div v-if="loading" class="social-state" role="status"><IconifyIcon icon="lucide:loader-circle" />æ£å¨å è½½ç»å®ä¿¡æ¯â¦</div> |
| | | <div v-else-if="failed" class="social-state" role="status"><span>ææ¶æ æ³è·åç»å®ä¿¡æ¯</span><button type="button" @click="load">éæ°å è½½</button></div> |
| | | <div v-else class="social-platforms"> |
| | | <article v-for="item in allBindList" :key="item.type" class="social-platform"> |
| | | <span class="social-platform-icon" :class="item.source"><IconifyIcon :icon="item.source === 'dingtalk' ? 'ant-design:dingtalk-outlined' : 'ant-design:wechat-outlined'" /></span> |
| | | <div class="social-platform-copy"><h3>{{ item.title }}<span :class="{ bound: item.user }">{{ item.user ? 'å·²ç»å®' : 'æªç»å®' }}</span></h3><p>{{ item.user ? (item.user.nickname || 'å·²å
³èæ¤å¹³å°è´¦å·') : 'ç»å®åå¯ä½¿ç¨' + item.title + 'è´¦å·ç»å½' }}</p></div> |
| | | <button type="button" :disabled="busyType !== undefined" :class="{ unbind: item.user }" @click="item.user ? unbind(item.user, item.title) : bind(item.type)">{{ busyType === item.type ? 'å¤çä¸â¦' : item.user ? 'è§£é¤ç»å®' : 'ç«å³ç»å®' }}</button> |
| | | </article> |
| | | </div> |
| | | <div class="social-note"><IconifyIcon icon="lucide:info" /><p>ç»å®æ¶å°åå¾å¯¹åºå¹³å°ææãè§£é¤ç»å®ä¸å½±åå½åè´¦å·ä½¿ç¨ç¨æ·ååå¯ç ç»å½ã</p></div> |
| | | </div> |
| | | </template> |
| | | <style scoped> |
| | | .social-platforms { display: grid; gap: 14px; }.social-platform { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 20px; border: 1px solid #dfeaf4; border-radius: 6px; }.social-platform-icon { display: grid; width: 48px; height: 48px; flex: none; place-items: center; border-radius: 10px; color: #2076bf; background: #edf5fd; font-size: 28px; }.social-platform-icon.wechat_enterprise { color: #19895a; background: #edf8f2; }.social-platform-copy { min-width: 0; flex: 1; }.social-platform-copy h3 { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; margin: 0 0 7px; font-size: 15px; font-weight: 650; }.social-platform-copy h3 span { padding: 2px 7px; border-radius: 3px; color: #657a8c; background: #f0f4f7; font-size: 10px; font-weight: 400; }.social-platform-copy h3 span.bound { color: #207348; background: #edf8f1; }.social-platform-copy p { margin: 0; color: var(--portal-muted); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }.social-platform button,.social-state button { min-height: 34px; flex: none; padding: 6px 13px; border: 1px solid #b7242d; border-radius: 4px; color: #b7242d; background: transparent; font-size: 12px; cursor: pointer; }.social-platform button.unbind { border-color: #d5e0eb; color: #4e6b85; }.social-platform button:disabled { opacity: .5; cursor: wait; }.social-note { display: flex; align-items: flex-start; gap: 9px; margin-top: 22px; color: var(--portal-muted); font-size: 12px; line-height: 1.8; }.social-note svg { width: 16px; height: 16px; flex: none; margin-top: 3px; }.social-note p { margin: 0; }.social-state { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 14px; color: var(--portal-muted); font-size: 13px; }.social-state svg { width: 20px; }.social-error { padding: 12px; color: #a3222b; background: #fff2f1; font-size: 12px; }button:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; } |
| | | @media(max-width:520px) { .social-platform { flex-wrap: wrap; gap: 12px; padding: 16px; }.social-platform-copy { flex-basis: calc(100% - 60px); }.social-platform button { margin-left: 60px; }.social-state { flex-direction: column; } } |
| | | </style> |
| | | |
| | | <div class="pb-3"> |
| | | <div |
| | | class="grid grid-cols-1 gap-2 px-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3" |
| | | > |
| | | <Card v-for="item in allBindList" :key="item.type" class="!mb-2"> |
| | | <div class="flex w-full items-center gap-4"> |
| | | <Image |
| | | :src="item.img" |
| | | :width="40" |
| | | :height="40" |
| | | :alt="item.title" |
| | | :preview="false" |
| | | /> |
| | | <div class="flex flex-1 items-center justify-between"> |
| | | <div class="flex flex-col"> |
| | | <h4 class="mb-1 text-sm text-black/85 dark:text-white/85"> |
| | | {{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }} |
| | | </h4> |
| | | <span class="text-black/45 dark:text-white/45"> |
| | | <template v-if="item.socialUser"> |
| | | {{ item.socialUser?.nickname || item.socialUser?.openid }} |
| | | </template> |
| | | <template v-else> |
| | | ç»å® |
| | | {{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }} |
| | | è´¦å· |
| | | </template> |
| | | </span> |
| | | </div> |
| | | <Button |
| | | :disabled="!!item.socialUser" |
| | | size="small" |
| | | type="link" |
| | | @click="onBind(item)" |
| | | > |
| | | {{ item.socialUser ? 'å·²ç»å®' : 'ç»å®' }} |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | import type { MenuRecordRaw } from '@vben/types'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { computed, ref } from 'vue'; |
| | | |
| | | import { Col, Row } from 'ant-design-vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { preferences } from '@vben/preferences'; |
| | | |
| | | import PortalFooter from '#/components/portal/portal-footer.vue'; |
| | | import { portalCategory, usePortalMenus } from '#/components/portal/use-portal-menus'; |
| | | |
| | | import KpiCards from './modules/kpi-cards.vue'; |
| | | import NoticePanel from './modules/notice-panel.vue'; |
| | | import QuickActions from './modules/quick-actions.vue'; |
| | | import RecentApplications from './modules/recent-applications.vue'; |
| | | import ResearchHighlights from './modules/research-highlights.vue'; |
| | | import TodoPanel from './modules/todo-panel.vue'; |
| | | |
| | | defineOptions({ name: 'DashboardAnalytics' }); |
| | | |
| | | // vite.config.ts 注å
¥çå
¬å¸ Logo è·¯å¾ |
| | | declare const __APP_LOGO__: string; |
| | | const brandLogo = __APP_LOGO__ || '/logos/JSSM.svg'; |
| | | const { menus, leaves, features, goMenu } = usePortalMenus(); |
| | | const appName = computed(() => preferences.copyright.companyName); |
| | | const slideIndex = ref(0); |
| | | const slides = [ |
| | | { title: 'æå¡ä¾éåä½ãå©åä¹¡ææ¯å
´', motto: '弿¾åä½ Â· 妿¯ç ç©¶ · æå¡åå±', description: 'æ¨å¨ä¾éåä½ç»æµç ç©¶ï¼æå¡è¡ä¸é«è´¨éåå±' }, |
| | | { title: 'åè妿¯åéãæ·±å课é¢ç ç©¶', motto: 'è¯¾é¢æå · å¨çº¿ç³æ¥ · è¿åº¦è·è¸ª', description: 'å´ç»ç 究课é¢ï¼è¿æ¥ç³æ¥ã审æ¹ä¸å使å¡' }, |
| | | { title: 'åå髿åå
¬ãå
±äº«å屿æ', motto: 'æµç¨åå · éç¥äºé · 便æ·åç', description: 'æ±èæ¥å¸¸ä¸å¡æå¡ï¼è®©åé¡¹å·¥ä½æåºæ¨è¿' }, |
| | | ]; |
| | | const currentSlide = computed(() => slides[slideIndex.value] ?? slides[0]!); |
| | | const showWork = computed(() => Boolean(features.value.todo || features.value.application || features.value.reimbursement || features.value.guideline)); |
| | | const topicMenus = computed(() => menus.value.slice(0, 4)); |
| | | const serviceLinks = computed(() => leaves.value.slice(0, 6)); |
| | | |
| | | const router = useRouter(); |
| | | |
| | | const greeting = computed(() => { |
| | | const hour = new Date().getHours(); |
| | | if (hour < 6) return '夿·±äº'; |
| | | if (hour < 9) return 'æ©ä¸å¥½'; |
| | | if (hour < 12) return 'ä¸å好'; |
| | | if (hour < 14) return 'ä¸å好'; |
| | | if (hour < 18) return 'ä¸å好'; |
| | | return 'æä¸å¥½'; |
| | | }); |
| | | |
| | | const today = computed(() => { |
| | | const d = new Date(); |
| | | const weekNames = ['æ¥', 'ä¸', 'äº', 'ä¸', 'å', 'äº', 'å
']; |
| | | return `${d.getFullYear()}å¹´${d.getMonth() + 1}æ${d.getDate()}æ¥ ææ${weekNames[d.getDay()]}`; |
| | | }); |
| | | |
| | | function handleNavigate(name: string) { |
| | | router.push({ name }); |
| | | function description(menu: MenuRecordRaw) { |
| | | switch (portalCategory(menu)) { |
| | | case 'research': return 'èç¦å¦æ¯ç ç©¶ï¼æ±éè¯¾é¢æå¡'; |
| | | case 'office': return 'åååçæµç¨ï¼æååå
¬æç'; |
| | | case 'finance': return 'è§èè´¹ç¨åçï¼ä¾¿æ·è´¢å¡æå¡'; |
| | | default: return 'æ±èä¸å¡ä¿¡æ¯ï¼æä¾ååæå¡'; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <!-- æ¬¢è¿æ ï¼èéåº + éè²æè¾¹ --> |
| | | <div class="elite-hero mb-6"> |
| | | <img alt="" class="elite-hero-mark" :src="brandLogo" /> |
| | | <div class="relative z-10 flex flex-wrap items-center justify-between gap-4"> |
| | | <div> |
| | | <h1 class="elite-hero-title">{{ greeting }}ï¼æ¬¢è¿åæ¥</h1> |
| | | <p class="elite-hero-date">{{ today }}</p> |
| | | <div class="portal-home"> |
| | | <section class="portal-hero" aria-labelledby="portal-hero-title"> |
| | | <div class="portal-hero-copy"> |
| | | <h1 id="portal-hero-title"><span v-for="part in currentSlide.title.split('ã')" :key="part">{{ part }}</span></h1> |
| | | <div class="portal-hero-organization"><span></span><strong>{{ appName }}</strong><span></span></div> |
| | | <p class="portal-hero-motto">{{ currentSlide.motto }}</p> |
| | | <p class="portal-hero-description">{{ currentSlide.description }}</p> |
| | | </div> |
| | | <div class="elite-status"> |
| | | <span class="elite-status-dot"></span> |
| | | ç³»ç»è¿è¡ä¸ |
| | | <div class="portal-hero-pages" aria-label="é¦é¡µä¸»é¢åæ¢"> |
| | | <button v-for="(slide, index) in slides" :key="slide.title" type="button" :aria-label="`æ¥ç主é¢ï¼${slide.title}`" :aria-pressed="slideIndex === index" :class="{ active: slideIndex === index }" @click="slideIndex = index"></button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | |
| | | <!-- ç³»ç»ææ --> |
| | | <KpiCards @navigate="handleNavigate" /> |
| | | <div class="portal-content"> |
| | | <QuickActions /> |
| | | |
| | | <!-- å¾
å + å¿«æ·å
¥å£ --> |
| | | <Row :gutter="16" class="mb-4"> |
| | | <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4"> |
| | | <TodoPanel @navigate="handleNavigate" /> |
| | | </Col> |
| | | <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4"> |
| | | <QuickActions @navigate="handleNavigate" /> |
| | | </Col> |
| | | </Row> |
| | | <section v-if="features.application || features.todo || features.notice" class="portal-news-grid" aria-label="ä¸å¡å¨æä¸éç¥"> |
| | | <ResearchHighlights v-if="features.application" :menu="features.application" /> |
| | | <TodoPanel v-else-if="features.todo" /> |
| | | <NoticePanel v-if="features.notice" :menu-path="features.notice.path" /> |
| | | </section> |
| | | |
| | | <!-- æè¿è¯¾é¢ç³æ¥ --> |
| | | <RecentApplications @navigate="handleNavigate" /> |
| | | </Page> |
| | | <section v-if="menus.length" class="portal-columns" aria-label="ä¸å¡æ ç®"> |
| | | <article v-for="menu in menus" :key="menu.path" class="portal-column" :class="`category-${portalCategory(menu)}`"> |
| | | <div class="portal-column-heading"><span><IconifyIcon :icon="typeof menu.icon === 'string' ? menu.icon : 'lucide:layers'" /></span><div><h2>{{ menu.name }}</h2><p>{{ description(menu) }}</p></div></div> |
| | | <div class="portal-column-body"> |
| | | <div class="portal-column-links"> |
| | | <button v-for="link in (menu.children ?? []).slice(0, 4)" :key="link.path" type="button" @click="goMenu(link)"><IconifyIcon icon="lucide:chevron-right" /><span>{{ link.name }}</span></button> |
| | | <p v-if="!menu.children?.length">{{ menu.name }}ç¸å
³ä¸å¡åç</p> |
| | | </div> |
| | | <div class="portal-column-photo portal-photo" :class="`photo-${portalCategory(menu)}`" aria-hidden="true"></div> |
| | | </div> |
| | | <button class="portal-column-enter" type="button" @click="goMenu(menu)">è¿å
¥æ ç® <IconifyIcon icon="lucide:chevron-right" /></button> |
| | | </article> |
| | | </section> |
| | | |
| | | <section v-if="topicMenus.length" class="portal-topic-strip" aria-label="ä¸å¡ç´è¾¾"> |
| | | <div class="portal-topic-label"><h2>ä¸å¡ç´è¾¾</h2><p>æ±èæå¡èµæº<br />æ·±åä¸å¡åå</p></div> |
| | | <button v-for="menu in topicMenus" :key="menu.path" type="button" class="portal-topic portal-photo" :class="`photo-${portalCategory(menu)}`" @click="goMenu(menu)"><span>{{ menu.name }}</span><IconifyIcon icon="lucide:arrow-up-right" /></button> |
| | | </section> |
| | | |
| | | <section v-if="showWork" class="portal-work-section" aria-labelledby="work-title"> |
| | | <div class="portal-panel-heading"><h2 id="work-title"><IconifyIcon icon="lucide:briefcase-business" />æçå·¥ä½</h2><span>ä¸å¡æ°æ®æ¦è§</span></div> |
| | | <KpiCards /> |
| | | </section> |
| | | |
| | | <section class="portal-service-grid"> |
| | | <article class="portal-service-intro"> |
| | | <div class="portal-panel-heading"><h2><IconifyIcon icon="lucide:landmark" />平尿å¡</h2></div> |
| | | <div class="portal-service-intro-body"><div class="portal-service-photo" aria-hidden="true"></div><div><h3>{{ appName }}</h3><p>å´ç»è¯¾é¢ç ç©¶ãæµç¨åå䏿¥å¸¸ä¸å¡ï¼æ±èæå¡å
¥å£ï¼è¿æ¥ä¿¡æ¯ä¸èµæºï¼ä¸ºå项工ä½çæåºå¼å±æä¾ä¾¿æ·æ¯æã</p><button v-if="features.todo" type="button" @click="goMenu(features.todo)">æ¥çæçå¾
å <IconifyIcon icon="lucide:chevron-right" /></button></div></div> |
| | | </article> |
| | | <article v-if="serviceLinks.length" class="portal-service-links"> |
| | | <div class="portal-panel-heading"><h2><IconifyIcon icon="lucide:link" />常ç¨åè½</h2><span>ä¸ç«å¼ä¸å¡æå¡</span></div> |
| | | <div class="portal-service-link-grid"><button v-for="menu in serviceLinks" :key="menu.path" type="button" @click="goMenu(menu)"><IconifyIcon :icon="typeof menu.icon === 'string' ? menu.icon : 'lucide:arrow-up-right'" /><span>{{ menu.name }}</span></button></div> |
| | | </article> |
| | | </section> |
| | | </div> |
| | | <PortalFooter /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ===== æ¬¢è¿æ ===== */ |
| | | .elite-hero { |
| | | position: relative; |
| | | overflow: hidden; |
| | | border-radius: 12px; |
| | | padding: 30px 34px; |
| | | background: |
| | | radial-gradient(circle at 88% 12%, rgba(212, 175, 55, 0.08), transparent 50%), |
| | | linear-gradient(135deg, #123156 0%, #0f2440 58%, #0b1c33 100%); |
| | | border: 1px solid rgba(212, 175, 55, 0.24); |
| | | box-shadow: 0 4px 18px rgba(15, 36, 64, 0.12); |
| | | } |
| | | |
| | | /* å³ä¾§æ·¡æ·¡çå¾½æ æ°´å° */ |
| | | .elite-hero-mark { |
| | | position: absolute; |
| | | top: 50%; |
| | | right: 168px; |
| | | width: 118px; |
| | | height: 118px; |
| | | pointer-events: none; |
| | | opacity: 0.05; |
| | | transform: translateY(-50%); |
| | | } |
| | | |
| | | .elite-hero-title { |
| | | margin: 0; |
| | | font-size: 22px; |
| | | font-weight: 600; |
| | | letter-spacing: 2px; |
| | | color: #f5f0e4; |
| | | } |
| | | |
| | | .elite-hero-date { |
| | | margin: 8px 0 0; |
| | | font-size: 13px; |
| | | letter-spacing: 1px; |
| | | color: rgba(212, 175, 55, 0.85); |
| | | } |
| | | |
| | | .elite-status { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | padding: 7px 16px; |
| | | font-size: 12.5px; |
| | | letter-spacing: 1px; |
| | | color: #f0e2b8; |
| | | background: rgba(212, 175, 55, 0.12); |
| | | border: 1px solid rgba(212, 175, 55, 0.38); |
| | | border-radius: 999px; |
| | | } |
| | | |
| | | /* éæç¶æç¹ï¼ä¸åèå²å¨ç» */ |
| | | .elite-status-dot { |
| | | display: inline-flex; |
| | | width: 7px; |
| | | height: 7px; |
| | | border-radius: 999px; |
| | | background: #d4af37; |
| | | } |
| | | .portal-home { --ink: #153a62; --muted: #5c7187; color: var(--ink); background: #f2f7fc; } |
| | | .portal-hero { position: relative; display: grid; min-height: 350px; place-items: center; overflow: hidden; background: url('/static/imgs/portal-hero-v2.png') center 59% / cover; } |
| | | .portal-hero::before { position: absolute; inset: 0; background: radial-gradient(ellipse at 52% 37%, rgb(255 255 255 / 86%) 0%, rgb(255 255 255 / 64%) 25%, rgb(255 255 255 / 5%) 67%); content: ''; } |
| | | .portal-hero-copy { position: relative; z-index: 1; width: min(1180px, 90%); padding: 24px 0 42px; text-align: center; } |
| | | .portal-hero h1 span { display: inline-block; white-space: nowrap; }.portal-hero h1 span + span { margin-left: .7em; } |
| | | .portal-hero h1 { margin: 0 0 12px; color: #ac131b; font-family: 'KaiTi', 'STKaiti', 'Noto Serif SC', serif; font-size: clamp(32px, 4vw, 54px); font-weight: 800; letter-spacing: .055em; line-height: 1.45; text-shadow: 0 2px 0 rgb(255 255 255 / 75%); } |
| | | .portal-hero-organization { display: flex; align-items: center; justify-content: center; gap: 16px; color: #103965; } |
| | | .portal-hero-organization strong { font-size: 23px; font-weight: 650; letter-spacing: .14em; } |
| | | .portal-hero-organization span { width: 58px; height: 1px; background: #527ba4; } |
| | | .portal-hero-motto { margin: 18px 0 5px; color: #153b67; font-family: 'KaiTi', 'STKaiti', serif; font-size: 23px; font-weight: 600; letter-spacing: .18em; } |
| | | .portal-hero-description { margin: 0; color: #234f78; font-size: 14px; letter-spacing: .12em; } |
| | | .portal-hero-pages { position: absolute; bottom: 13px; z-index: 2; display: flex; gap: 0; } |
| | | .portal-hero-pages button { display: grid; width: 28px; height: 28px; place-items: center; } |
| | | .portal-hero-pages button::before { width: 10px; height: 10px; border: 1px solid rgb(21 58 98 / 16%); border-radius: 50%; background: #fff; content: ''; } |
| | | .portal-hero-pages button.active::before { width: 19px; border-color: #bb2029; border-radius: 7px; background: #bb2029; } |
| | | .portal-content { width: min(1280px, calc(100% - 80px)); margin: 0 auto; padding-top: 22px; } |
| | | .portal-news-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr); gap: 16px; margin-top: 22px; } |
| | | .portal-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 14px; margin-top: 18px; } |
| | | .portal-column { display: flex; min-width: 0; min-height: 278px; flex-direction: column; padding: 19px 18px 16px; border: 1px solid #e0eaf4; border-radius: 6px; background: #fff; box-shadow: 0 3px 10px rgb(24 66 106 / 4%); } |
| | | .portal-column-heading { display: flex; align-items: center; gap: 11px; } |
| | | .portal-column-heading > span { display: grid; width: 46px; height: 46px; flex: none; place-items: center; border-radius: 50%; color: #216dc1; background: #eaf3ff; font-size: 25px; } |
| | | .category-office .portal-column-heading > span { color: #c42a22; background: #fff0ed; } |
| | | .category-finance .portal-column-heading > span { color: #1d5277; background: #edf3f7; } |
| | | .portal-column-heading h2 { margin: 0; color: #153a62; font-size: 17px; font-weight: 700; } |
| | | .portal-column-heading p { margin: 5px 0 0; color: #5c7187; font-size: 11px; } |
| | | .portal-column-body { display: flex; min-height: 132px; justify-content: space-between; gap: 12px; margin: 18px 0 14px; } |
| | | .portal-column-links { display: flex; min-width: 0; flex: 1; flex-direction: column; } |
| | | .portal-column-links button { display: flex; min-height: 31px; align-items: center; gap: 5px; color: #315273; font-size: 12px; text-align: left; } |
| | | .portal-column-links button > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| | | .portal-column-links :deep(svg) { width: 11px; flex: none; color: #8ca7c0; } |
| | | .portal-column-links p { color: #5c7187; font-size: 12px; line-height: 1.7; } |
| | | .portal-photo { background-image: url('/static/imgs/portal-modules-v2.png'); background-size: 200% auto; } |
| | | .photo-research { background-position: left top; }.photo-office { background-position: right top; }.photo-finance { background-position: left bottom; }.photo-archive { background-position: right bottom; } |
| | | .portal-column-photo { width: 43%; min-width: 100px; flex: none; border-radius: 4px; } |
| | | .portal-column-enter { display: inline-flex; min-height: 28px; align-self: flex-start; align-items: center; gap: 5px; margin-top: auto; padding: 3px 11px; border: 1px solid #d9e7f5; border-radius: 4px; color: #315f90; background: #f8fbff; font-size: 11px; } |
| | | .portal-topic-strip { display: grid; grid-template-columns: 190px repeat(auto-fit, minmax(170px, 1fr)); align-items: stretch; gap: 12px; margin-top: 18px; padding: 12px; border: 1px solid #e1ebf4; border-radius: 6px; background: #fff; } |
| | | .portal-topic-label { margin: -12px 0 -12px -12px; padding: 25px 24px; border-radius: 6px 0 0 6px; color: #fff; background: linear-gradient(110deg, #204d7b, #6499c0); } |
| | | .portal-topic-label h2 { margin: 0 0 7px; font-size: 23px; font-weight: 650; } |
| | | .portal-topic-label p { margin: 0; color: #edf6ff; font-size: 12px; line-height: 1.7; } |
| | | .portal-topic { position: relative; display: flex; min-height: 108px; align-items: flex-end; justify-content: center; overflow: hidden; border-radius: 4px; color: #fff; background-size: 200% 200%; } |
| | | .portal-topic::before { position: absolute; inset: 0; background: linear-gradient(transparent 25%, rgb(9 36 60 / 82%)); content: ''; } |
| | | .portal-topic span { position: relative; padding: 10px; font-size: 15px; font-weight: 650; } |
| | | .portal-topic :deep(svg) { position: absolute; top: 10px; right: 10px; width: 16px; } |
| | | .portal-work-section { margin-top: 18px; padding: 18px; border: 1px solid #e1ebf4; border-radius: 6px; background: #fff; } |
| | | .portal-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 15px; } |
| | | .portal-panel-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: 16px; font-weight: 700; } |
| | | .portal-panel-heading h2 :deep(svg) { width: 18px; color: #bb212b; } |
| | | .portal-panel-heading > span { color: #5c7187; font-size: 11px; } |
| | | .portal-service-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 18px; padding: 20px; border: 1px solid #e1ebf4; border-radius: 6px; background: #fff; } |
| | | .portal-service-intro-body { display: flex; gap: 18px; } |
| | | .portal-service-photo { width: 155px; min-height: 130px; flex: none; border-radius: 4px; background: url('/static/imgs/portal-hero-v2.png') 90% 72% / 310%; } |
| | | .portal-service-intro h3 { margin: 0 0 7px; font-size: 13px; font-weight: 650; } |
| | | .portal-service-intro p { margin: 0; color: #5c7187; font-size: 12px; line-height: 1.9; } |
| | | .portal-service-intro button { display: inline-flex; min-height: 27px; align-items: center; gap: 4px; margin-top: 10px; padding: 2px 9px; border-radius: 4px; color: #fff; background: #b7242d; font-size: 11px; } |
| | | .portal-service-link-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } |
| | | .portal-service-link-grid button { display: flex; min-height: 45px; align-items: center; gap: 9px; padding: 8px 10px; border-radius: 4px; color: #315273; background: #f3f7fc; font-size: 11px; text-align: left; } |
| | | .portal-service-link-grid :deep(svg) { width: 21px; height: 21px; flex: none; color: #16803f; } |
| | | .portal-home button { cursor: pointer; } |
| | | .portal-home button:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; } |
| | | .portal-column-links button:hover, .portal-column-enter:hover { color: #b7242d; } |
| | | @media (max-width: 1050px) { .portal-content { width: calc(100% - 48px); } .portal-topic-strip { grid-template-columns: 150px repeat(auto-fit, minmax(135px, 1fr)); } .portal-service-photo { width: 105px; } .portal-service-link-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .portal-hero-organization strong { font-size: 20px; } } |
| | | @media (max-width: 767px) { .portal-hero { min-height: 280px; background-position: 62% 58%; } .portal-hero h1 { font-size: 32px; letter-spacing: .015em; } .portal-hero-organization { gap: 8px; } .portal-hero-organization strong { font-size: 17px; letter-spacing: .03em; } .portal-hero-organization span { width: 22px; } .portal-hero-motto { font-size: 18px; letter-spacing: .03em; } .portal-hero-description { font-size: 12px; letter-spacing: 0; } .portal-content { width: calc(100% - 28px); padding-top: 16px; } .portal-news-grid, .portal-service-grid { grid-template-columns: 1fr; } .portal-topic-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } .portal-topic-label { grid-column: 1 / -1; margin: -12px -12px 0; padding: 14px 18px; border-radius: 6px 6px 0 0; } .portal-topic-label h2 { font-size: 19px; } .portal-topic-label p { display: none; } .portal-service-grid { padding: 16px; } .portal-service-photo { width: 105px; } } |
| | | @media (max-width: 767px) { .portal-hero h1 span { display: block; }.portal-hero h1 span + span { margin-left: 0; }.portal-hero::before { background: radial-gradient(ellipse at 52% 40%, rgb(255 255 255 / 92%) 0%, rgb(255 255 255 / 75%) 42%, rgb(255 255 255 / 8%) 90%); } } |
| | | @media (max-width: 420px) { .portal-hero h1 { font-size: 29px; } .portal-hero-organization strong { font-size: 15px; } .portal-hero-organization span { width: 16px; } .portal-column { min-height: 260px; } } |
| | | </style> |
| | |
| | | <script lang="ts" setup> |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { CountTo } from '@vben/common-ui'; |
| | | <script setup lang="ts"> |
| | | import type { MenuRecordRaw } from '@vben/types'; |
| | | import { computed, onMounted, reactive } from 'vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { Col, Row } from 'ant-design-vue'; |
| | | |
| | | import { usePortalMenus } from '#/components/portal/use-portal-menus'; |
| | | import { getTaskTodoPage } from '#/api/bpm/task'; |
| | | import { getReimbursementPage } from '#/api/finance/reimbursement'; |
| | | import { getApplicationPage } from '#/api/research/application'; |
| | | import { getGuidelinePage } from '#/api/research/guideline'; |
| | | import { getReimbursementPage } from '#/api/finance/reimbursement'; |
| | | |
| | | defineOptions({ name: 'DashboardKpiCards' }); |
| | | |
| | | const emit = defineEmits<{ |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | const todoCount = ref(0); |
| | | const applicationCount = ref(0); |
| | | const reimbursementCount = ref(0); |
| | | const guidelineCount = ref(0); |
| | | |
| | | async function loadTodo() { |
| | | try { |
| | | const result = await getTaskTodoPage({ pageNo: 1, pageSize: 1 }); |
| | | todoCount.value = result.total ?? 0; |
| | | } catch { |
| | | /* æ æéææ¥å£å¼å¸¸ï¼ä¿æ 0 */ |
| | | } |
| | | } |
| | | |
| | | async function loadApplication() { |
| | | try { |
| | | const result = await getApplicationPage({ pageNo: 1, pageSize: 1 }); |
| | | applicationCount.value = result.total ?? 0; |
| | | } catch { |
| | | /* æ æéææ¥å£å¼å¸¸ï¼ä¿æ 0 */ |
| | | } |
| | | } |
| | | |
| | | async function loadReimbursement() { |
| | | try { |
| | | const result = await getReimbursementPage({ pageNo: 1, pageSize: 1 }); |
| | | reimbursementCount.value = result.total ?? 0; |
| | | } catch { |
| | | /* æ æéææ¥å£å¼å¸¸ï¼ä¿æ 0 */ |
| | | } |
| | | } |
| | | |
| | | async function loadGuideline() { |
| | | try { |
| | | const result = await getGuidelinePage({ pageNo: 1, pageSize: 1 }); |
| | | guidelineCount.value = result.total ?? 0; |
| | | } catch { |
| | | /* æ æéææ¥å£å¼å¸¸ï¼ä¿æ 0 */ |
| | | } |
| | | } |
| | | |
| | | type MetricKey = 'todo' | 'application' | 'reimbursement' | 'guideline'; |
| | | interface Metric { key: MetricKey; menu: MenuRecordRaw; icon: string; unit: string } |
| | | const { features, goMenu } = usePortalMenus(); |
| | | const states = reactive<Record<MetricKey, { loading: boolean; failed: boolean; count: number }>>({ |
| | | todo: { loading: true, failed: false, count: 0 }, application: { loading: true, failed: false, count: 0 }, reimbursement: { loading: true, failed: false, count: 0 }, guideline: { loading: true, failed: false, count: 0 }, |
| | | }); |
| | | const metrics = computed<Metric[]>(() => { |
| | | const f = features.value; |
| | | return [ |
| | | f.todo && { key: 'todo', menu: f.todo, icon: 'lucide:list-todo', unit: '项å¾
å' }, |
| | | f.application && { key: 'application', menu: f.application, icon: 'lucide:file-text', unit: 'é¡¹ç³æ¥' }, |
| | | f.reimbursement && { key: 'reimbursement', menu: f.reimbursement, icon: 'lucide:receipt', unit: 'å¼ æ¥éå' }, |
| | | f.guideline && { key: 'guideline', menu: f.guideline, icon: 'lucide:book-open', unit: '份æå' }, |
| | | ].filter((item): item is Metric => Boolean(item)); |
| | | }); |
| | | const loaders = { todo: getTaskTodoPage, application: getApplicationPage, reimbursement: getReimbursementPage, guideline: getGuidelinePage }; |
| | | onMounted(() => { |
| | | loadTodo(); |
| | | loadApplication(); |
| | | loadReimbursement(); |
| | | loadGuideline(); |
| | | for (const metric of metrics.value) { |
| | | loaders[metric.key]({ pageNo: 1, pageSize: 1 }).then((result) => { states[metric.key].count = result.total ?? 0; }).catch(() => { states[metric.key].failed = true; }).finally(() => { states[metric.key].loading = false; }); |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Row :gutter="16"> |
| | | <!-- æçå¾
å --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <div |
| | | class="elite-card group cursor-pointer" |
| | | @click="emit('navigate', 'BpmTaskTodo')" |
| | | > |
| | | <div class="flex items-center gap-4 p-5"> |
| | | <div class="elite-icon-tile"> |
| | | <IconifyIcon class="size-6" icon="lucide:list-todo" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="elite-label">æçå¾
å</div> |
| | | <div class="flex items-baseline gap-1"> |
| | | <CountTo |
| | | class="elite-value" |
| | | :class="todoCount > 0 ? 'text-[#b8901f] dark:text-[#e3c567]' : ''" |
| | | :duration="1200" |
| | | :end-val="todoCount" |
| | | /> |
| | | <span class="elite-unit">项</span> |
| | | </div> |
| | | <div class="elite-meta">审æ¹ä¸å¿ · å¾
å¤çä»»å¡</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Col> |
| | | |
| | | <!-- 课é¢ç³æ¥ --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <div |
| | | class="elite-card group cursor-pointer" |
| | | @click="emit('navigate', 'ResearchApplication')" |
| | | > |
| | | <div class="flex items-center gap-4 p-5"> |
| | | <div class="elite-icon-tile"> |
| | | <IconifyIcon class="size-6" icon="lucide:file-text" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="elite-label">课é¢ç³æ¥</div> |
| | | <div class="flex items-baseline gap-1"> |
| | | <CountTo |
| | | class="elite-value" |
| | | :duration="1200" |
| | | :end-val="applicationCount" |
| | | /> |
| | | <span class="elite-unit">项</span> |
| | | </div> |
| | | <div class="elite-meta">课é¢ç®¡ç · ç³æ¥ä¹¦</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Col> |
| | | |
| | | <!-- è´¢å¡æ¥é --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <div |
| | | class="elite-card group cursor-pointer" |
| | | @click="emit('navigate', 'FinanceReimbursement')" |
| | | > |
| | | <div class="flex items-center gap-4 p-5"> |
| | | <div class="elite-icon-tile"> |
| | | <IconifyIcon class="size-6" icon="lucide:receipt" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="elite-label">è´¢å¡æ¥é</div> |
| | | <div class="flex items-baseline gap-1"> |
| | | <CountTo |
| | | class="elite-value" |
| | | :duration="1200" |
| | | :end-val="reimbursementCount" |
| | | /> |
| | | <span class="elite-unit">å</span> |
| | | </div> |
| | | <div class="elite-meta">è´¢å¡æ¥é · æ¥éå</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Col> |
| | | |
| | | <!-- è¯¾é¢æå --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <div |
| | | class="elite-card group cursor-pointer" |
| | | @click="emit('navigate', 'ResearchGuideline')" |
| | | > |
| | | <div class="flex items-center gap-4 p-5"> |
| | | <div class="elite-icon-tile"> |
| | | <IconifyIcon class="size-6" icon="lucide:book-open" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="elite-label">è¯¾é¢æå</div> |
| | | <div class="flex items-baseline gap-1"> |
| | | <CountTo |
| | | class="elite-value" |
| | | :duration="1200" |
| | | :end-val="guidelineCount" |
| | | /> |
| | | <span class="elite-unit">份</span> |
| | | </div> |
| | | <div class="elite-meta">课é¢ç®¡ç · ç³æ¥æå</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Col> |
| | | </Row> |
| | | <div class="portal-metrics" :style="{ '--count': metrics.length }"><button v-for="metric in metrics" :key="metric.key" type="button" @click="goMenu(metric.menu)"><span class="metric-icon"><IconifyIcon :icon="metric.icon" /></span><div><strong>{{ metric.menu.name }}</strong><div class="metric-value"><b>{{ states[metric.key].loading ? 'â¦' : states[metric.key].failed ? 'â' : states[metric.key].count }}</b><span>{{ states[metric.key].failed ? 'ææ¶æ æ³è·å' : metric.unit }}</span></div></div><IconifyIcon class="metric-arrow" icon="lucide:chevron-right" /></button></div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ===== 妿¯å
¸é
å¡ç ===== */ |
| | | .elite-card { |
| | | background: #fdfcfa; |
| | | border: 1px solid rgba(212, 175, 55, 0.22); |
| | | border-radius: 14px; |
| | | box-shadow: 0 2px 14px rgba(15, 36, 64, 0.05); |
| | | transition: |
| | | border-color 0.3s ease, |
| | | box-shadow 0.3s ease; |
| | | } |
| | | |
| | | .elite-card:hover { |
| | | border-color: rgba(212, 175, 55, 0.5); |
| | | box-shadow: 0 6px 20px rgba(15, 36, 64, 0.09); |
| | | } |
| | | |
| | | .dark .elite-card { |
| | | background: #101a2b; |
| | | border-color: rgba(212, 175, 55, 0.2); |
| | | box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3); |
| | | } |
| | | |
| | | /* 徿 åºï¼è鿏å + éè²å¾æ */ |
| | | .elite-icon-tile { |
| | | display: flex; |
| | | width: 48px; |
| | | height: 48px; |
| | | flex-shrink: 0; |
| | | align-items: center; |
| | | justify-content: center; |
| | | border-radius: 12px; |
| | | color: #e3c567; |
| | | background: linear-gradient(135deg, #1e4571, #0f2440); |
| | | border: 1px solid rgba(212, 175, 55, 0.3); |
| | | box-shadow: 0 4px 12px rgba(15, 36, 64, 0.18); |
| | | transition: border-color 0.3s ease; |
| | | } |
| | | |
| | | /* æ¬ååªè®©éè²æè¾¹å¾®å¾®æäº®ï¼ä¸åç¼©æ¾ */ |
| | | .group:hover .elite-icon-tile { |
| | | border-color: rgba(212, 175, 55, 0.6); |
| | | } |
| | | |
| | | .elite-label { |
| | | margin-bottom: 4px; |
| | | font-size: 12px; |
| | | font-weight: 500; |
| | | letter-spacing: 1px; |
| | | color: #8a94a6; |
| | | } |
| | | |
| | | .elite-value { |
| | | font-size: 24px; |
| | | font-weight: 700; |
| | | line-height: 1.2; |
| | | color: #0f2440; |
| | | } |
| | | |
| | | .dark .elite-value { |
| | | color: #e3c567; |
| | | } |
| | | |
| | | .elite-unit { |
| | | font-size: 12px; |
| | | color: #8a94a6; |
| | | } |
| | | |
| | | .elite-meta { |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | color: #8a94a6; |
| | | } |
| | | .portal-metrics { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); } |
| | | button { display: flex; min-width: 0; min-height: 80px; align-items: center; gap: 13px; padding: 8px 20px; border-right: 1px solid #e4edf5; background: #fff; text-align: left; cursor: pointer; } |
| | | button:last-child { border: 0; }.metric-icon { display: grid; width: 43px; height: 43px; flex: none; place-items: center; border-radius: 50%; color: #2775b9; background: #ecf4fd; font-size: 23px; } |
| | | strong { color: #315273; font-size: 13px; font-weight: 600; }.metric-value { display: flex; align-items: baseline; gap: 7px; margin-top: 4px; }.metric-value b { color: #173958; font-size: 26px; line-height: 1.2; }.metric-value span { color: #5c7187; font-size: 11px; }.metric-arrow { width: 15px; flex: none; margin-left: auto; color: #5c7187; } |
| | | button:hover { background: #f7faff; }button:focus-visible { outline: 2px solid #2c70b2; outline-offset: -2px; } |
| | | @media (max-width: 850px) { .portal-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 0; }button { padding: 8px 10px; }button:nth-child(2n) { border-right: 0; } } |
| | | @media (max-width: 420px) { .metric-icon { width: 34px; height: 34px; font-size: 20px; }button { gap: 8px; padding: 8px 5px; }.metric-arrow { display: none; }.metric-value b { font-size: 23px; } } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { getNoticePage as getOaNoticePage } from '#/api/bpm/oa/notice'; |
| | | import { getNoticePage as getSystemNoticePage } from '#/api/system/notice'; |
| | | |
| | | defineOptions({ name: 'DashboardNoticePanel' }); |
| | | const props = defineProps<{ menuPath: string }>(); |
| | | const router = useRouter(); |
| | | const loading = ref(true); |
| | | const failed = ref(false); |
| | | const isOa = computed(() => props.menuPath.includes('/bpm/')); |
| | | const notices = ref<{ id?: number; title: string; date: string }[]>([]); |
| | | |
| | | function dateText(value: string | Date | undefined) { |
| | | if (!value) return ''; |
| | | if (typeof value === 'string') return value.slice(0, 10); |
| | | return new Intl.DateTimeFormat('zh-CN').format(value); |
| | | } |
| | | |
| | | async function load() { |
| | | loading.value = true; |
| | | failed.value = false; |
| | | try { |
| | | if (isOa.value) { |
| | | const result = await getOaNoticePage({ pageNo: 1, pageSize: 6 }); |
| | | notices.value = (result.list ?? []).map((item) => ({ id: item.id, title: item.title, date: dateText(item.publishTime || item.createTime) })); |
| | | } else { |
| | | const result = await getSystemNoticePage({ pageNo: 1, pageSize: 6 }); |
| | | notices.value = (result.list ?? []).map((item) => ({ id: item.id, title: item.title, date: dateText(item.createTime) })); |
| | | } |
| | | } catch { |
| | | notices.value = []; |
| | | failed.value = true; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | onMounted(load); |
| | | |
| | | function openNotice(notice: { id?: number }) { |
| | | if (isOa.value && notice.id && router.hasRoute('OANoticeDetail')) { |
| | | router.push({ name: 'OANoticeDetail', query: { id: notice.id } }); |
| | | } else router.push(props.menuPath); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="notice-panel"> |
| | | <div class="notice-head"> |
| | | <h2><IconifyIcon icon="lucide:megaphone" /> éç¥å
Œ</h2> |
| | | <button type="button" @click="router.push(menuPath)">æ´å¤ <IconifyIcon icon="lucide:chevron-right" /></button> |
| | | </div> |
| | | <div v-if="loading" class="notice-empty" role="status">å è½½ä¸â¦</div> |
| | | <div v-else-if="failed" class="notice-empty notice-error" role="status"> |
| | | <span>å
¬åå 载失败ï¼è¯·éè¯</span> |
| | | <button type="button" @click="load">éè¯</button> |
| | | </div> |
| | | <div v-else-if="!notices.length" class="notice-empty">ææ éç¥å
Œ</div> |
| | | <ul v-else> |
| | | <li v-for="notice in notices" :key="notice.id"> |
| | | <button type="button" @click="openNotice(notice)"> |
| | | <span class="notice-title">{{ notice.title }}</span> |
| | | <time>{{ notice.date }}</time> |
| | | </button> |
| | | </li> |
| | | </ul> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .notice-panel { height: 100%; min-width: 0; min-height: 300px; padding: 18px; border: 1px solid #e1ebf5; border-radius: 6px; background: #fff; box-shadow: 0 3px 10px rgb(24 66 106 / 4%); } |
| | | .notice-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } |
| | | .notice-head h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #153a62; font-size: 17px; font-weight: 700; } |
| | | .notice-head h2 :deep(svg) { width: 19px; color: #b7242d; } |
| | | .notice-head button { display: inline-flex; align-items: center; gap: 2px; color: #58799b; font-size: 12px; } |
| | | .notice-empty { display: grid; min-height: 230px; place-items: center; color: #5c7187; font-size: 13px; } |
| | | .notice-error { align-content: center; gap: 10px; } |
| | | .notice-error button { min-height: 36px; padding: 0 18px; border: 1px solid #c3d5e6; border-radius: 5px; color: #245987; background: #f4f8fc; } |
| | | ul { margin: 0; padding: 0; list-style: none; } |
| | | li + li { border-top: 1px solid #edf2f6; } |
| | | li button { display: flex; width: 100%; min-height: 39px; align-items: center; gap: 12px; padding: 9px 2px; text-align: left; } |
| | | li button::before { width: 5px; height: 5px; flex: none; border-radius: 50%; background: #b7242d; content: ''; } |
| | | .notice-title { min-width: 0; flex: 1; overflow: hidden; color: #284563; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } |
| | | time { flex: none; color: #5c7187; font-size: 11px; } |
| | | li button:hover .notice-title, .notice-head button:hover { color: #b7242d; } |
| | | button:focus-visible { outline: 2px solid #2775bd; outline-offset: 2px; } |
| | | .dark .notice-panel { border-color: #33485e; background: #14283e; } |
| | | .dark .notice-head h2, .dark .notice-title { color: #e8eef7; } |
| | | .dark .notice-empty, .dark time { color: #afc2d5; } |
| | | .dark li + li { border-color: #33485e; } |
| | | </style> |
| | |
| | | <script lang="ts" setup> |
| | | import type { MenuRecordRaw } from '@vben/types'; |
| | | import { computed } from 'vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { usePortalMenus } from '#/components/portal/use-portal-menus'; |
| | | |
| | | defineOptions({ name: 'DashboardQuickActions' }); |
| | | |
| | | const emit = defineEmits<{ |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | interface QuickAction { |
| | | title: string; |
| | | icon: string; |
| | | route: string; |
| | | const { features, leaves, goMenu } = usePortalMenus(); |
| | | const actions = computed(() => { |
| | | const f = features.value; |
| | | const preferred = [f.todo, f.create, f.application, f.guideline, f.reimbursement, f.notice].filter((item): item is MenuRecordRaw => Boolean(item)); |
| | | return [...new Map([...preferred, ...leaves.value].map((menu) => [menu.path, menu])).values()].slice(0, 6); |
| | | }); |
| | | function caption(menu: MenuRecordRaw) { |
| | | if (/todo|å¾
å/.test(menu.path + menu.name)) return 'ä»»å¡å¤ç · è¿åº¦è·è¿'; |
| | | if (/application|ç³æ¥/.test(menu.path + menu.name)) return 'å¨çº¿ç³æ¥ · è¿åº¦æ¥è¯¢'; |
| | | if (/guideline|æå/.test(menu.path + menu.name)) return 'æåæ¥è¯¢ · ç³æ¥åè'; |
| | | if (/notice|å
¬å/.test(menu.path + menu.name)) return 'éç¥åå¸ Â· ä¿¡æ¯æ¥é
'; |
| | | if (/reimbursement|æ¥é/.test(menu.path + menu.name)) return 'è´¹ç¨æ¥é · åæ®æ¥è¯¢'; |
| | | return 'ä¸å¡åç · ä¿¡æ¯æ¥è¯¢'; |
| | | } |
| | | |
| | | /** å¿«æ·å
¥å£ï¼å¯¹åºç³»ç»å®é
èåï¼å®¡æ¹ä¸å¿ / 课é¢ç®¡ç / è´¢å¡æ¥é / éç¥å
¬åï¼ */ |
| | | const actions: QuickAction[] = [ |
| | | { title: 'åèµ·æµç¨', icon: 'lucide:circle-plus', route: 'BpmProcessInstanceCreate' }, |
| | | { title: 'å¾
åä»»å¡', icon: 'lucide:list-todo', route: 'BpmTaskTodo' }, |
| | | { title: 'å·²åä»»å¡', icon: 'lucide:circle-check-big', route: 'BpmTaskDone' }, |
| | | { title: 'æçæµç¨', icon: 'lucide:git-branch', route: 'BpmProcessInstance' }, |
| | | { title: '课é¢ç³æ¥', icon: 'lucide:file-text', route: 'ResearchApplication' }, |
| | | { title: 'è¯¾é¢æå', icon: 'lucide:book-open', route: 'ResearchGuideline' }, |
| | | { title: 'è´¢å¡æ¥é', icon: 'lucide:receipt', route: 'FinanceReimbursement' }, |
| | | { title: 'éç¥å
Œ', icon: 'lucide:bell', route: 'BpmOaNotice' }, |
| | | ]; |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="elite-card p-5"> |
| | | <div class="elite-head"> |
| | | <h3 class="elite-title"> |
| | | <span class="elite-bar"></span> |
| | | å¿«æ·å
¥å£ |
| | | </h3> |
| | | <section v-if="actions.length" class="portal-quick-strip" aria-labelledby="quick-title"> |
| | | <div class="portal-quick-label"><h2 id="quick-title">å¿«æ·å
¥å£</h2><p>ä¸ç«å¼æå¡<br />é«æä¾¿æ·</p><IconifyIcon icon="lucide:circle-arrow-right" /></div> |
| | | <div class="portal-quick-actions" :style="{ '--columns': actions.length }"> |
| | | <button v-for="menu in actions" :key="menu.path" type="button" @click="goMenu(menu)"><span class="portal-quick-icon"><IconifyIcon :icon="typeof menu.icon === 'string' ? menu.icon : 'lucide:arrow-up-right'" /></span><strong>{{ menu.name }}</strong><span class="portal-quick-caption">{{ caption(menu) }}</span></button> |
| | | </div> |
| | | <div class="grid grid-cols-4 gap-3"> |
| | | <div |
| | | v-for="action in actions" |
| | | :key="action.route" |
| | | class="elite-action group flex cursor-pointer flex-col items-center gap-2 rounded-xl p-3 transition-colors duration-300" |
| | | @click="emit('navigate', action.route)" |
| | | > |
| | | <div |
| | | class="elite-icon flex size-10 items-center justify-center rounded-xl transition-shadow duration-300" |
| | | > |
| | | <IconifyIcon :icon="action.icon" class="size-5 text-[#e3c567]" /> |
| | | </div> |
| | | <span class="elite-action-label">{{ action.title }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .elite-card { |
| | | background: #fdfcfa; |
| | | border: 1px solid rgba(212, 175, 55, 0.22); |
| | | border-radius: 14px; |
| | | box-shadow: 0 2px 14px rgba(15, 36, 64, 0.05); |
| | | transition: |
| | | border-color 0.3s ease, |
| | | box-shadow 0.3s ease; |
| | | } |
| | | |
| | | .elite-card:hover { |
| | | border-color: rgba(212, 175, 55, 0.5); |
| | | box-shadow: 0 6px 20px rgba(15, 36, 64, 0.09); |
| | | } |
| | | |
| | | .dark .elite-card { |
| | | background: #101a2b; |
| | | border-color: rgba(212, 175, 55, 0.2); |
| | | box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3); |
| | | } |
| | | |
| | | .elite-head { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .elite-title { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | margin: 0; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | letter-spacing: 1px; |
| | | color: #0f2440; |
| | | } |
| | | |
| | | .dark .elite-title { |
| | | color: #f0ead8; |
| | | } |
| | | |
| | | .elite-bar { |
| | | display: inline-block; |
| | | width: 3px; |
| | | height: 15px; |
| | | border-radius: 2px; |
| | | background: linear-gradient(180deg, #e3c567, #b8901f); |
| | | } |
| | | |
| | | /* 徿 åºï¼è鿏å + éè²å¾æ */ |
| | | .elite-icon { |
| | | color: #e3c567; |
| | | background: linear-gradient(135deg, #1e4571, #0f2440); |
| | | border: 1px solid rgba(212, 175, 55, 0.3); |
| | | box-shadow: 0 4px 12px rgba(15, 36, 64, 0.18); |
| | | } |
| | | |
| | | /* æ¬ååªæäº®éè²æè¾¹ï¼ä¸åä½ç§» */ |
| | | .elite-action:hover .elite-icon { |
| | | box-shadow: |
| | | 0 0 0 1px rgba(212, 175, 55, 0.7), |
| | | 0 4px 14px rgba(15, 36, 64, 0.22); |
| | | } |
| | | |
| | | .elite-action-label { |
| | | font-size: 12px; |
| | | font-weight: 500; |
| | | letter-spacing: 0.5px; |
| | | color: #8a94a6; |
| | | } |
| | | |
| | | .elite-action:hover .elite-action-label { |
| | | color: #b8901f; |
| | | } |
| | | |
| | | .dark .elite-action:hover .elite-action-label { |
| | | color: #e3c567; |
| | | } |
| | | .portal-quick-strip { display: flex; overflow: hidden; border-radius: 8px; background: #fff; box-shadow: 0 5px 24px rgb(25 70 110 / 6%); } |
| | | .portal-quick-label { width: 170px; flex: none; padding: 24px 30px; color: #fff; background: #b8222c; clip-path: polygon(0 0, 88% 0, 100% 50%, 88% 100%, 0 100%); } |
| | | .portal-quick-label h2 { margin: 0 0 9px; font-size: 23px; font-weight: 650; white-space: nowrap; } |
| | | .portal-quick-label p { margin: 0 0 10px; color: #fff4f4; font-size: 13px; line-height: 1.6; letter-spacing: .07em; } |
| | | .portal-quick-label :deep(svg) { width: 18px; height: 18px; } |
| | | .portal-quick-actions { display: grid; min-width: 0; flex: 1; grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); align-items: center; padding: 18px 12px; } |
| | | .portal-quick-actions button { display: flex; min-width: 0; min-height: 116px; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 4px 6px; border-right: 1px solid #e4edf7; text-align: center; cursor: pointer; } |
| | | .portal-quick-actions button:last-child { border-right: 0; } |
| | | .portal-quick-icon { display: grid; width: 55px; height: 55px; place-items: center; border-radius: 50%; color: #2877d1; background: #eaf3ff; font-size: 29px; } |
| | | .portal-quick-actions button:nth-child(4) .portal-quick-icon { color: #139d8a; background: #e8f7f4; } |
| | | .portal-quick-actions strong { max-width: 100%; overflow: hidden; color: #173958; font-size: 14px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } |
| | | .portal-quick-caption { color: #5c7187; font-size: 10px; white-space: nowrap; } |
| | | .portal-quick-actions button:hover { background: #f4f8fd; } |
| | | button:focus-visible { outline: 2px solid #2c70b2; outline-offset: -2px; } |
| | | @media (max-width: 1050px) { .portal-quick-label { width: 145px; padding: 24px 22px; } .portal-quick-caption { font-size: 9px; } } |
| | | @media (max-width: 767px) { .portal-quick-strip { flex-direction: column; } .portal-quick-label { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 15px; padding: 14px 18px; clip-path: none; } .portal-quick-label h2 { margin: 0; font-size: 20px; } .portal-quick-label p { margin: 0; font-size: 11px; } .portal-quick-label p br { display: none; } .portal-quick-actions { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px 0; padding: 16px 6px; } .portal-quick-actions button:nth-child(3n) { border-right: 0; } .portal-quick-caption { font-size: 9px; } .portal-quick-icon { width: 46px; height: 46px; font-size: 24px; } .portal-quick-actions strong { font-size: 13px; } } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import type { MenuRecordRaw } from '@vben/types'; |
| | | import type { ResearchApplicationApi } from '#/api/research/application'; |
| | | import { onMounted, ref } from 'vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { useRouter } from 'vue-router'; |
| | | import { usePortalMenus } from '#/components/portal/use-portal-menus'; |
| | | import { getApplicationPage } from '#/api/research/application'; |
| | | |
| | | const props = defineProps<{ menu: MenuRecordRaw }>(); |
| | | const { goMenu } = usePortalMenus(); |
| | | const router = useRouter(); |
| | | const loading = ref(true); |
| | | const failed = ref(false); |
| | | const applications = ref<ResearchApplicationApi.Application[]>([]); |
| | | async function load() { |
| | | loading.value = true; failed.value = false; |
| | | try { const result = await getApplicationPage({ pageNo: 1, pageSize: 6 }); applications.value = result.list ?? []; } |
| | | catch { failed.value = true; applications.value = []; } |
| | | finally { loading.value = false; } |
| | | } |
| | | function openApplication(item: ResearchApplicationApi.Application) { |
| | | if (item.id && router.hasRoute('ResearchApplicationDetail')) router.push({ name: 'ResearchApplicationDetail', query: { id: item.id } }); |
| | | else goMenu(props.menu); |
| | | } |
| | | onMounted(load); |
| | | </script> |
| | | |
| | | <template> |
| | | <article class="portal-research-panel"> |
| | | <div class="research-heading"><h2><IconifyIcon icon="lucide:newspaper" />课é¢å¨æ</h2><button type="button" @click="goMenu(menu)">æ´å¤ <IconifyIcon icon="lucide:chevron-right" /></button></div> |
| | | <div class="research-content"> |
| | | <button class="research-feature" type="button" @click="goMenu(menu)"><span>课é¢ç ç©¶ä¸ç³æ¥æå¡</span><small>æ¥ç课é¢ç³æ¥ <IconifyIcon icon="lucide:arrow-right" /></small></button> |
| | | <div class="research-list-area"> |
| | | <div v-if="loading" class="research-status" role="status">æ£å¨å 载课é¢å¨æâ¦</div> |
| | | <div v-else-if="failed" class="research-status" role="status"><span>课é¢å¨æå 载失败</span><button type="button" @click="load">éæ°å è½½</button></div> |
| | | <div v-else-if="!applications.length" class="research-status"><IconifyIcon icon="lucide:files" /><span>ææ è¯¾é¢ç³æ¥è®°å½</span><button type="button" @click="goMenu(menu)">è¿å
¥è¯¾é¢ç³æ¥</button></div> |
| | | <ul v-else><li v-for="item in applications" :key="item.id ?? item.no"><button type="button" @click="openApplication(item)"><span>{{ item.title }}</span><time>{{ (item.createTime || '').slice(0, 10) }}</time></button></li></ul> |
| | | </div> |
| | | </div> |
| | | </article> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .portal-research-panel { min-width: 0; padding: 18px; border: 1px solid #e1ebf5; border-radius: 6px; background: #fff; box-shadow: 0 3px 10px rgb(24 66 106 / 4%); } |
| | | .research-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 17px; }.research-heading h2 { display: flex; align-items: center; gap: 10px; margin: 0; color: #153a62; font-size: 17px; font-weight: 700; }.research-heading h2 :deep(svg) { width: 19px; color: #bf222b; }.research-heading > button { display: inline-flex; align-items: center; gap: 2px; color: #5c7187; font-size: 11px; } |
| | | .research-content { display: grid; grid-template-columns: minmax(180px, .95fr) minmax(0, 1.1fr); gap: 20px; min-height: 234px; }.research-feature { position: relative; display: flex; min-height: 234px; flex-direction: column; align-items: flex-start; justify-content: flex-end; overflow: hidden; padding: 17px 16px; border-radius: 5px; color: #fff; background: url('/static/imgs/portal-modules-v2.png') left top / 200% 200%; text-align: left; }.research-feature::before { position: absolute; inset: 0; background: linear-gradient(transparent 35%, rgb(10 34 58 / 90%)); content: ''; }.research-feature span { position: relative; font-size: 16px; font-weight: 650; }.research-feature small { position: relative; display: flex; align-items: center; gap: 8px; margin-top: 9px; color: #e5f0fc; font-size: 11px; }.research-feature small :deep(svg) { width: 14px; } |
| | | .research-list-area { min-width: 0; }.research-status { display: flex; min-height: 230px; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #5c7187; font-size: 13px; }.research-status > :deep(svg) { width: 32px; height: 32px; color: #b6c9dc; }.research-status button { min-height: 30px; padding: 4px 12px; border: 1px solid #d8e5f3; border-radius: 4px; color: #2b6094; background: #f6faff; font-size: 11px; } |
| | | ul { margin: 0; padding: 0; list-style: none; }li + li { border-top: 1px solid #eef3f8; }li button { display: flex; width: 100%; min-height: 39px; align-items: center; gap: 12px; color: #315273; text-align: left; }li span { min-width: 0; flex: 1; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }li time { flex: none; color: #5c7187; font-size: 10px; }button { cursor: pointer; }button:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; }li button:hover { color: #b7242d; } |
| | | @media (max-width: 1050px) { .research-content { grid-template-columns: minmax(150px, .85fr) minmax(0, 1fr); gap: 12px; }li time { display: none; } } |
| | | @media (max-width: 520px) { .research-content { grid-template-columns: 1fr; gap: 12px; }.research-feature { min-height: 180px; background-size: 200% 280%; }.research-status { min-height: 125px; }li time { display: block; } } |
| | | </style> |
| | |
| | | <script lang="ts" setup> |
| | | <script setup lang="ts"> |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { usePortalMenus } from '#/components/portal/use-portal-menus'; |
| | | import { getTaskTodoPage } from '#/api/bpm/task'; |
| | | |
| | | defineOptions({ name: 'DashboardTodoPanel' }); |
| | | |
| | | const emit = defineEmits<{ |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | interface TodoItem { |
| | | id: string; |
| | | name: string; |
| | | createTime?: number; |
| | | } |
| | | |
| | | const loading = ref(true); |
| | | const items = ref<TodoItem[]>([]); |
| | | |
| | | /** createTime ä¸ºæ¯«ç§æ¶é´æ³ï¼æ ¼å¼åææè¯»ææ¬ */ |
| | | function formatTime(ts?: number) { |
| | | if (!ts) return '-'; |
| | | const d = new Date(ts); |
| | | const p = (n: number) => String(n).padStart(2, '0'); |
| | | return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; |
| | | } |
| | | |
| | | const { features, goMenu } = usePortalMenus(); |
| | | const loading = ref(true), failed = ref(false); |
| | | const tasks = ref<{ id: string; name: string }[]>([]); |
| | | async function load() { |
| | | loading.value = true; |
| | | try { |
| | | const result = await getTaskTodoPage({ pageNo: 1, pageSize: 6 }); |
| | | items.value = (result.list ?? []).map((t: any) => ({ |
| | | id: String(t.id), |
| | | name: t.name ?? 'æªå½åä»»å¡', |
| | | createTime: t.createTime, |
| | | })); |
| | | } catch { |
| | | items.value = []; |
| | | } finally { |
| | | loading.value = false; |
| | | loading.value = true; failed.value = false; |
| | | try { const result = await getTaskTodoPage({ pageNo: 1, pageSize: 6 }); tasks.value = (result.list ?? []).map((task) => ({ id: String(task.id), name: task.name || 'æªå½åä»»å¡' })); } |
| | | catch { failed.value = true; tasks.value = []; } |
| | | finally { loading.value = false; } |
| | | } |
| | | } |
| | | |
| | | onMounted(load); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="elite-section h-full"> |
| | | <div class="elite-head"> |
| | | <h3 class="elite-title"> |
| | | <span class="elite-bar"></span> |
| | | æçå¾
å |
| | | </h3> |
| | | <span class="elite-more" @click="emit('navigate', 'BpmTaskTodo')"> |
| | | å
¨é¨ |
| | | <IconifyIcon class="size-3.5" icon="lucide:chevron-right" /> |
| | | </span> |
| | | </div> |
| | | |
| | | <!-- éª¨æ¶ --> |
| | | <div v-if="loading" class="elite-empty">å è½½ä¸â¦</div> |
| | | |
| | | <!-- ç©ºç¶æ --> |
| | | <div v-else-if="items.length === 0" class="elite-empty"> |
| | | <IconifyIcon class="mb-2 size-8 opacity-40" icon="lucide:inbox" /> |
| | | <div>ææ å¾
åä»»å¡</div> |
| | | </div> |
| | | |
| | | <!-- å表 --> |
| | | <ul v-else class="elite-list"> |
| | | <li |
| | | v-for="item in items" |
| | | :key="item.id" |
| | | class="elite-row" |
| | | @click="emit('navigate', 'BpmTaskTodo')" |
| | | > |
| | | <span class="elite-dot"></span> |
| | | <span class="elite-row-name">{{ item.name }}</span> |
| | | <span class="elite-row-time">{{ formatTime(item.createTime) }}</span> |
| | | </li> |
| | | </ul> |
| | | </div> |
| | | </template> |
| | | <template><article class="portal-todo-panel"><div class="todo-heading"><h2><IconifyIcon icon="lucide:list-todo" />æçå¾
å</h2><button type="button" @click="goMenu(features.todo)">æ´å¤ <IconifyIcon icon="lucide:chevron-right" /></button></div><div v-if="loading" class="todo-status" role="status">æ£å¨å è½½å¾
åâ¦</div><div v-else-if="failed" class="todo-status" role="status"><span>å¾
åå 载失败</span><button type="button" @click="load">éæ°å è½½</button></div><div v-else-if="!tasks.length" class="todo-status"><IconifyIcon icon="lucide:inbox" /><span>ææ å¾
åä»»å¡</span></div><ul v-else><li v-for="task in tasks" :key="task.id"><button type="button" @click="goMenu(features.todo)"><span>{{ task.name }}</span><IconifyIcon icon="lucide:chevron-right" /></button></li></ul></article></template> |
| | | |
| | | <style scoped> |
| | | .elite-section { |
| | | border-radius: 14px; |
| | | padding: 20px; |
| | | background: #fdfcfa; |
| | | border: 1px solid rgba(212, 175, 55, 0.22); |
| | | box-shadow: 0 2px 14px rgba(15, 36, 64, 0.05); |
| | | } |
| | | |
| | | .dark .elite-section { |
| | | background: #101a2b; |
| | | border-color: rgba(212, 175, 55, 0.2); |
| | | box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3); |
| | | } |
| | | |
| | | .elite-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 14px; |
| | | } |
| | | |
| | | .elite-title { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | margin: 0; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | letter-spacing: 1px; |
| | | color: #0f2440; |
| | | } |
| | | |
| | | .dark .elite-title { |
| | | color: #f0ead8; |
| | | } |
| | | |
| | | .elite-bar { |
| | | display: inline-block; |
| | | width: 3px; |
| | | height: 15px; |
| | | border-radius: 2px; |
| | | background: linear-gradient(180deg, #e3c567, #b8901f); |
| | | } |
| | | |
| | | .elite-more { |
| | | display: inline-flex; |
| | | cursor: pointer; |
| | | align-items: center; |
| | | gap: 2px; |
| | | font-size: 12px; |
| | | color: #b8901f; |
| | | transition: color 0.2s ease; |
| | | } |
| | | |
| | | .elite-more:hover { |
| | | color: #8a6a12; |
| | | } |
| | | |
| | | .elite-empty { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 34px 0; |
| | | font-size: 13px; |
| | | color: #8a94a6; |
| | | } |
| | | |
| | | .elite-list { |
| | | margin: 0; |
| | | padding: 0; |
| | | list-style: none; |
| | | } |
| | | |
| | | .elite-row { |
| | | display: flex; |
| | | cursor: pointer; |
| | | align-items: center; |
| | | gap: 10px; |
| | | padding: 11px 10px; |
| | | border-radius: 8px; |
| | | border-bottom: 1px solid rgba(15, 36, 64, 0.05); |
| | | transition: background-color 0.2s ease; |
| | | } |
| | | |
| | | .elite-row:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | .elite-row:hover { |
| | | background: rgba(212, 175, 55, 0.07); |
| | | } |
| | | |
| | | .elite-dot { |
| | | width: 6px; |
| | | height: 6px; |
| | | flex-shrink: 0; |
| | | border-radius: 999px; |
| | | background: #d4af37; |
| | | } |
| | | |
| | | .elite-row-name { |
| | | overflow: hidden; |
| | | flex: 1; |
| | | font-size: 13.5px; |
| | | color: #0f2440; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .dark .elite-row-name { |
| | | color: #e6e9ef; |
| | | } |
| | | |
| | | .elite-row-time { |
| | | flex-shrink: 0; |
| | | font-size: 12px; |
| | | color: #8a94a6; |
| | | } |
| | | .portal-todo-panel { padding: 18px; border: 1px solid #e1ebf5; border-radius: 6px; background: #fff; }.todo-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px; }.todo-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #153a62; font-size: 17px; font-weight: 700; }.todo-heading h2 :deep(svg) { width: 19px; color: #b7242d; }.todo-heading button { display: flex; align-items: center; color: #5c7187; font-size: 11px; }.todo-status { display: flex; min-height: 230px; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #5c7187; font-size: 13px; }.todo-status > :deep(svg) { width: 32px; height: 32px; color: #b6c9dc; }.todo-status button { min-height: 30px; padding: 4px 12px; border: 1px solid #d8e5f3; border-radius: 4px; color: #2b6094; background: #f6faff; }ul { margin: 0; padding: 0; list-style: none; }li + li { border-top: 1px solid #edf3f8; }li button { display: flex; width: 100%; min-height: 39px; align-items: center; justify-content: space-between; gap: 12px; color: #315273; font-size: 12px; text-align: left; }li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }li :deep(svg) { width: 14px; flex: none; }button { cursor: pointer; }button:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; } |
| | | </style> |