云
7 小时以前 91bfcf61f44f353dfebc54a57b5b774721de3e9a
feat(auth): 更新门户认证页面布局和样式

- 替换AuthPageLayout为自定义门户认证布局
- 添加门户品牌标识和标语展示
- 集成审批协同、课题管理、信息互通等功能介绍
- 实现响应式设计适配不同屏幕尺寸
- 添加浏览器兼容性提示

feat(router): 添加个人中心路由和个人资料管理功能

- 新增Profile路由支持个人中心访问
- 实现个人资料编辑功能包括显示名称、手机号码、电子邮箱
- 添加密码设置和个人账号绑定功能
- 集成社交账号绑定管理界面
- 实现个人资料的保存和更新逻辑

feat(layout): 优化基本布局结构和门户主题样式

- 更新头部导航栏布局和样式
- 添加门户品牌文字组件
- 集成个人中心入口按钮
- 优化全局搜索组件外观
- 添加门户主题CSS文件支持

feat(company): 更新公司配置管理和门户名称显示

- 修改江苏商贸公司名称为江苏省供销合作经济学会融合门户
- 实现公司名称显示逻辑根据环境变量调整
- 添加门户名称固定功能防止修改
- 更新公司配置保存和应用逻辑

feat(menu): 添加门户系统菜单支持和权限控制

