From 91bfcf61f44f353dfebc54a57b5b774721de3e9a Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 19 十月 2026 09:39:22 +0800
Subject: [PATCH] feat(auth): 更新门户认证页面布局和样式
---
docs/portal-redesign/header-search.png | 0
docs/portal-redesign/profile-mobile-form.png | 0
docs/portal-redesign/profile-mobile.png | 0
src/packages/effects/layouts/src/widgets/global-search/global-search.vue | 15
docs/portal-redesign/profile-password.png | 0
src/views/dashboard/analytics/modules/quick-actions.vue | 160 --
docs/portal-redesign/notice-retry.png | 0
src/views/_core/profile/modules/reset-pwd.vue | 18
docs/portal-redesign/deep-home-top.png | 0
public/logos/JSSM-portal.svg | 8
src/views/_core/profile/index.vue | 101 +
src/packages/@core/ui-kit/layout-ui/src/components/layout-header.vue | 2
public/static/imgs/portal-hero-v2.png | 0
src/router/access.ts | 6
docs/portal-redesign/login-desktop.png | 0
src/packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue | 39
src/packages/effects/layouts/src/basic/layout.vue | 20
src/preferences.ts | 32
src/views/dashboard/analytics/modules/research-highlights.vue | 52 +
docs/portal-redesign/profile-bindings.png | 0
docs/portal-redesign/README.md | 40
docs/portal-redesign/profile-desktop.png | 0
docs/portal-redesign/deep-home-mobile.png | 0
docs/portal-redesign/profile-detector.json | 15
docs/portal-redesign/home-desktop.png | 0
src/views/dashboard/analytics/modules/notice-panel.vue | 95 +
src/packages/effects/layouts/src/basic/header/header.vue | 35
docs/portal-redesign/deep-home-content.png | 0
src/views/dashboard/analytics/modules/kpi-cards.vue | 269 ----
public/static/imgs/portal-avatar.svg | 1
src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue | 2
src/views/_core/authentication/login.vue | 244 ----
docs/portal-redesign/header-optimized.png | 0
docs/portal-redesign/REVIEW.md | 25
docs/portal-redesign/DESIGN.md | 73 +
docs/portal-redesign/deep-home-full.png | 0
src/components/portal/use-portal-menus.ts | 64 +
docs/portal-redesign/menu-mobile.png | 0
docs/portal-redesign/profile-scroll-fixed.png | 0
src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue | 25
docs/portal-redesign/login-final.png | 0
src/router/guard.ts | 5
src/views/_core/profile/modules/base-info.vue | 29
src/views/_core/profile/modules/profile-user.vue | 158 --
docs/portal-redesign/header-deep-overflow.png | 0
src/packages/@core/ui-kit/menu-ui/src/components/menu.vue | 18
company.config.ts | 6
docs/portal-redesign/header-overflow.png | 0
public/static/imgs/portal-landscape-v1.png | 0
docs/portal-redesign/menu-desktop.png | 0
src/components/portal/portal-footer.vue | 40
src/layouts/basic.vue | 67 -
src/packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue | 11
src/store/company-config.ts | 9
docs/portal-redesign/deep-detector.json | 1
scripts/portal-preview.html | 5
scripts/portal-review.html | 11
src/portal-theme.css | 116 ++
src/router/portal-system-menus.ts | 69 +
src/bootstrap.ts | 1
src/packages/effects/hooks/src/use-app-config.ts | 2
src/main.ts | 9
public/static/imgs/portal-modules-v2.png | 0
docs/portal-redesign/login-mobile.png | 0
src/packages/effects/layouts/src/basic/menu/menu.vue | 1
src/views/_core/profile/modules/user-social.vue | 249 +---
docs/portal-redesign/system-menu-no-tenant.png | 0
src/components/portal/portal-brand-text.vue | 18
src/layouts/auth.vue | 77 +
src/packages/@core/ui-kit/menu-ui/src/types.ts | 3
src/views/dashboard/analytics/index.vue | 268 +++--
docs/portal-redesign/notice-error.png | 0
src/router/routes/core.ts | 9
src/views/dashboard/analytics/modules/todo-panel.vue | 207 ----
scripts/portal-preview.ts | 151 +++
src/utils/company-config.ts | 17
76 files changed, 1,495 insertions(+), 1,403 deletions(-)
diff --git a/company.config.ts b/company.config.ts
index 3f66970..2794fbd 100644
--- a/company.config.ts
+++ b/company.config.ts
@@ -72,9 +72,9 @@
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: '',
},
};
diff --git a/docs/portal-redesign/DESIGN.md b/docs/portal-redesign/DESIGN.md
new file mode 100644
index 0000000..80761ed
--- /dev/null
+++ b/docs/portal-redesign/DESIGN.md
@@ -0,0 +1,73 @@
+---
+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
+
+鏈鏄庤褰曠幇鏈夊疄鐜帮紱瑙嗚渚濇嵁涓虹敤鎴锋寚瀹氱殑鏈烘瀯闂ㄦ埛鍙傝�冦�備笟鍔¢椤甸噰鐢ㄤ笁灞傚ご閮ㄣ�佸叏骞呬埂鏉戝煄甯傜孩缁镐富鍥俱�佺孩鑹插揩鎹锋湇鍔°�佹潈闄愭爮鐩拰钃濊壊椤佃剼锛涗釜浜轰腑蹇冩部鐢ㄥ悓涓�涓氬姟澶栧3銆傝璇侀〉淇濈暀鐧借壊鏈烘瀯椤靛ご銆佹櫙瑙備粙缁嶅尯涓庤处鍙疯〃鍗曘�傞粯璁ゆ祬鑹诧紝閮ㄥ垎鏆楄壊瑕嗙洊涓嶈兘瑙嗕负瀹屾暣鏆楄壊楠屾敹銆�
+
+鏈烘瀯鍚嶇О鐢� `preferences.copyright.companyName` 鎻愪緵锛孞SSM 绔浐瀹氫负鈥滄睙鑻忕渷渚涢攢鍚堜綔缁忔祹瀛︿細铻嶅悎闂ㄦ埛鈥濓紝缂栬瘧閰嶇疆銆佸悗绔睍绀哄眰涓庨〉鏍囬淇濇寔涓�鑷淬�侺ogo 浣跨敤閰嶇疆鏉ユ簮锛屽綋鍓嶉棬鎴锋湁鏈湴缁胯壊鏍囪 `public/logos/JSSM-portal.svg`銆傜櫥褰曚粙缁嶄娇鐢ㄥ叕鍙搁厤缃強婧愮爜缂虹渷鏂囨銆備腑鎬ч粯璁ゅご鍍忎负 `public/static/imgs/portal-avatar.svg`銆傝繖浜涜祫浜т笉浠h〃宸插彇寰楀叾浠栨満鏋勭殑韬唤鎺堟潈銆�
+
+## Colors
+
+鍓嶇疆鑹插�兼潵鑷幇鏈変富棰樹笌椤佃剼銆傝摑鑹叉壙鎷呴摼鎺ャ�佷晶鏍忛�夋嫨鍜岀粨鏋勫眰娆★紝娣辫摑鎵挎媴涓昏鏂囧瓧锛岀伆钃濇壙鎷呰緟鍔╀俊鎭紝鐧借壊鎵挎媴鍐呭琛ㄩ潰銆傜孩鑹叉壙鎷呮闈竴绾у鑸�変腑銆佸揩鎹锋湇鍔℃爣璇嗗強涓昏鎿嶄綔锛涜摑鑹查〉鑴氬強鏇存繁鐨勭増鏉冩潯鏀舵潫闀块〉闈€�傚眬閮ㄧ豢鑹叉爣璁板拰鍔熻兘鍥炬爣涓嶆墿灞曚负鏂板叏灞�鍝佺墝鑹层��
+
+## Typography
+
+姝f枃涓庤〃鏍肩户鎵块」鐩熀纭�瀛椾綋鏍堛�傞椤典富鏍囬浣跨敤绯荤粺妤蜂綋/瀹嬩綋鍥為��锛圞aiTi銆丼TKaiti銆丯oto Serif SC銆乻erif锛夛紝灏哄闅忓搴﹀彉鍖栵紙32鈥�54px銆�800锛夛紱鎵嬫満涓猴紙32px锛夛紝鏋佺獎灞忎负锛�29px锛夈�傚ご閮ㄦ満鏋勫悕涓猴紙25px銆�750锛夛紝涓瓑瀹藉害涓猴紙21px锛夛紝鎵嬫満涓猴紙16px锛夈�傛爮鐩爣棰樹负锛�17px銆�700锛夛紝姝f枃涓庤緟鍔╀俊鎭富瑕佷负锛�11鈥�14px锛夈�備釜浜轰腑蹇冮〉鏍囬涓猴紙29px銆�700锛夛紝鎵嬫満涓猴紙25px锛夈�傚瓧璺濆己璋冪敤浜庡搧鐗屼笌妯箙锛屼笉搴旂敤浜庤祫鏂欒〃鍗曟鏂囥��
+
+## Layout
+
+涓氬姟妯″紡閲囩敤 `mixed-nav`锛氭闈㈠ご閮ㄤ负宸ュ叿灞傦紙32px锛夈�佸搧鐗屼笌鎼滅储灞傦紙80px锛夈�佷竴绾у鑸眰锛�46px锛夛紝鍏憋紙158px锛夛紱鎵嬫満鍥炲綊锛�72px锛夊ご閮ㄤ笌鎶藉眽瀵艰埅銆備腑绛夊搴︼紙768鈥�1100px锛夐殣钘忓搧鐗岄檮鍔犳枃瀛椼�佹敹绐勬悳绱笌鍝佺墝鍖猴紝淇濈暀鏄电О鍜屾搷浣滃彲璇绘�с�傛闈㈠鑸秴鍑哄彲鐢ㄥ搴︽敹绾冲埌鈥滄洿澶氣�濄�備晶鏍忛粯璁ゅ睍寮�瀹藉害锛�228px锛夛紝鏍囩鏍忛珮锛�40px锛夛紝鏍囩鏍忔寜瀹為檯渚ф爮鍗犵敤瀹藉害瀵归綈锛涙爣绛惧巻鍙蹭笉鍏ㄩ儴鎸佷箙鍖栥��
+
+棣栭〉妯箙鍏ㄥ箙锛屾闈㈡渶灏忛珮锛�350px锛夛紝鎵嬫満涓猴紙280px锛夛紱姝f枃瀹瑰櫒鏈�澶э紙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
+
+- **澶撮儴**锛氬彲璇诲ご鍍忔樀绉颁笅鎷夈�佷釜浜轰腑蹇冪洿杈俱�佸瘑鐮佽缃揩鎹疯彍鍗曚笌鍏ㄥ眬鑿滃崟鎼滅储銆傛闈竴绾ц彍鍗曢�変腑绾㈠簳鐧藉瓧锛屼晶鏍忛�変腑钃濆簳鐧藉瓧锛涒�滄洿澶氣�濅繚鐣欏睍寮�銆佸叧闂拰鐒︾偣浜や簰銆備腑绛夊搴︿紭鍏堝帇缂╁搧鐗岋紝涓嶆尋鎺夌敤鎴峰叆鍙c��
+- **棣栭〉**锛歚usePortalMenus` 浠庤处鍙锋潈闄愯彍鍗曟瀯寤哄彲瑙佹爮鐩笌鍙跺叆鍙o紝鎺掗櫎闅愯棌銆佺鐢ㄥ強绌虹洰褰曪紝鎸夌幇鏈夎矾寰勫拰鍚嶇О璇嗗埆璇鹃銆佸緟鍔炪�佹姤閿�銆佸叕鍛婄瓑鍔熻兘銆傜浉鍏抽潰鏉胯皟鐢ㄧ湡瀹炰笟鍔� API锛涚己灏戞潈闄愭椂涓嶅嚟棰勮鏍锋湰娣诲姞鐢熶骇妯″潡銆備繚鐣欏姞杞姐�佺┖鎬併�佸け璐ュ強閫傜敤鐨勯噸璇曠姸鎬併��
+- **涓汉涓績**锛氬乏渚уご鍍忋�佽韩浠藉拰鑱旂郴淇℃伅锛屽彸渚у熀鏈祫鏂欍�佸瘑鐮佽缃�佽处鍙风粦瀹氫笁涓〉绛撅紱鎵嬫満鍫嗗彔銆傛部鐢ㄥ師鎺ュ彛锛屽ご鍍忎笂浼犺鍙栬繑鍥炲璞$殑 `url`锛岀┖閭鍏佽涓嶅~锛屽瘑鐮佷慨鏀规垚鍔熷悗娓呯┖琛ㄥ崟銆傜粦瀹氬洖璋冧繚鐣� hash 璺敱锛屼娇鐢� `router.replace` 娓呯悊 `code/state/type`锛涚晫闈笉灞曠ず openid 鎴� token銆侾rofile 鏄殣钘� 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 鏁版嵁鎺ュ叆姝e紡鍏ュ彛銆�
+
+鐙珛瀹℃煡缁撹涓衡�滆瑙夋敹灏鹃�氳繃锛屽彲浜や粯鐣岄潰棰勮鈥濄�傜湡瀹炲悗绔櫥褰曚笌鍐欏叆銆佺涓夋柟鎺堟潈銆佸畬鏁撮敭鐩樻祦绋嬨�佹殫鑹插強璁$畻瀵规瘮搴︿粛鏈獙鏀讹紱鍏蜂綋杈圭晫瑙� REVIEW.md銆�
+
diff --git a/docs/portal-redesign/README.md b/docs/portal-redesign/README.md
new file mode 100644
index 0000000..db8688d
--- /dev/null
+++ b/docs/portal-redesign/README.md
@@ -0,0 +1,40 @@
+# 铻嶅悎鏈嶅姟闂ㄦ埛澶栬
+
+## 鏈湴棰勮
+
+浣跨敤鐜版湁渚濊禆鍚姩锛�
+
+```powershell
+node node_modules/vite/bin/vite.js --mode development --host 127.0.0.1 --port 5670 --strictPort
+```
+
+鐪熷疄鐧诲綍鍏ュ彛 `/auth/login`锛岄椤� `/analytics`锛屼釜浜轰腑蹇� `/profile`锛堜粛鏍¢獙 token锛夈�傛寮忓叆鍙d笉鍔犺浇 `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`銆傚潎涓虹ず渚嬪満鏅紝涓嶈兘璇佹槑鐢熶骇璐﹀彿鏁版嵁鎴栨帴鍙e啓鍏ユ垚鍔熴�傜洰褰曚腑鏃х増鎴浘涓庢棫妫�娴嬫枃浠剁暀浣滃巻鍙茶褰曪紝涓嶄唬琛ㄦ湰鐗堢姸鎬併��
+
+## 鍥惧儚涓庡搧鐗�
+
+- `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`锛氭湰鍦扮豢鑹查棬鎴锋爣璁帮紱鏈烘瀯鍚嶇О鍙奓ogo浠嶉�氳繃鏃㈡湁鍝佺墝閰嶇疆鎻愪緵銆�
+- `public/static/imgs/portal-avatar.svg`锛氭湰鍦颁腑鎬ч粯璁ゅご鍍忋��
+
+鐢熸垚鏅涓庡浘闆嗕笉鏄壒瀹氱湡瀹炲湴鐐规垨浜嬩欢鐨勮褰曠収鐗囥�倂2 姝ゅ浠呰褰曞凡鐭ョ敤閫旓紝涓嶅皢绠�杩板啋鍏呭畬鏁寸敓鎴愭彁绀鸿瘝銆�
+
+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鎺ㄦ柇妗岄潰楂樺害銆�
+
diff --git a/docs/portal-redesign/REVIEW.md b/docs/portal-redesign/REVIEW.md
new file mode 100644
index 0000000..29cb371
--- /dev/null
+++ b/docs/portal-redesign/REVIEW.md
@@ -0,0 +1,25 @@
+# 鏈増澶嶆牳璁板綍
+
+鐙珛瀹℃煡缁撹锛�**瑙嗚鏀跺熬閫氳繃锛屽彲浜や粯鐣岄潰棰勮銆�** 鏈彂鐜伴樆鏂氦浠樿瑙夋潗鏂欑殑缂洪櫡銆傝缁撹浠呰鐩栧綋鍓嶇晫闈㈤瑙堬紝涓嶄唬琛ㄧ湡瀹炲悗绔強鍏ㄩ儴浜や簰楠屾敹銆�
+
+| 鏍搁獙鑼冨洿 | 褰撳墠璁板綍 | 杈圭晫 |
+| --- | --- | --- |
+| 棣栭〉涓庡ご閮� | 宸叉湁妗岄潰銆佹墜鏈恒�佹孩鍑哄鑸ず渚嬫埅鍥撅紱棣栭〉妫�娴嬪櫒杈撳嚭 `[]` | 绀轰緥鏁版嵁涓嶈瘉鏄庣敓浜ф潈闄愬拰鐪熷疄 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 鍗曟枃浠剁粍浠跺凡閫氳繃鐙珛瑙f瀽涓庢ā鏉跨紪璇戯紝鑿滃崟鏉冮檺鍙樻崲宸查�氳繃鏈湴鏂█锛屾湰鍦� Vite 棰勮姝e父銆�
+
+涓汉涓績宸插疄鐜帮細涓婁紶杩斿洖瀵硅薄鍙� `url`銆佺┖閭鍙�夈�佹敼瀵嗘垚鍔熸竻绌恒�丱Auth 鍥炶皟淇濈暀 hash 璺敱骞剁敱 `router.replace` 娓呯悊 `code/state/type`銆佷笉鏄剧ず openid/token锛汸rofile 闅愯棌 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`銆傛埅鍥惧潎涓虹ず渚嬪満鏅�傛寮忓叆鍙f湭鍔犺浇 fixture 棰勮鑴氭湰锛岄瑙堣彍鍗曚笉寰椾綔涓哄疄闄呰处鍙锋潈闄愯鏄庛�傛棫鐗堟埅鍥句笌鏃ф娴嬫枃浠朵繚鐣欎綔鍘嗗彶璁板綍銆�
diff --git a/docs/portal-redesign/deep-detector.json b/docs/portal-redesign/deep-detector.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/docs/portal-redesign/deep-detector.json
@@ -0,0 +1 @@
+[]
diff --git a/docs/portal-redesign/deep-home-content.png b/docs/portal-redesign/deep-home-content.png
new file mode 100644
index 0000000..f4e323f
--- /dev/null
+++ b/docs/portal-redesign/deep-home-content.png
Binary files differ
diff --git a/docs/portal-redesign/deep-home-full.png b/docs/portal-redesign/deep-home-full.png
new file mode 100644
index 0000000..3ff70a5
--- /dev/null
+++ b/docs/portal-redesign/deep-home-full.png
Binary files differ
diff --git a/docs/portal-redesign/deep-home-mobile.png b/docs/portal-redesign/deep-home-mobile.png
new file mode 100644
index 0000000..4361d57
--- /dev/null
+++ b/docs/portal-redesign/deep-home-mobile.png
Binary files differ
diff --git a/docs/portal-redesign/deep-home-top.png b/docs/portal-redesign/deep-home-top.png
new file mode 100644
index 0000000..6bb4dec
--- /dev/null
+++ b/docs/portal-redesign/deep-home-top.png
Binary files differ
diff --git a/docs/portal-redesign/header-deep-overflow.png b/docs/portal-redesign/header-deep-overflow.png
new file mode 100644
index 0000000..8caec7c
--- /dev/null
+++ b/docs/portal-redesign/header-deep-overflow.png
Binary files differ
diff --git a/docs/portal-redesign/header-optimized.png b/docs/portal-redesign/header-optimized.png
new file mode 100644
index 0000000..1c03c48
--- /dev/null
+++ b/docs/portal-redesign/header-optimized.png
Binary files differ
diff --git a/docs/portal-redesign/header-overflow.png b/docs/portal-redesign/header-overflow.png
new file mode 100644
index 0000000..db27974
--- /dev/null
+++ b/docs/portal-redesign/header-overflow.png
Binary files differ
diff --git a/docs/portal-redesign/header-search.png b/docs/portal-redesign/header-search.png
new file mode 100644
index 0000000..461957a
--- /dev/null
+++ b/docs/portal-redesign/header-search.png
Binary files differ
diff --git a/docs/portal-redesign/home-desktop.png b/docs/portal-redesign/home-desktop.png
new file mode 100644
index 0000000..49f0243
--- /dev/null
+++ b/docs/portal-redesign/home-desktop.png
Binary files differ
diff --git a/docs/portal-redesign/login-desktop.png b/docs/portal-redesign/login-desktop.png
new file mode 100644
index 0000000..3fb5a92
--- /dev/null
+++ b/docs/portal-redesign/login-desktop.png
Binary files differ
diff --git a/docs/portal-redesign/login-final.png b/docs/portal-redesign/login-final.png
new file mode 100644
index 0000000..ce2ae16
--- /dev/null
+++ b/docs/portal-redesign/login-final.png
Binary files differ
diff --git a/docs/portal-redesign/login-mobile.png b/docs/portal-redesign/login-mobile.png
new file mode 100644
index 0000000..6ff70b8
--- /dev/null
+++ b/docs/portal-redesign/login-mobile.png
Binary files differ
diff --git a/docs/portal-redesign/menu-desktop.png b/docs/portal-redesign/menu-desktop.png
new file mode 100644
index 0000000..bea7538
--- /dev/null
+++ b/docs/portal-redesign/menu-desktop.png
Binary files differ
diff --git a/docs/portal-redesign/menu-mobile.png b/docs/portal-redesign/menu-mobile.png
new file mode 100644
index 0000000..cea7600
--- /dev/null
+++ b/docs/portal-redesign/menu-mobile.png
Binary files differ
diff --git a/docs/portal-redesign/notice-error.png b/docs/portal-redesign/notice-error.png
new file mode 100644
index 0000000..6195589
--- /dev/null
+++ b/docs/portal-redesign/notice-error.png
Binary files differ
diff --git a/docs/portal-redesign/notice-retry.png b/docs/portal-redesign/notice-retry.png
new file mode 100644
index 0000000..022d1f3
--- /dev/null
+++ b/docs/portal-redesign/notice-retry.png
Binary files differ
diff --git a/docs/portal-redesign/profile-bindings.png b/docs/portal-redesign/profile-bindings.png
new file mode 100644
index 0000000..8b885f0
--- /dev/null
+++ b/docs/portal-redesign/profile-bindings.png
Binary files differ
diff --git a/docs/portal-redesign/profile-desktop.png b/docs/portal-redesign/profile-desktop.png
new file mode 100644
index 0000000..4ad7ec4
--- /dev/null
+++ b/docs/portal-redesign/profile-desktop.png
Binary files differ
diff --git a/docs/portal-redesign/profile-detector.json b/docs/portal-redesign/profile-detector.json
new file mode 100644
index 0000000..87974d7
--- /dev/null
+++ b/docs/portal-redesign/profile-detector.json
@@ -0,0 +1,15 @@
+[
+ {
+ "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"
+ ]
+ }
+]
diff --git a/docs/portal-redesign/profile-mobile-form.png b/docs/portal-redesign/profile-mobile-form.png
new file mode 100644
index 0000000..d14cd08
--- /dev/null
+++ b/docs/portal-redesign/profile-mobile-form.png
Binary files differ
diff --git a/docs/portal-redesign/profile-mobile.png b/docs/portal-redesign/profile-mobile.png
new file mode 100644
index 0000000..dd48e53
--- /dev/null
+++ b/docs/portal-redesign/profile-mobile.png
Binary files differ
diff --git a/docs/portal-redesign/profile-password.png b/docs/portal-redesign/profile-password.png
new file mode 100644
index 0000000..01cab56
--- /dev/null
+++ b/docs/portal-redesign/profile-password.png
Binary files differ
diff --git a/docs/portal-redesign/profile-scroll-fixed.png b/docs/portal-redesign/profile-scroll-fixed.png
new file mode 100644
index 0000000..e8226ab
--- /dev/null
+++ b/docs/portal-redesign/profile-scroll-fixed.png
Binary files differ
diff --git a/docs/portal-redesign/system-menu-no-tenant.png b/docs/portal-redesign/system-menu-no-tenant.png
new file mode 100644
index 0000000..486d6ae
--- /dev/null
+++ b/docs/portal-redesign/system-menu-no-tenant.png
Binary files differ
diff --git a/public/logos/JSSM-portal.svg b/public/logos/JSSM-portal.svg
new file mode 100644
index 0000000..5927a1b
--- /dev/null
+++ b/public/logos/JSSM-portal.svg
@@ -0,0 +1,8 @@
+<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>
diff --git a/public/static/imgs/portal-avatar.svg b/public/static/imgs/portal-avatar.svg
new file mode 100644
index 0000000..98c87fc
--- /dev/null
+++ b/public/static/imgs/portal-avatar.svg
@@ -0,0 +1 @@
+<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>
diff --git a/public/static/imgs/portal-hero-v2.png b/public/static/imgs/portal-hero-v2.png
new file mode 100644
index 0000000..2d27fb7
--- /dev/null
+++ b/public/static/imgs/portal-hero-v2.png
Binary files differ
diff --git a/public/static/imgs/portal-landscape-v1.png b/public/static/imgs/portal-landscape-v1.png
new file mode 100644
index 0000000..23b038e
--- /dev/null
+++ b/public/static/imgs/portal-landscape-v1.png
Binary files differ
diff --git a/public/static/imgs/portal-modules-v2.png b/public/static/imgs/portal-modules-v2.png
new file mode 100644
index 0000000..a87221d
--- /dev/null
+++ b/public/static/imgs/portal-modules-v2.png
Binary files differ
diff --git a/scripts/portal-preview.html b/scripts/portal-preview.html
new file mode 100644
index 0000000..fb8c5db
--- /dev/null
+++ b/scripts/portal-preview.html
@@ -0,0 +1,5 @@
+<!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>
diff --git a/scripts/portal-preview.ts b/scripts/portal-preview.ts
new file mode 100644
index 0000000..4c13fad
--- /dev/null
+++ b/scripts/portal-preview.ts
@@ -0,0 +1,151 @@
+/** 浠呬緵 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();
+
+// 姝ゆ浛韬粎瀛樺湪浜庣嫭绔嬮瑙堝叆鍙o紝姝e紡鍏ュ彛涓嶅姞杞芥湰鏂囦欢銆�
+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();
diff --git a/scripts/portal-review.html b/scripts/portal-review.html
new file mode 100644
index 0000000..2b09211
--- /dev/null
+++ b/scripts/portal-review.html
@@ -0,0 +1,11 @@
+<!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>
diff --git a/src/bootstrap.ts b/src/bootstrap.ts
index bbfb549..84c080d 100644
--- a/src/bootstrap.ts
+++ b/src/bootstrap.ts
@@ -7,6 +7,7 @@
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';
diff --git a/src/components/portal/portal-brand-text.vue b/src/components/portal/portal-brand-text.vue
new file mode 100644
index 0000000..d1d6fc5
--- /dev/null
+++ b/src/components/portal/portal-brand-text.vue
@@ -0,0 +1,18 @@
+<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>
diff --git a/src/components/portal/portal-footer.vue b/src/components/portal/portal-footer.vue
new file mode 100644
index 0000000..308a431
--- /dev/null
+++ b/src/components/portal/portal-footer.vue
@@ -0,0 +1,40 @@
+<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>
diff --git a/src/components/portal/use-portal-menus.ts b/src/components/portal/use-portal-menus.ts
new file mode 100644
index 0000000..6d6a4f6
--- /dev/null
+++ b/src/components/portal/use-portal-menus.ts
@@ -0,0 +1,64 @@
+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 };
+}
diff --git a/src/layouts/auth.vue b/src/layouts/auth.vue
index 51dc083..8e07a13 100644
--- a/src/layouts/auth.vue
+++ b/src/layouts/auth.vue
@@ -1,11 +1,11 @@
<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;
@@ -19,21 +19,66 @@
const appName = computed(() => preferences.copyright.companyName);
// 鐧诲綍椤� logo 浼樺厛鍙栨暟鎹簱閰嶇疆锛坧references.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>
diff --git a/src/layouts/basic.vue b/src/layouts/basic.vue
index ee2b152..ff20779 100644
--- a/src/layouts/basic.vue
+++ b/src/layouts/basic.vue
@@ -1,14 +1,12 @@
<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,
@@ -21,14 +19,12 @@
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,
@@ -36,18 +32,16 @@
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);
@@ -65,6 +59,11 @@
},
icon: AntdProfileOutlined,
text: $t('ui.widgets.profile'),
+ },
+ {
+ handler: () => router.push({ name: 'Profile', query: { section: 'resetPwd' } }),
+ icon: 'lucide:shield-check',
+ text: '瀵嗙爜璁剧疆',
},
// {
// handler: () => {
@@ -157,41 +156,10 @@
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(
() => {
@@ -269,9 +237,13 @@
</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"
@@ -280,6 +252,9 @@
@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
@@ -293,17 +268,7 @@
@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"
diff --git a/src/main.ts b/src/main.ts
index b692f75..b078d0b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,7 +1,7 @@
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;
@@ -24,6 +24,13 @@
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__ } });
diff --git a/src/packages/@core/ui-kit/layout-ui/src/components/layout-header.vue b/src/packages/@core/ui-kit/layout-ui/src/components/layout-header.vue
index fba07d4..17d0b2d 100644
--- a/src/packages/@core/ui-kit/layout-ui/src/components/layout-header.vue
+++ b/src/packages/@core/ui-kit/layout-ui/src/components/layout-header.vue
@@ -66,7 +66,7 @@
: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>
diff --git a/src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue b/src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue
index a9e5afe..1baa58b 100644
--- a/src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue
+++ b/src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue
@@ -281,19 +281,13 @@
// 濡傛灉涓嶆槸娣峰悎瀵艰埅锛宼abbar 鐨勫搴︿负 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%';
@@ -495,7 +489,7 @@
</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"
@@ -543,7 +537,7 @@
<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="[
@@ -573,6 +567,7 @@
<template #toggle-button>
<VbenIconButton
v-if="showHeaderToggleButton"
+ :aria-label="sidebarCollapse ? '灞曞紑瀵艰埅鑿滃崟' : '鏀惰捣瀵艰埅鑿滃崟'"
class="my-0 mr-1 rounded-md"
@click="handleHeaderToggle"
>
@@ -603,7 +598,7 @@
: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>
diff --git a/src/packages/@core/ui-kit/menu-ui/src/components/menu.vue b/src/packages/@core/ui-kit/menu-ui/src/components/menu.vue
index d31999d..7c8d768 100644
--- a/src/packages/@core/ui-kit/menu-ui/src/components/menu.vue
+++ b/src/packages/@core/ui-kit/menu-ui/src/components/menu.vue
@@ -149,7 +149,7 @@
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() {
@@ -163,17 +163,24 @@
(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 鈥渕ore鈥� 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;
}
@@ -356,7 +363,8 @@
</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" />
diff --git a/src/packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue b/src/packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
index 6745b8e..2acc973 100644
--- a/src/packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
+++ b/src/packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
@@ -3,7 +3,7 @@
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';
@@ -41,6 +41,32 @@
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,
@@ -218,13 +244,19 @@
>
<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>
@@ -232,7 +264,9 @@
</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)"
@@ -240,6 +274,7 @@
<ul
:class="[nsMenu.b(), is('rounded', rounded)]"
:style="subMenuStyle"
+ role="menu"
>
<slot></slot>
</ul>
diff --git a/src/packages/@core/ui-kit/menu-ui/src/types.ts b/src/packages/@core/ui-kit/menu-ui/src/types.ts
index 9921bb7..4d3b329 100644
--- a/src/packages/@core/ui-kit/menu-ui/src/types.ts
+++ b/src/packages/@core/ui-kit/menu-ui/src/types.ts
@@ -40,6 +40,9 @@
*/
mode?: 'horizontal' | 'vertical';
+ /** 妯悜鑿滃崟鏀剁撼鍏ュ彛鐨勬枃瀛楋紱涓虹┖鏃朵粎灞曠ず鍥炬爣銆� */
+ overflowLabel?: string;
+
/**
* @zh_CN 鏄惁鍦嗘鼎椋庢牸
* @default true
diff --git a/src/packages/effects/hooks/src/use-app-config.ts b/src/packages/effects/hooks/src/use-app-config.ts
index 4320741..a9f6fc7 100644
--- a/src/packages/effects/hooks/src/use-app-config.ts
+++ b/src/packages/effects/hooks/src/use-app-config.ts
@@ -36,7 +36,7 @@
}
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 {
diff --git a/src/packages/effects/layouts/src/basic/header/header.vue b/src/packages/effects/layouts/src/basic/header/header.vue
index 1d319ef..4105989 100644
--- a/src/packages/effects/layouts/src/basic/header/header.vue
+++ b/src/packages/effects/layouts/src/basic/header/header.vue
@@ -48,6 +48,9 @@
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({
@@ -120,14 +123,6 @@
const leftSlots = computed(() => {
const list: Array<SlotItem> = [];
- // 鍒锋柊
- if (preferences.widget.refresh) {
- list.push({
- index: 0,
- name: 'refresh',
- });
- }
-
Object.keys(slots).forEach((key) => {
// 閫傞厤鎻掓Ы鍚嶇О锛屼緥濡傜涓�涓彃妲藉悕锛歨eader-left-1
if (key.startsWith('header-left')) {
@@ -161,17 +156,12 @@
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"
@@ -180,19 +170,26 @@
</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'">
diff --git a/src/packages/effects/layouts/src/basic/layout.vue b/src/packages/effects/layouts/src/basic/layout.vue
index 10467c3..a1cfac5 100644
--- a/src/packages/effects/layouts/src/basic/layout.vue
+++ b/src/packages/effects/layouts/src/basic/layout.vue
@@ -185,6 +185,7 @@
}
const route = useRoute();
+const isPortalHome = computed(() => route.path === '/analytics');
onMounted(() => {
autoCollapseMenuByRouteMeta(route);
@@ -225,18 +226,19 @@
<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"
@@ -259,7 +261,7 @@
: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"
@@ -287,6 +289,7 @@
<VbenLogo
v-if="preferences.logo.enable"
:fit="preferences.logo.fit"
+ :logo-size="isMixedNav && !isMobile ? 56 : 32"
:class="logoClass"
:collapsed="logoCollapsed"
:src="preferences.logo.source"
@@ -323,8 +326,9 @@
:menus="wrapperMenus(headerMenus)"
:rounded="isMenuRounded"
:theme="headerTheme"
- class="w-full"
+ class="portal-header-menu w-full"
mode="horizontal"
+ overflow-label="鏇村"
@select="handleMenuSelect"
/>
</template>
diff --git a/src/packages/effects/layouts/src/basic/menu/menu.vue b/src/packages/effects/layouts/src/basic/menu/menu.vue
index 0a921c4..ab4ee3a 100644
--- a/src/packages/effects/layouts/src/basic/menu/menu.vue
+++ b/src/packages/effects/layouts/src/basic/menu/menu.vue
@@ -36,6 +36,7 @@
:default-active="defaultActive"
:menus="menus"
:mode="mode"
+ :overflow-label="overflowLabel"
:rounded="rounded"
scroll-to-active
:theme="theme"
diff --git a/src/packages/effects/layouts/src/widgets/global-search/global-search.vue b/src/packages/effects/layouts/src/widgets/global-search/global-search.vue
index bfbd2ef..b55d784 100644
--- a/src/packages/effects/layouts/src/widgets/global-search/global-search.vue
+++ b/src/packages/effects/layouts/src/widgets/global-search/global-search.vue
@@ -24,10 +24,11 @@
});
const props = withDefaults(
- defineProps<{ enableShortcutKey?: boolean; menus?: MenuRecordRaw[] }>(),
+ defineProps<{ enableShortcutKey?: boolean; menus?: MenuRecordRaw[]; presentation?: 'compact' | 'portal' }>(),
{
enableShortcutKey: true,
menus: () => [],
+ presentation: 'compact',
},
);
@@ -134,7 +135,11 @@
</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()"
>
@@ -157,3 +162,11 @@
</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>
diff --git a/src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue b/src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue
index 6a7d235..e7c66d4 100644
--- a/src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue
+++ b/src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue
@@ -19,6 +19,7 @@
const loginDescription = ref(companyConfigStore.loginDescription);
const logoUrl = ref(companyConfigStore.logoUrl);
const uploading = ref(false);
+const fixedPortalName = import.meta.env.VITE_COMPANY === 'JSSM';
// 鎵撳紑鍖哄潡鏃朵粠鍚庣鎷夊彇鏈�鏂板叕鍙搁厤缃苟鍥炲~琛ㄥ崟锛坰tore 鍒濆�煎彲鑳戒负绌猴紝鍥犲惎鍔ㄥ姞杞借蛋鐨勬槸绾嚱鏁帮級
onMounted(async () => {
@@ -78,6 +79,7 @@
</span>
<Input
v-model:value="companyName"
+ :disabled="fixedPortalName"
:placeholder="$t('preferences.company.companyNamePlaceholder')"
class="w-41.25"
allow-clear
diff --git a/src/packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue b/src/packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue
index 7496bbe..c81c15b 100644
--- a/src/packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue
+++ b/src/packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue
@@ -61,6 +61,8 @@
* 鏂囨湰
*/
text?: string;
+ /** Display the account name beside the avatar in a portal toolbar. */
+ showText?: boolean;
/** 瑙﹀彂鏂瑰紡 */
trigger?: 'both' | 'click' | 'hover';
/** hover瑙﹀彂鏃讹紝寤惰繜鍝嶅簲鐨勬椂闂� */
@@ -79,6 +81,7 @@
showShortcutKey: true,
tagText: '',
text: '',
+ showText: false,
trigger: 'click',
hoverDelay: 500,
});
@@ -213,11 +216,12 @@
/>
<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">
@@ -292,3 +296,6 @@
</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>
diff --git a/src/portal-theme.css b/src/portal-theme.css
new file mode 100644
index 0000000..7db0b75
--- /dev/null
+++ b/src/portal-theme.css
@@ -0,0 +1,116 @@
+/* 闂ㄦ埛鍏辩敤鑹叉澘锛氫笟鍔¢〉闈㈠拰璁よ瘉椤甸潰鍏变韩鍝佺墝涓庤〃闈㈠眰绾с�� */
+: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; }
+}
diff --git a/src/preferences.ts b/src/preferences.ts
index 4da3a4e..36c65c9 100644
--- a/src/preferences.ts
+++ b/src/preferences.ts
@@ -13,14 +13,43 @@
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,
@@ -39,6 +68,7 @@
source: __APP_LOGO__ || undefined,
},
tabbar: {
+ ...portalAppearancePreferences.tabbar,
/** 鍒锋柊娴忚鍣ㄥ悗鍙繚鐣欏綋鍓嶉〉闈紝涓嶆寔涔呭寲鎵�鏈夋爣绛鹃〉 */
persist: false,
},
@@ -90,5 +120,5 @@
label: 'preferences.antd.fields.reportTitle.label',
placeholder: 'preferences.antd.fields.reportTitle.placeholder',
},
- ],
+ ].filter((field) => import.meta.env.VITE_COMPANY !== 'JSSM' || field.key !== 'tenantMode'),
});
diff --git a/src/router/access.ts b/src/router/access.ts
index c4b161a..2811d94 100644
--- a/src/router/access.ts
+++ b/src/router/access.ts
@@ -10,6 +10,7 @@
import { convertServerMenuToRouteRecordStringComponent } from '@vben/utils';
import { BasicLayout, IFrameView } from '#/layouts';
+import { portalSystemMenus } from './portal-system-menus';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
@@ -28,7 +29,10 @@
// 鐢变簬 yudao 閫氳繃 accessStore 璇诲彇锛屾墍浠ヤ笉鍦ㄨ繘琛� message.loading 鎻愮ず
// 琛ュ厖璇存槑锛歛ccessStore.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,
diff --git a/src/router/guard.ts b/src/router/guard.ts
index 88a80ee..960a44a 100644
--- a/src/router/guard.ts
+++ b/src/router/guard.ts
@@ -56,7 +56,10 @@
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) ||
diff --git a/src/router/portal-system-menus.ts b/src/router/portal-system-menus.ts
new file mode 100644
index 0000000..46c6ecf
--- /dev/null
+++ b/src/router/portal-system-menus.ts
@@ -0,0 +1,69 @@
+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;
+}
diff --git a/src/router/routes/core.ts b/src/router/routes/core.ts
index 7e14b88..b816d6e 100644
--- a/src/router/routes/core.ts
+++ b/src/router/routes/core.ts
@@ -36,7 +36,14 @@
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,
diff --git a/src/store/company-config.ts b/src/store/company-config.ts
index 1668f86..b8cb482 100644
--- a/src/store/company-config.ts
+++ b/src/store/company-config.ts
@@ -5,7 +5,7 @@
getCompanyConfig,
saveCompanyConfig as saveCompanyConfigApi,
} from '#/api/system/company-config';
-import { applyCompanyConfig } from '#/utils/company-config';
+import { applyCompanyConfig, displayCompanyName } from '#/utils/company-config';
/**
* 鍏徃鍝佺墝淇℃伅鍏ㄥ眬閰嶇疆
@@ -32,7 +32,7 @@
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 ?? '';
@@ -51,8 +51,9 @@
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 ?? '';
diff --git a/src/utils/company-config.ts b/src/utils/company-config.ts
index c44ccae..437ec81 100644
--- a/src/utils/company-config.ts
+++ b/src/utils/company-config.ts
@@ -8,6 +8,12 @@
loginDescription?: string;
}
+export const societyPortalName = '姹熻嫃鐪佷緵閿�鍚堜綔缁忔祹瀛︿細铻嶅悎闂ㄦ埛';
+
+export function displayCompanyName(name?: string) {
+ return import.meta.env.VITE_COMPANY === 'JSSM' ? societyPortalName : name;
+}
+
/**
* 灏嗗叕鍙稿搧鐗屼俊鎭簲鐢ㄥ埌鍏ㄥ眬锛�
* - 鍏徃鍚� 鈫� preferences.app.name / copyright.companyName锛堣仈鍔ㄨ彍鍗曟爮銆佺櫥褰曢〉銆佺綉椤垫爣棰樸�佺増鏉冿級
@@ -16,20 +22,21 @@
*/
export function applyCompanyConfig(config: CompanyConfigData) {
const updates: Record<string, any> = {};
+ const companyName = displayCompanyName(config.companyName);
if (config.logoUrl) {
updates.logo = { source: config.logoUrl };
// 鍚屾缃戦〉鏍囬鍥炬爣锛坒avicon锛変负涓婁紶鐨� 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;
}
}
diff --git a/src/views/_core/authentication/login.vue b/src/views/_core/authentication/login.vue
index f78f508..90d4140 100644
--- a/src/views/_core/authentication/login.vue
+++ b/src/views/_core/authentication/login.vue
@@ -22,9 +22,6 @@
defineOptions({ name: 'Login' });
-// vite.config.ts 娉ㄥ叆鐨勫叕鍙� Logo 璺緞
-declare const __APP_LOGO__: string;
-
const authStore = useAuthStore();
const accessStore = useAccessStore();
const tenantEnable = isTenantEnable();
@@ -34,12 +31,6 @@
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[]>([]); // 绉熸埛鍒楄〃
@@ -123,8 +114,11 @@
{
component: 'VbenInput',
componentProps: {
+ autocomplete: 'username',
placeholder: $t('authentication.usernameTip'),
},
+ hideLabel: false,
+ labelWidth: 44,
fieldName: 'username',
label: $t('authentication.username'),
rules: z
@@ -135,8 +129,11 @@
{
component: 'VbenInputPassword',
componentProps: {
+ autocomplete: 'current-password',
placeholder: $t('authentication.passwordTip'),
},
+ hideLabel: false,
+ labelWidth: 44,
fieldName: 'password',
label: $t('authentication.password'),
rules: z
@@ -150,13 +147,6 @@
<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"
@@ -173,8 +163,8 @@
<!-- 鑷畾涔夋爣棰橈細瑕嗙洊娆㈣繋璇� -->
<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>
@@ -214,201 +204,23 @@
</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;
- /* 鏈瓧鍙充晶浼氬鍑轰竴浠藉瓧璺濓紝鐢ㄧ瓑閲忕缉杩涙姷娑堬紝瑙嗚鎵嶇湡姝e眳涓� */
- 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;
-}
-</style>
\ No newline at end of file
+.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>
diff --git a/src/views/_core/profile/index.vue b/src/views/_core/profile/index.vue
index 7120444..5c94b48 100644
--- a/src/views/_core/profile/index.vue
+++ b/src/views/_core/profile/index.vue
@@ -1,67 +1,84 @@
<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>姝e湪鍔犺浇涓汉璧勬枡鈥�</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>
+
diff --git a/src/views/_core/profile/modules/base-info.vue b/src/views/_core/profile/modules/base-info.vue
index d2d7590..9afb6c3 100644
--- a/src/views/_core/profile/modules/base-info.vue
+++ b/src/views/_core/profile/modules/base-info.vue
@@ -22,12 +22,15 @@
}>();
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: {
@@ -36,22 +39,24 @@
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: '鐢ㄦ埛鎬у埆',
@@ -62,14 +67,14 @@
buttonStyle: 'solid',
optionType: 'button',
},
- rules: z.number(),
+ rules: z.number().optional(),
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
- content: '鏇存柊淇℃伅',
+ content: '淇濆瓨淇敼',
},
handleSubmit,
});
@@ -94,7 +99,7 @@
() => props.profile,
(newProfile) => {
if (newProfile) {
- formApi.setValues(newProfile);
+ formApi.setValues({ nickname: newProfile.nickname, mobile: newProfile.mobile ?? '', email: newProfile.email ?? '', sex: newProfile.sex });
}
},
{ immediate: true },
@@ -102,7 +107,11 @@
</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>
diff --git a/src/views/_core/profile/modules/profile-user.vue b/src/views/_core/profile/modules/profile-user.vue
index 57384e3..7eb5073 100644
--- a/src/views/_core/profile/modules/profile-user.vue
+++ b/src/views/_core/profile/modules/profile-user.vue
@@ -1,148 +1,40 @@
<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>
- </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 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>
+<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>
+
diff --git a/src/views/_core/profile/modules/reset-pwd.vue b/src/views/_core/profile/modules/reset-pwd.vue
index e7a0103..95dfd8c 100644
--- a/src/views/_core/profile/modules/reset-pwd.vue
+++ b/src/views/_core/profile/modules/reset-pwd.vue
@@ -9,14 +9,18 @@
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 涓瓧绗�')
@@ -39,6 +43,7 @@
},
fieldName: 'newPassword',
label: '鏂板瘑鐮�',
+ componentProps: { autocomplete: 'new-password', placeholder: '璇疯緭鍏� 5鈥�20 浣嶆柊瀵嗙爜' },
rules: 'required',
},
{
@@ -58,6 +63,7 @@
},
fieldName: 'confirmPassword',
label: '纭瀵嗙爜',
+ componentProps: { autocomplete: 'new-password', placeholder: '璇峰啀娆¤緭鍏ユ柊瀵嗙爜' },
rules: 'required',
},
],
@@ -79,6 +85,7 @@
newPassword: values.newPassword,
});
message.success($t('ui.actionMessage.operationSuccess'));
+ await formApi.resetForm();
} catch (error) {
console.error(error);
} finally {
@@ -88,7 +95,12 @@
</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>
+
diff --git a/src/views/_core/profile/modules/user-social.vue b/src/views/_core/profile/modules/user-social.vue
index 4c96f99..39c0867 100644
--- a/src/views/_core/profile/modules/user-social.vue
+++ b/src/views/_core/profile/modules/user-social.vue
@@ -1,207 +1,84 @@
-<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">
- 瑙g粦
- </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,
- },
- },
-});
-
-/** 瑙g粦璐﹀彿 */
-function onUnbind(row: SystemSocialUserApi.SocialUser) {
- confirm(`纭畾瑙g粦[${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('瑙i櫎涓�' + title + '鐨勭粦瀹氬悗锛屽皢鏃犳硶閫氳繃璇ヨ处鍙风櫥褰曘�傜‘瀹氳В闄ょ粦瀹氬悧锛�'); }
+ catch { return; }
+ busyType.value = user.type;
try {
- // 璁$畻 redirectUri
- // tricky: type 闇�瑕佸厛 encode 涓�娆★紝鍚﹀垯閽夐拤鍥炶皟浼氫涪澶便�傞厤鍚� getUrlValue() 浣跨敤
- // hash 璺敱锛堢敓浜ф瀯寤猴級涓嬪繀椤诲甫 # 鍓嶇紑锛屽惁鍒欏洖璺冲悗 vue-router 瑙f瀽鍒� /锛岃繘涓嶄簡缁戝畾娴佺▼
+ 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;
+onMounted(async () => {
+ const code = route.query.code;
+ if (typeof code === 'string' && code) {
+ emit('update:activeName', 'userSocial');
+ 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; }
}
- await socialBind({ type, code, state });
- // 鎻愮ず鎴愬姛
- message.success('缁戝畾鎴愬姛');
- emit('update:activeName', 'userSocial');
- await gridApi.reload();
- // 娓呯悊 URL 鍙傛暟锛岄伩鍏嶅埛鏂伴噸澶嶈Е鍙�
- window.history.replaceState({}, '', location.pathname);
-}
-
-/** 鍒濆鍖� */
-onMounted(() => {
- bindSocial();
+ await load();
});
</script>
<template>
- <div class="flex flex-col">
- <Grid />
-
- <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 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" />姝e湪鍔犺浇缁戝畾淇℃伅鈥�</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 ? '瑙i櫎缁戝畾' : '绔嬪嵆缁戝畾' }}</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>
+
diff --git a/src/views/dashboard/analytics/index.vue b/src/views/dashboard/analytics/index.vue
index f352c08..4a3a653 100644
--- a/src/views/dashboard/analytics/index.vue
+++ b/src/views/dashboard/analytics/index.vue
@@ -1,140 +1,170 @@
<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>
- <div class="elite-status">
- <span class="elite-status-dot"></span>
- 绯荤粺杩愯涓�
- </div>
+ <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="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>
+ </section>
+
+ <div class="portal-content">
+ <QuickActions />
+
+ <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>
+
+ <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>鍥寸粫璇鹃鐮旂┒銆佹祦绋嬪崗鍚屼笌鏃ュ父涓氬姟锛屾眹鑱氭湇鍔″叆鍙o紝杩炴帴淇℃伅涓庤祫婧愶紝涓哄悇椤瑰伐浣滅殑鏈夊簭寮�灞曟彁渚涗究鎹锋敮鎸併��</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>
-
- <!-- 绯荤粺鎸囨爣 -->
- <KpiCards @navigate="handleNavigate" />
-
- <!-- 寰呭姙 + 蹇嵎鍏ュ彛 -->
- <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>
-
- <!-- 鏈�杩戣棰樼敵鎶� -->
- <RecentApplications @navigate="handleNavigate" />
- </Page>
+ <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>
diff --git a/src/views/dashboard/analytics/modules/kpi-cards.vue b/src/views/dashboard/analytics/modules/kpi-cards.vue
index a813bda..1f3d505 100644
--- a/src/views/dashboard/analytics/modules/kpi-cards.vue
+++ b/src/views/dashboard/analytics/modules/kpi-cards.vue
@@ -1,251 +1,46 @@
-<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>
diff --git a/src/views/dashboard/analytics/modules/notice-panel.vue b/src/views/dashboard/analytics/modules/notice-panel.vue
new file mode 100644
index 0000000..baa0ce6
--- /dev/null
+++ b/src/views/dashboard/analytics/modules/notice-panel.vue
@@ -0,0 +1,95 @@
+<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>
diff --git a/src/views/dashboard/analytics/modules/quick-actions.vue b/src/views/dashboard/analytics/modules/quick-actions.vue
index ea58f12..eec4c36 100644
--- a/src/views/dashboard/analytics/modules/quick-actions.vue
+++ b/src/views/dashboard/analytics/modules/quick-actions.vue
@@ -1,135 +1,49 @@
<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>
diff --git a/src/views/dashboard/analytics/modules/research-highlights.vue b/src/views/dashboard/analytics/modules/research-highlights.vue
new file mode 100644
index 0000000..c7df9ec
--- /dev/null
+++ b/src/views/dashboard/analytics/modules/research-highlights.vue
@@ -0,0 +1,52 @@
+<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">姝e湪鍔犺浇璇鹃鍔ㄦ�佲��</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>
diff --git a/src/views/dashboard/analytics/modules/todo-panel.vue b/src/views/dashboard/analytics/modules/todo-panel.vue
index 916b434..8c840ed 100644
--- a/src/views/dashboard/analytics/modules/todo-panel.vue
+++ b/src/views/dashboard/analytics/modules/todo-panel.vue
@@ -1,208 +1,23 @@
-<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">姝e湪鍔犺浇寰呭姙鈥�</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>
--
Gitblit v1.9.3