- 实现portalSystemMenus模块化菜单处理
- 集成角色权限验证机制
- 添加门户系统特定菜单项
- 优化菜单转换和渲染逻辑
已添加43个文件
已修改33个文件
2898 ■■■■■ 文件已修改
company.config.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/DESIGN.md 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/README.md 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/REVIEW.md 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/deep-detector.json 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/deep-home-content.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/deep-home-full.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/deep-home-mobile.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/deep-home-top.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/header-deep-overflow.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/header-optimized.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/header-overflow.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/header-search.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/home-desktop.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/login-desktop.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/login-final.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/login-mobile.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/menu-desktop.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/menu-mobile.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/notice-error.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/notice-retry.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/profile-bindings.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/profile-desktop.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/profile-detector.json 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/profile-mobile-form.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/profile-mobile.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/profile-password.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/profile-scroll-fixed.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/portal-redesign/system-menu-no-tenant.png 补丁 | 查看 | 原始文档 | blame | 历史
public/logos/JSSM-portal.svg 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/static/imgs/portal-avatar.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/static/imgs/portal-hero-v2.png 补丁 | 查看 | 原始文档 | blame | 历史
public/static/imgs/portal-landscape-v1.png 补丁 | 查看 | 原始文档 | blame | 历史
public/static/imgs/portal-modules-v2.png 补丁 | 查看 | 原始文档 | blame | 历史
scripts/portal-preview.html 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/portal-preview.ts 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/portal-review.html 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/bootstrap.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/portal/portal-brand-text.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/portal/portal-footer.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/portal/use-portal-menus.ts 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/basic.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/layout-ui/src/components/layout-header.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/menu-ui/src/components/menu.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/menu-ui/src/types.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/hooks/src/use-app-config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/basic/header/header.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/basic/layout.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/basic/menu/menu.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/global-search/global-search.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/preferences/blocks/company/company.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/portal-theme.css 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/preferences.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/access.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/guard.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/portal-system-menus.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/core.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/company-config.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/company-config.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 244 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/profile/index.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/profile/modules/base-info.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/profile/modules/profile-user.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/profile/modules/reset-pwd.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/profile/modules/user-social.vue 249 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/kpi-cards.vue 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/notice-panel.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/research-highlights.vue 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/todo-panel.vue 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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: '',
  },
};
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
本说明记录现有实现;视觉依据为用户指定的机构门户参考。业务首页采用三层头部、全幅乡村城市红绸主图、红色快捷服务、权限栏目和蓝色页脚;个人中心沿用同一业务外壳。认证页保留白色机构页头、景观介绍区与账号表单。默认浅色,部分暗色覆盖不能视为完整暗色验收。
机构名称由 `preferences.copyright.companyName` æä¾›ï¼ŒJSSM ç«¯å›ºå®šä¸ºâ€œæ±Ÿè‹çœä¾›é”€åˆä½œç»æµŽå­¦ä¼šèžåˆé—¨æˆ·â€ï¼Œç¼–译配置、后端展示层与页标题保持一致。Logo ä½¿ç”¨é…ç½®æ¥æºï¼Œå½“前门户有本地绿色标记 `public/logos/JSSM-portal.svg`。登录介绍使用公司配置及源码缺省文案。中性默认头像为 `public/static/imgs/portal-avatar.svg`。这些资产不代表已取得其他机构的身份授权。
## Colors
前置色值来自现有主题与页脚。蓝色承担链接、侧栏选择和结构层次,深蓝承担主要文字,灰蓝承担辅助信息,白色承担内容表面。红色承担桌面一级导航选中、快捷服务标识及主要操作;蓝色页脚及更深的版权条收束长页面。局部绿色标记和功能图标不扩展为新全局品牌色。
## Typography
正文与表格继承项目基础字体栈。首页主标题使用系统楷体/宋体回退(KaiTi、STKaiti、Noto Serif SC、serif),尺寸随宽度变化(32–54px、800);手机为(32px),极窄屏为(29px)。头部机构名为(25px、750),中等宽度为(21px),手机为(16px)。栏目标题为(17px、700),正文与辅助信息主要为(11–14px)。个人中心页标题为(29px、700),手机为(25px)。字距强调用于品牌与横幅,不应用于资料表单正文。
## Layout
业务模式采用 `mixed-nav`:桌面头部为工具层(32px)、品牌与搜索层(80px)、一级导航层(46px),共(158px);手机回归(72px)头部与抽屉导航。中等宽度(768–1100px)隐藏品牌附加文字、收窄搜索与品牌区,保留昵称和操作可读性。桌面导航超出可用宽度收纳到“更多”。侧栏默认展开宽度(228px),标签栏高(40px),标签栏按实际侧栏占用宽度对齐;标签历史不全部持久化。
首页横幅全幅,桌面最小高(350px),手机为(280px);正文容器最大(1280px),桌面左右各留(40px),(1050px)以下各留(24px),(767px)以下各留(14px)。新闻区域为宽窄双列,栏目网格最小列宽(270px)。手机将新闻与服务区改为单列、专题改为双列,快捷入口改为三列,红色标签从左侧移至顶部。首页专用蓝色页脚不等同于框架默认固定页脚。
个人中心最大宽度(1240px),左身份卡(282px)、右设置区自适应,间隔(20px);(1000px)以下左列(240px)、间隔(14px);(767px)以下上下堆叠。认证模式仍为桌面介绍与表单双列,(850px)以下单列,(420px)以下缩减表单内距与Logo。
`src/main.ts` é€šè¿‡å½“前偏好命名空间的 `portal-appearance-v2` æ ‡è®°æ‰§è¡Œä¸€æ¬¡å¤–观迁移,保留登录存储,随后允许用户继续调整外观。
## Elevation & Depth
白色表面、浅蓝灰边框和轻阴影构成业务层级。栏目使用 `0 3px 10px rgb(24 66 106 / 4%)`,个人中心为相同方向的更淡阴影(3%)。认证表单保留更明显的 `0 18px 56px rgb(24 62 98 / 15%)`,不推广为业务列表阴影。
## Shapes
栏目与输入以小圆角组织,身份卡、快捷条、认证面板的圆角见前置令牌。一级导航为整齐方形色块,快捷区桌面红色标签带箭头形裁切、手机改矩形;功能图标使用圆形底。列表采用水平分隔,避免额外装饰线干扰资料编辑。
## Components
- **头部**:可读头像昵称下拉、个人中心直达、密码设置快捷菜单与全局菜单搜索。桌面一级菜单选中红底白字,侧栏选中蓝底白字;“更多”保留展开、关闭和焦点交互。中等宽度优先压缩品牌,不挤掉用户入口。
- **首页**:`usePortalMenus` ä»Žè´¦å·æƒé™èœå•构建可见栏目与叶入口,排除隐藏、禁用及空目录,按现有路径和名称识别课题、待办、报销、公告等功能。相关面板调用真实业务 API;缺少权限时不凭预览样本添加生产模块。保留加载、空态、失败及适用的重试状态。
- **个人中心**:左侧头像、身份和联系信息,右侧基本资料、密码设置、账号绑定三个页签;手机堆叠。沿用原接口,头像上传读取返回对象的 `url`,空邮箱允许不填,密码修改成功后清空表单。绑定回调保留 hash è·¯ç”±ï¼Œä½¿ç”¨ `router.replace` æ¸…理 `code/state/type`;界面不展示 openid æˆ– token。Profile æ˜¯éšè— core è·¯ç”±ï¼Œä½†ä¸èµ° core è·¯ç”±å…è®¤è¯åˆ†æ”¯ï¼Œä»æ ¡éªŒ token。
- **登录**:保留账号密码标签、验证码开关与既有提交逻辑,输入和提交按钮高(46px),有明确聚焦与提交中状态。
- **图像**:`public/static/imgs/portal-hero-v2.png` ä¸ºæ— æ–‡å­—的乡村、城市与红绸主图;`portal-modules-v2.png` æ˜¯ä¹¦ç±ã€åŠžå…¬ã€è´¢åŠ¡ã€æ¡£æ¡ˆå››è±¡é™å›¾é›†ï¼›ä¿ç•™çš„ `portal-landscape-v1.png` ç”¨äºŽè®¤è¯æ™¯è§‚。生成图不作为真实地点或事件照片;用途与预览边界见 README.md。
## Do's and Don'ts
- **Do**:保持蓝白结构、红色强调、配置品牌与动态权限菜单;窄屏检查抽屉、品牌压缩、表单和页签可读性。
- **Do**:让真实 API çš„加载、失败、成功状态与演示数据保持清楚边界,保留键盘焦点及敏感字段隐藏。
- **Don't**:把示例截图、检测器无提示或局部静态检查视为真实账号、权限、上传、改密和绑定验收。
- **Don't**:将 `scripts/portal-preview` æˆ– fixture æ•°æ®æŽ¥å…¥æ­£å¼å…¥å£ã€‚
独立审查结论为“视觉收尾通过,可交付界面预览”。真实后端登录与写入、第三方授权、完整键盘流程、暗色及计算对比度仍未验收;具体边界见 REVIEW.md。
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)。正式入口不加载 `scripts/portal-preview`。开发样式入口 `/scripts/portal-preview.html?surface=home|menu|login|profile|system` ä½¿ç”¨ fixture;`system` æ¼”示只返回租户菜单时按已授权系统页面重建导航。菜单拥挤场景可附加 `crowded=1`,个人中心页签可用 `section=resetPwd` æˆ– `section=userSocial`。具体支持参数以脚本为准。
## å½“前实现与验证边界
当前方向贴近用户参考:桌面三层头部、全幅乡村城市红绸主图、红色快捷服务、动态权限栏目及蓝色页脚,补充个人中心与头部用户操作。相关首页模块接入真实课题、待办、报销和公告 API,个人中心沿用原资料、头像、密码和绑定接口;样式预览使用示例响应。
JSSM å“ç‰Œåç§°å›ºå®šä¸ºâ€œæ±Ÿè‹çœä¾›é”€åˆä½œç»æµŽå­¦ä¼šèžåˆé—¨æˆ·â€ï¼Œé¡µé¢æ ‡é¢˜ã€å¤´éƒ¨ã€ç™»å½•和页脚一致。系统管理过滤租户页,只补充登录账号已获查询权限的现有管理页(超级管理员可见全部);如果后端既未返回非租户菜单也未授权对应权限,系统管理入口不会出现,需要在后端配置角色授权。个人中心和长业务页使用主内容区滚动,外层页面保持固定头部。
本次相关源码在 `vue-tsc` è¾“出中过滤无错;全仓因存量重复定义退出,日志为 `typecheck-latest.log`。后端连接不可用,真实登录、资料写入、上传、改密和第三方绑定仍未验收。检测器首页输出 `[]`;个人中心唯一 `border-left` è£…饰提示已移除。独立复核结论为“视觉收尾通过,可交付界面预览”;完整键盘流程、暗色与计算对比度未验收,详见 REVIEW.md。
当前截图:`deep-home-full.png`、`deep-home-mobile.png`、`header-deep-overflow.png`、`profile-desktop.png`、`profile-mobile-form.png`、`profile-password.png`、`profile-bindings.png`。均为示例场景,不能证明生产账号数据或接口写入成功。目录中旧版截图与旧检测文件留作历史记录,不代表本版状态。
## å›¾åƒä¸Žå“ç‰Œ
- `public/static/imgs/portal-hero-v2.png`:生成的无文字乡村、城市与红绸主图,用于首页横幅及局部裁切。
- `public/static/imgs/portal-modules-v2.png`:生成的书籍、办公、财务、档案 2×2 å›¾é›†ï¼Œé€šè¿‡èƒŒæ™¯å®šä½è£åˆ‡ç”¨äºŽæ ç›®ä¸Žä¸“题。
- `public/static/imgs/portal-landscape-v1.png`:前版生成景观,保留用于认证背景。
- `public/logos/JSSM-portal.svg`:本地绿色门户标记;机构名称及Logo仍通过既有品牌配置提供。
- `public/static/imgs/portal-avatar.svg`:本地中性默认头像。
生成景观与图集不是特定真实地点或事件的记录照片。v2 æ­¤å¤„仅记录已知用途,不将简述冒充完整生成提示词。
v1 åŽŸç”Ÿæˆæç¤ºè¯ï¼ˆåŽ†å²è®°å½•ï¼‰ï¼š
> Create one premium photorealistic panoramic website background asset for a Jiangsu supply and marketing cooperation / academic business portal. No text, lettering, logos, watermarks or UI. Landscape 3:2 composition. Jiangnan green agricultural fields and a calm river in foreground, small white traditional villages in middle distance, a restrained modern Chinese city skyline far on the right horizon, soft blue sky, warm clear morning daylight. Quiet, credible institutional photography / realistic composite, natural greens and pale sky blues, no oversaturation. Large clean pale sky in the upper left half for HTML headings; landscape fills the lower 45 percent and skyline stays distant on the right. Crisp detail and atmospheric depth. No red fabric, flags, people, charts or glowing effects.
## å¤–观迁移
`src/preferences.ts` å®šä¹‰å…±ç”¨å¤–观。`src/main.ts` ä»¥çŽ°æœ‰åå¥½å‘½åç©ºé—´çš„ `portal-appearance-v2` æ ‡è®°æ‰§è¡Œä¸€æ¬¡è¿ç§»ï¼Œæ›´æ–°å¸ƒå±€ä¸Žé»˜è®¤å¤´åƒï¼Œä¿ç•™ç™»å½•存储;执行后允许后续用户偏好调整。桌面实际头部高度由布局及门户样式共同实现,不能只读偏好中的72px推断桌面高度。
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 å•文件组件已通过独立解析与模板编译,菜单权限变换已通过本地断言,本地 Vite é¢„览正常。
个人中心已实现:上传返回对象取 `url`、空邮箱可选、改密成功清空、OAuth å›žè°ƒä¿ç•™ hash è·¯ç”±å¹¶ç”± `router.replace` æ¸…理 `code/state/type`、不显示 openid/token;Profile éšè— core è·¯ç”±ä»æ£€æŸ¥ token。移动个人中心图标带 `aria-label`,默认头像为本地中性 SVG。以上属于实现记录,不等同于真实后端联调成功。
本版主要证据:`deep-home-full.png`、`deep-home-mobile.png`、`header-deep-overflow.png`、`profile-desktop.png`、`profile-mobile-form.png`、`profile-password.png`、`profile-bindings.png`。截图均为示例场景。正式入口未加载 fixture é¢„览脚本,预览菜单不得作为实际账号权限说明。旧版截图与旧检测文件保留作历史记录。
docs/portal-redesign/deep-detector.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
[]
docs/portal-redesign/deep-home-content.png
docs/portal-redesign/deep-home-full.png
docs/portal-redesign/deep-home-mobile.png
docs/portal-redesign/deep-home-top.png
docs/portal-redesign/header-deep-overflow.png
docs/portal-redesign/header-optimized.png
docs/portal-redesign/header-overflow.png
docs/portal-redesign/header-search.png
docs/portal-redesign/home-desktop.png
docs/portal-redesign/login-desktop.png
docs/portal-redesign/login-final.png
docs/portal-redesign/login-mobile.png
docs/portal-redesign/menu-desktop.png
docs/portal-redesign/menu-mobile.png
docs/portal-redesign/notice-error.png
docs/portal-redesign/notice-retry.png
docs/portal-redesign/profile-bindings.png
docs/portal-redesign/profile-desktop.png
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"
    ]
  }
]
docs/portal-redesign/profile-mobile-form.png
docs/portal-redesign/profile-mobile.png
docs/portal-redesign/profile-password.png
docs/portal-redesign/profile-scroll-fixed.png
docs/portal-redesign/system-menu-no-tenant.png
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>
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>
public/static/imgs/portal-hero-v2.png
public/static/imgs/portal-landscape-v1.png
public/static/imgs/portal-modules-v2.png
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>
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();
// æ­¤æ›¿èº«ä»…存在于独立预览入口,正式入口不加载本文件。
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();
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>
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';
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>
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>
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 };
}
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 ä¼˜å…ˆå–数据库配置(preferences.logo.source),未配置时回退编译期默认 __APP_LOGO__
const logo = computed(() => preferences.logo.source || __APP_LOGO__);
const logoDark = computed(() => preferences.logo.sourceDark);
// ç™»å½•页标题/描述使用 i18n å›ºå®šæ–‡æ¡ˆï¼Œä¸éšå…¬å¸é…ç½®å˜åŒ–
const pageTitle = computed(() => $t('authentication.pageTitle'));
const pageDescription = computed(() => $t('authentication.pageDesc'));
const pageTitle = computed(() => companyConfigStore.loginTitle || '服务供销合作,助力乡村振兴');
const pageDescription = computed(() => companyConfigStore.loginDescription || '连接课题研究、审批协作与日常业务,让每一项工作有序推进。');
</script>
<template>
  <AuthPageLayout
    :app-name="appName"
    :logo="logo"
    :logo-dark="logoDark"
    :page-description="pageDescription"
    :page-title="pageTitle"
  >
    <!-- è‡ªå®šä¹‰å·¥å…·æ  -->
    <!-- <template #toolbar></template> -->
  </AuthPageLayout>
  <div class="portal-auth">
    <header class="auth-masthead">
      <div class="auth-brand">
        <img :src="logo" :alt="appName" />
        <PortalBrandText />
      </div>
      <span class="auth-motto">开放协作 Â· ä¾¿æ·æœåŠ¡ Â· é«˜æ•ˆåŠžç†</span>
    </header>
    <main class="auth-main">
      <div class="auth-landscape" aria-hidden="true"></div>
      <div class="auth-content">
        <section class="auth-intro" aria-labelledby="auth-heading">
          <h1 id="auth-heading">{{ pageTitle }}</h1>
          <p>{{ pageDescription }}</p>
          <div class="auth-services">
            <div><IconifyIcon icon="lucide:workflow" /><strong>审批协同</strong><span>流程办理 Â· å¾…办跟进</span></div>
            <div><IconifyIcon icon="lucide:book-open-text" /><strong>课题管理</strong><span>指南查询 Â· è¯¾é¢˜ç”³æŠ¥</span></div>
            <div><IconifyIcon icon="lucide:megaphone" /><strong>信息互通</strong><span>通知公告 Â· æ¶ˆæ¯æé†’</span></div>
          </div>
        </section>
        <section class="auth-form-panel" aria-label="身份认证">
          <RouterView v-slot="{ Component, route }">
            <KeepAlive :include="['Login']">
              <component :is="Component" :key="route.path" />
            </KeepAlive>
          </RouterView>
        </section>
      </div>
    </main>
    <footer class="auth-footer"><span>{{ appName }}</span><span>建议使用最新版 Chrome æˆ– Edge æµè§ˆå™¨</span></footer>
  </div>
</template>
<style scoped>
.portal-auth { --background: 0 0% 100%; --foreground: 213 51% 23%; --muted-foreground: 213 19% 43%; --input-background: 210 75% 99%; --input: 211 29% 81%; --border: 211 37% 89%; display: flex; min-height: 100dvh; flex-direction: column; color-scheme: light; color: #18365a; background: #f5f9fd; }
.auth-masthead { display: flex; width: 100%; min-height: 120px; align-items: center; justify-content: space-between; gap: 24px; padding: 20px max(4vw, calc((100vw - 1280px) / 2)); border-top: 20px solid #d9ecfb; background: #fff; }
.auth-brand { display: flex; min-width: 0; align-items: center; gap: 16px; }
.auth-brand img { width: 56px; height: 56px; object-fit: contain; }
.auth-motto { color: #426689; font-size: 14px; letter-spacing: .12em; white-space: nowrap; }
.auth-main { position: relative; display: flex; min-height: 650px; flex: 1; align-items: center; overflow: hidden; background: #e3f0f9; }
.auth-landscape { position: absolute; inset: 0; background: linear-gradient(90deg, rgb(247 251 255 / 90%), rgb(247 251 255 / 55%) 46%, rgb(247 251 255 / 8%) 75%), url('/static/imgs/portal-hero-v2.png') center 58% / cover; }
.auth-content { position: relative; display: grid; width: min(1320px, 90%); grid-template-columns: minmax(0, 1fr) 420px; align-items: center; gap: 8%; margin: 52px auto 68px; }
.auth-intro { align-self: start; max-width: 680px; padding-top: 56px; }
.auth-intro h1 { max-width: 13em; margin: 0 0 22px; color: #a91e28; font-family: 'KaiTi', 'STKaiti', serif; font-size: clamp(34px, 3.8vw, 54px); font-weight: 800; letter-spacing: .045em; line-height: 1.4; text-wrap: balance; }
.auth-intro > p { max-width: 32em; margin: 0; color: #294e73; font-size: 17px; line-height: 1.9; }
.auth-services { display: flex; gap: 32px; margin-top: 42px; }
.auth-services > div { display: grid; gap: 7px; }
.auth-services :deep(svg) { width: 25px; height: 25px; margin-bottom: 3px; color: #28689b; }
.auth-services strong { font-size: 15px; }
.auth-services span { color: #4c6c88; font-size: 12px; white-space: nowrap; }
.auth-form-panel { position: relative; padding: 38px 38px 30px; border-radius: 12px; background: #fff; box-shadow: 0 18px 56px rgb(24 62 98 / 15%); }
.auth-form-panel::before { position: absolute; top: 0; right: 38px; left: 38px; height: 3px; background: #b7242d; content: ''; }
.auth-footer { display: flex; min-height: 64px; align-items: center; justify-content: center; gap: 28px; padding: 16px 24px; color: #5c7187; background: #fff; font-size: 12px; }
@media (max-width: 1100px) { .auth-content { grid-template-columns: minmax(0, 1fr) 390px; gap: 5%; } .auth-services { gap: 20px; } .auth-services span { white-space: normal; } .auth-form-panel { padding: 32px; } }
@media (max-width: 850px) { .auth-masthead { min-height: 90px; padding: 18px 6%; } .auth-motto { display: none; } .auth-content { width: min(460px, 90%); grid-template-columns: 1fr; gap: 25px; margin: 30px auto 42px; } .auth-intro { padding-top: 0; } .auth-intro h1 { margin-bottom: 10px; font-size: 32px; } .auth-intro > p { font-size: 14px; } .auth-services { display: none; } .auth-main { min-height: 0; } .auth-footer { flex-direction: column; gap: 5px; } .auth-landscape { background-position: 35% center; } }
@media (max-width: 420px) { .auth-form-panel { padding: 28px 24px; } .auth-brand strong { font-size: 22px; } .auth-brand img { width: 46px; height: 46px; } }
@media (max-width: 850px) { .auth-brand :deep(.shell-brand-main) { gap: 10px; }.auth-brand :deep(.shell-brand strong) { max-width: calc(100vw - 120px); font-size: 18px; white-space: normal; }.auth-brand :deep(.shell-brand em) { display: none; }.auth-brand :deep(.shell-brand small) { font-size: 8px; letter-spacing: 0; } }
</style>
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"
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__ } });
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>
src/packages/@core/ui-kit/layout-ui/src/vben-layout.vue
@@ -281,19 +281,13 @@
  // å¦‚果不是混合导航,tabbar çš„宽度为 100%
  if (!isMixedNav.value || props.sidebarHidden) {
    width = '100%';
  } else if (sidebarEnable.value) {
    // é¼ æ ‡åœ¨ä¾§è¾¹æ ä¸Šæ—¶ï¼Œä¸”侧边栏展开时的宽度
    const onHoveringWidth = sidebarExpandOnHover.value
      ? props.sidebarWidth
      : getSideCollapseWidth.value;
    // è®¾ç½® marginLeft,根据侧边栏是否折叠来决定
    marginLeft = sidebarCollapse.value
  } else if (sidebarEnable.value && showSidebar.value && !props.isMobile) {
    // æ ‡ç­¾æ ä¸Žå®žé™…侧栏对齐;临时悬停展开时才保留折叠宽度。
    const occupiedWidth = sidebarExpandOnHovering.value && !sidebarExpandOnHover.value
      ? getSideCollapseWidth.value
      : onHoveringWidth;
    // è®¾ç½® tabbar çš„宽度,计算方式为 100% å‡åŽ»ä¾§è¾¹æ çš„å®½åº¦
    width = `calc(100% - ${sidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
      : getSidebarWidth.value;
    marginLeft = occupiedWidth;
    width = `calc(100% - ${occupiedWidth}px)`;
  } else {
    // é»˜è®¤æƒ…况下,tabbar çš„宽度为 100%
    width = '100%';
@@ -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>
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 â€œmore” only when
  // the complete menu actually overflows, otherwise the final item is hidden.
  const itemWidths = items.map(calcMenuItemWidth);
  if (itemWidths.reduce((total, width) => total + width, 0) <= menuWidth + 1) {
    return -1;
  }
  let calcWidth = 0;
  let sliceIndex = 0;
  items.forEach((item, index) => {
    calcWidth += calcMenuItemWidth(item);
  itemWidths.forEach((width, index) => {
    calcWidth += width;
    if (calcWidth <= menuWidth - moreItemWidth) {
      sliceIndex = index + 1;
    }
@@ -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" />
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>
src/packages/@core/ui-kit/menu-ui/src/types.ts
@@ -40,6 +40,9 @@
   */
  mode?: 'horizontal' | 'vertical';
  /** æ¨ªå‘菜单收纳入口的文字;为空时仅展示图标。 */
  overflowLabel?: string;
  /**
   * @zh_CN æ˜¯å¦åœ†æ¶¦é£Žæ ¼
   * @default true
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 {
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) => {
    // é€‚配插槽名称,例如第一个插槽名:header-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'">
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>
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"
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>
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';
// æ‰“开区块时从后端拉取最新公司配置并回填表单(store åˆå€¼å¯èƒ½ä¸ºç©ºï¼Œå› å¯åŠ¨åŠ è½½èµ°çš„æ˜¯çº¯å‡½æ•°ï¼‰
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
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>
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; }
}
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'),
  });
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 æç¤º
      // è¡¥å……说明:accessStore.accessMenus ä¸€å¼€å§‹æ˜¯ AppRouteRecordRaw ç±»åž‹ï¼ˆåŽç«¯åŠ è½½ï¼‰ï¼ŒåŽé¢è¢«èµ‹å€¼æˆ MenuRecordRaw ç±»åž‹ï¼ˆå‰ç«¯è½¬æ¢ï¼‰
      const accessMenus = accessStore.accessMenus as AppRouteRecordRaw[];
      return convertServerMenuToRouteRecordStringComponent(accessMenus);
      const portalMenus = import.meta.env.VITE_COMPANY === 'JSSM'
        ? portalSystemMenus(accessMenus, accessStore.accessCodes, options.roles ?? [])
        : accessMenus;
      return convertServerMenuToRouteRecordStringComponent(portalMenus);
    },
    // å¯ä»¥æŒ‡å®šæ²¡æœ‰æƒé™è·³è½¬403页面
    forbiddenComponent,
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) ||
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;
}
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,
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 ?? '';
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 };
    // åŒæ­¥ç½‘页标题图标(favicon)为上传的 logo
    const faviconLink = document.querySelector('link[rel="icon"]');
    faviconLink?.setAttribute('href', config.logoUrl);
  }
  if (config.companyName) {
    updates.app = { name: config.companyName };
    updates.copyright = { companyName: config.companyName };
  if (companyName) {
    updates.app = { name: companyName };
    updates.copyright = { companyName };
  }
  if (Object.keys(updates).length > 0) {
    preferencesManager.updatePreferences(updates);
  }
  if (config.companyName) {
    document.title = config.companyName;
  if (companyName) {
    document.title = companyName;
  }
}
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;
  /* æœ«å­—右侧会多出一份字距,用等量缩进抵消,视觉才真正居中 */
  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>
.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>
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>正在加载个人资料…</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>
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>
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>
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>
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">
              è§£ç»‘
            </Button>
          );
        },
      },
    },
  ];
const loading = ref(true);
const failed = ref(false);
const busyType = ref<number>();
const callbackFailed = ref(false);
const allBindList = computed(() => Object.values(SystemUserSocialTypeEnum).map(platform => ({
  ...platform, user: bindList.value.find(item => item.type === platform.type),
})));
async function load() {
  loading.value = true; failed.value = false;
  try { bindList.value = await getBindSocialUserList(); }
  catch { failed.value = true; }
  finally { loading.value = false; }
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useGridColumns(),
    minHeight: 0,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async () => {
          bindList.value = await getBindSocialUserList();
          return bindList.value;
        },
      },
    },
    rowConfig: {
      keyField: 'id',
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  },
});
/** è§£ç»‘账号 */
function onUnbind(row: SystemSocialUserApi.SocialUser) {
  confirm(`确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`).then(async () => {
    await socialUnbind({ type: row.type, openid: row.openid });
    // æç¤ºæˆåŠŸ
    message.success($t('ui.actionMessage.operationSuccess'));
    await gridApi.reload();
  });
}
/** ç»‘定账号(跳转授权页面) */
async function onBind(bind: any) {
  const type = bind.type;
  if (type <= 0) {
    return;
  }
async function unbind(user: SystemSocialUserApi.SocialUser, title: string) {
  try { await confirm('解除与' + title + '的绑定后,将无法通过该账号登录。确定解除绑定吗?'); }
  catch { return; }
  busyType.value = user.type;
  try {
    // è®¡ç®— redirectUri
    // tricky: type éœ€è¦å…ˆ encode ä¸€æ¬¡ï¼Œå¦åˆ™é’‰é’‰å›žè°ƒä¼šä¸¢å¤±ã€‚配合 getUrlValue() ä½¿ç”¨
    // hash è·¯ç”±ï¼ˆç”Ÿäº§æž„建)下必须带 # å‰ç¼€ï¼Œå¦åˆ™å›žè·³åŽ vue-router è§£æžåˆ° /,进不了绑定流程
    await socialUnbind({ type: user.type, openid: user.openid });
    message.success('已解除绑定');
    await load();
  } finally { busyType.value = undefined; }
}
async function bind(type: number) {
  busyType.value = type;
  try {
    const hashPrefix = import.meta.env.VITE_ROUTER_HISTORY === 'hash' ? '/#' : '';
    const redirectUri = `${location.origin}${hashPrefix}/profile?${encodeURIComponent(`type=${type}`)}`;
    // è¿›è¡Œè·³è½¬
    const redirectUri = location.origin + hashPrefix + '/profile?' + encodeURIComponent('type=' + type);
    window.location.href = await socialAuthRedirect(type, redirectUri);
  } catch (error) {
    console.error('社交绑定处理失败:', error);
  }
  } finally { busyType.value = undefined; }
}
/** ç›‘听路由变化,处理社交绑定回调 */
async function bindSocial() {
  // ç¤¾äº¤ç»‘定
  const type = Number(getUrlValue('type'));
  const code = route.query.code as string;
  const state = route.query.state as string;
  if (!code) {
    return;
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" />正在加载绑定信息…</div>
    <div v-else-if="failed" class="social-state" role="status"><span>暂时无法获取绑定信息</span><button type="button" @click="load">重新加载</button></div>
    <div v-else class="social-platforms">
      <article v-for="item in allBindList" :key="item.type" class="social-platform">
        <span class="social-platform-icon" :class="item.source"><IconifyIcon :icon="item.source === 'dingtalk' ? 'ant-design:dingtalk-outlined' : 'ant-design:wechat-outlined'" /></span>
        <div class="social-platform-copy"><h3>{{ item.title }}<span :class="{ bound: item.user }">{{ item.user ? '已绑定' : '未绑定' }}</span></h3><p>{{ item.user ? (item.user.nickname || '已关联此平台账号') : '绑定后可使用' + item.title + '账号登录' }}</p></div>
        <button type="button" :disabled="busyType !== undefined" :class="{ unbind: item.user }" @click="item.user ? unbind(item.user, item.title) : bind(item.type)">{{ busyType === item.type ? '处理中…' : item.user ? '解除绑定' : '立即绑定' }}</button>
      </article>
    </div>
    <div class="social-note"><IconifyIcon icon="lucide:info" /><p>绑定时将前往对应平台授权。解除绑定不影响当前账号使用用户名和密码登录。</p></div>
  </div>
</template>
<style scoped>
.social-platforms { display: grid; gap: 14px; }.social-platform { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 20px; border: 1px solid #dfeaf4; border-radius: 6px; }.social-platform-icon { display: grid; width: 48px; height: 48px; flex: none; place-items: center; border-radius: 10px; color: #2076bf; background: #edf5fd; font-size: 28px; }.social-platform-icon.wechat_enterprise { color: #19895a; background: #edf8f2; }.social-platform-copy { min-width: 0; flex: 1; }.social-platform-copy h3 { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; margin: 0 0 7px; font-size: 15px; font-weight: 650; }.social-platform-copy h3 span { padding: 2px 7px; border-radius: 3px; color: #657a8c; background: #f0f4f7; font-size: 10px; font-weight: 400; }.social-platform-copy h3 span.bound { color: #207348; background: #edf8f1; }.social-platform-copy p { margin: 0; color: var(--portal-muted); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }.social-platform button,.social-state button { min-height: 34px; flex: none; padding: 6px 13px; border: 1px solid #b7242d; border-radius: 4px; color: #b7242d; background: transparent; font-size: 12px; cursor: pointer; }.social-platform button.unbind { border-color: #d5e0eb; color: #4e6b85; }.social-platform button:disabled { opacity: .5; cursor: wait; }.social-note { display: flex; align-items: flex-start; gap: 9px; margin-top: 22px; color: var(--portal-muted); font-size: 12px; line-height: 1.8; }.social-note svg { width: 16px; height: 16px; flex: none; margin-top: 3px; }.social-note p { margin: 0; }.social-state { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 14px; color: var(--portal-muted); font-size: 13px; }.social-state svg { width: 20px; }.social-error { padding: 12px; color: #a3222b; background: #fff2f1; font-size: 12px; }button:focus-visible { outline: 2px solid #2c70b2; outline-offset: 3px; }
@media(max-width:520px) { .social-platform { flex-wrap: wrap; gap: 12px; padding: 16px; }.social-platform-copy { flex-basis: calc(100% - 60px); }.social-platform button { margin-left: 60px; }.social-state { flex-direction: column; } }
</style>
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>围绕课题研究、流程协同与日常业务,汇聚服务入口,连接信息与资源,为各项工作的有序开展提供便捷支持。</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>
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>
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>
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>
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">正在加载课题动态…</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>
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">正在加载待办…</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>