已添加38个文件
已删除8个文件
已修改23个文件
7965 ■■■■■ 文件已修改
.env 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.development 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/_e2e_attachment.txt 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-09-28T10-11-52-064Z.yml 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-09-28T10-11-57-412Z.yml 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
company.config.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/logos/JSSM.svg 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/static/imgs/jssm-campus.svg 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/borrow/index.ts 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/category/index.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/document/index.ts 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/archive/stat/index.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/finance/reimbursement/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/application/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/fund/index.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/member/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/activity.ts 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/member.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/wxcp/notice.ts 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/layouts/src/authentication/authentication.vue 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/locales/src/langs/en-US/authentication.json 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/locales/src/langs/zh-CN/authentication.json 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 305 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/data.ts 307 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/detail/approve/index.vue 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/index.vue 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/modules/form.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/modules/process-select-modal.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/borrow/modules/return-form.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/category/data.ts 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/category/index.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/category/modules/form.vue 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/document/data.ts 442 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/document/index.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/document/modules/form.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/stat/data.ts 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/archive/stat/index.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/ai-insights.vue 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/alert-panel.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/biz-cards.vue 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/kpi-cards.vue 359 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/production-trend.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/recent-applications.vue 272 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/todo-panel.vue 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/data.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/fund-form.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/fund-panel.vue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/index.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/data.ts 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/index.vue 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/form.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/push-modal.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/qrcode-modal.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/signup-list.vue 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/activity/modules/stats-modal.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/components/push-scope-picker.vue 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/data.ts 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/data.ts 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/index.vue 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/modules/form.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wxcp/notice/modules/push-modal.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -1,5 +1,5 @@
# åº”用标题
VITE_APP_TITLE=工业AI智造平台
VITE_APP_TITLE=江苏商贸
# åº”用命名空间,用于缓存、store等功能的前缀,确保隔离
VITE_APP_NAMESPACE=yudao-vben-antd
.env.development
@@ -1,6 +1,12 @@
# ç«¯å£å·
VITE_PORT=5666
# åº”用标题
VITE_APP_TITLE=江苏商贸
# å½“前公司标识:决定应用名称 / Logo / å›¾æ ‡ï¼ˆè§ company.config.ts)
VITE_COMPANY=JSSM
VITE_BASE=/
# è¯·æ±‚路径
@@ -21,4 +27,4 @@
VITE_APP_DEFAULT_PASSWORD=admin123
# äº§å“è®¾è®¡æ¨¡å—本地 Mock(无后端时演示)
VITE_PD_MOCK=true
VITE_PD_MOCK=true
.playwright-mcp/_e2e_attachment.txt
ÎļþÒÑɾ³ý
.playwright-mcp/page-2026-09-28T10-11-52-064Z.yml
ÎļþÒÑɾ³ý
.playwright-mcp/page-2026-09-28T10-11-57-412Z.yml
ÎļþÒÑɾ³ý
company.config.ts
@@ -71,4 +71,10 @@
    favicon: '/logos/YZFXLogo.ico',
    apiUrl: 'http://36.213.150.237:9006/admin-api',
  },
  JSSM: {
    name: '江苏商贸',
    logo: '/logos/JSSM.svg',
    favicon: '/logos/JSSM.svg',
    apiUrl: '',
  },
};
package.json
@@ -52,6 +52,7 @@
    "@vueuse/core": "catalog:",
    "@vueuse/integrations": "catalog:",
    "ant-design-vue": "catalog:",
    "axios": "catalog:",
    "bpmn-js": "catalog:",
    "bpmn-js-properties-panel": "catalog:",
    "bpmn-js-token-simulation": "catalog:",
pnpm-lock.yaml
@@ -620,6 +620,9 @@
      ant-design-vue:
        specifier: 'catalog:'
        version: 4.2.6(vue@3.5.38(typescript@6.0.3))
      axios:
        specifier: 'catalog:'
        version: 1.18.1
      bpmn-js:
        specifier: 'catalog:'
        version: 18.18.0
@@ -3183,8 +3186,8 @@
  '@lezer/common@1.5.2':
    resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
  '@lezer/highlight@1.2.3':
    resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
  '@lezer/highlight@1.2.4':
    resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
  '@lezer/json@1.0.3':
    resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
@@ -8844,8 +8847,8 @@
  strnum@1.0.5:
    resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
  style-mod@4.1.3:
    resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
  style-mod@4.1.4:
    resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
  style-search@0.1.0:
    resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==}
@@ -9207,8 +9210,8 @@
    resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
    engines: {node: '>= 0.4'}
  typed-array-byte-offset@1.0.4:
    resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
  typed-array-byte-offset@1.0.5:
    resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==}
    engines: {node: '>= 0.4'}
  typed-array-length@1.0.8:
@@ -10710,7 +10713,7 @@
    dependencies:
      '@codemirror/language': 6.12.3
      '@codemirror/view': 6.43.1
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
  '@bpmn-io/diagram-js-ui@0.2.4':
    dependencies:
@@ -10733,7 +10736,7 @@
      '@codemirror/lint': 6.9.7
      '@codemirror/state': 6.6.0
      '@codemirror/view': 6.43.1
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
      min-dom: 5.3.0
      mitt: 3.0.1
@@ -10758,13 +10761,13 @@
  '@bpmn-io/lezer-feel@2.5.0':
    dependencies:
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
      '@lezer/lr': 1.4.10
      min-dash: 5.0.0
  '@bpmn-io/lezer-feel@3.0.1':
    dependencies:
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
      '@lezer/lr': 1.4.10
      min-dash: 5.0.0
@@ -10849,9 +10852,9 @@
      '@codemirror/state': 6.6.0
      '@codemirror/view': 6.43.1
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
      '@lezer/lr': 1.4.10
      style-mod: 4.1.3
      style-mod: 4.1.4
  '@codemirror/lint@6.9.7':
    dependencies:
@@ -10867,7 +10870,7 @@
    dependencies:
      '@codemirror/state': 6.6.0
      crelt: 1.0.7
      style-mod: 4.1.3
      style-mod: 4.1.4
      w3c-keyname: 2.2.8
  '@commitlint/cli@19.8.0(@types/node@25.9.4)(typescript@6.0.3)':
@@ -11560,14 +11563,14 @@
  '@lezer/common@1.5.2': {}
  '@lezer/highlight@1.2.3':
  '@lezer/highlight@1.2.4':
    dependencies:
      '@lezer/common': 1.5.2
  '@lezer/json@1.0.3':
    dependencies:
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
      '@lezer/lr': 1.4.10
  '@lezer/lr@1.4.10':
@@ -11577,7 +11580,7 @@
  '@lezer/markdown@1.6.4':
    dependencies:
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
  '@livekit/mutex@1.1.1': {}
@@ -14351,7 +14354,7 @@
      string.prototype.trimstart: 1.0.8
      typed-array-buffer: 1.0.3
      typed-array-byte-length: 1.0.3
      typed-array-byte-offset: 1.0.4
      typed-array-byte-offset: 1.0.5
      typed-array-length: 1.0.8
      unbox-primitive: 1.1.0
      which-typed-array: 1.1.22
@@ -14771,7 +14774,7 @@
      '@codemirror/state': 6.6.0
      '@codemirror/view': 6.43.1
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.4
      '@lezer/lr': 1.4.10
      '@lezer/markdown': 1.6.4
      min-dom: 5.3.0
@@ -17384,7 +17387,7 @@
  strnum@1.0.5: {}
  style-mod@4.1.3: {}
  style-mod@4.1.4: {}
  style-search@0.1.0: {}
@@ -17782,13 +17785,12 @@
      has-proto: 1.2.0
      is-typed-array: 1.1.15
  typed-array-byte-offset@1.0.4:
  typed-array-byte-offset@1.0.5:
    dependencies:
      available-typed-arrays: 1.0.7
      call-bind: 1.0.9
      for-each: 0.3.5
      gopd: 1.2.0
      has-proto: 1.2.0
      is-typed-array: 1.1.15
      reflect.getprototypeof: 1.0.10
public/logos/JSSM.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="江苏商贸">
  <defs>
    <!-- è—é’主色:左上角略亮,右下角沉下去,形成体积感 -->
    <linearGradient id="jssmNavy" x1="0" y1="0" x2="0.85" y2="1">
      <stop offset="0%" stop-color="#1E4571"/>
      <stop offset="52%" stop-color="#123156"/>
      <stop offset="100%" stop-color="#0A1D35"/>
    </linearGradient>
    <!-- é‡‘色:上亮下深,模拟金属光泽 -->
    <linearGradient id="jssmGold" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="#EBD07E"/>
      <stop offset="45%" stop-color="#D4AF37"/>
      <stop offset="100%" stop-color="#B8901F"/>
    </linearGradient>
  </defs>
  <!-- ç›¾å½¢å¾½æ ‡ä¸»ä½“ -->
  <path d="M8 14 A6 6 0 0 1 14 8 H50 A6 6 0 0 1 56 14 V32 C56 46 45.5 55.5 32 60 C18.5 55.5 8 46 8 32 Z"
        fill="url(#jssmNavy)"/>
  <!-- å¤–圈金边:深色背景下靠它勾勒轮廓 -->
  <path d="M8 14 A6 6 0 0 1 14 8 H50 A6 6 0 0 1 56 14 V32 C56 46 45.5 55.5 32 60 C18.5 55.5 8 46 8 32 Z"
        fill="none" stroke="url(#jssmGold)" stroke-width="1.5" opacity="0.9"/>
  <!-- å†…圈金线:增加徽章的层次 -->
  <path d="M12.5 15 A3.5 3.5 0 0 1 16 11.5 H48 A3.5 3.5 0 0 1 51.5 15 V32 C51.5 44 42.5 52.5 32 56.5 C21.5 52.5 12.5 44 12.5 32 Z"
        fill="none" stroke="url(#jssmGold)" stroke-width="1" opacity="0.55"/>
  <!-- é¡¶éƒ¨å­¦æœ¯ä¹‹æ˜Ÿ -->
  <path d="M32 15.6 L33.9 19 L32 22.4 L30.1 19 Z" fill="url(#jssmGold)"/>
  <!-- å±•开的书本:左页 -->
  <path d="M31.2 29.5 C27.4 26.1 22.2 24.6 17.8 25.5 V41.4 C22.2 40.5 27.4 42 31.2 45.4 Z"
        fill="url(#jssmGold)"/>
  <!-- å±•开的书本:右页(略暗,拉开左右层次) -->
  <path d="M32.8 29.5 C36.6 26.1 41.8 24.6 46.2 25.5 V41.4 C41.8 40.5 36.6 42 32.8 45.4 Z"
        fill="url(#jssmGold)" opacity="0.78"/>
  <!-- ä¹¦è„Š -->
  <path d="M32 29.5 V45.4" stroke="#0A1D35" stroke-width="1.6" stroke-linecap="round" opacity="0.55"/>
</svg>
public/static/imgs/jssm-campus.svg
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,226 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 900" preserveAspectRatio="xMidYMid slice" role="img" aria-label="学术校园夜景">
  <defs>
    <!-- å¤œç©ºï¼šä¸Šæ·±ä¸‹æµ…的藏青 -->
    <linearGradient id="sky" x1="0" y1="0" x2="0.25" y2="1">
      <stop offset="0%" stop-color="#08182C"/>
      <stop offset="42%" stop-color="#0F2440"/>
      <stop offset="72%" stop-color="#14375F"/>
      <stop offset="100%" stop-color="#0B1C33"/>
    </linearGradient>
    <!-- æœˆå…‰å…‰æ™• -->
    <radialGradient id="moonGlow" cx="0.5" cy="0.5" r="0.5">
      <stop offset="0%" stop-color="#EBD07E" stop-opacity="0.30"/>
      <stop offset="45%" stop-color="#D4AF37" stop-opacity="0.10"/>
      <stop offset="100%" stop-color="#D4AF37" stop-opacity="0"/>
    </radialGradient>
    <!-- å»ºç­‘受光面:左亮右暗 -->
    <linearGradient id="facade" x1="0" y1="0" x2="1" y2="0.35">
      <stop offset="0%" stop-color="#1E4571"/>
      <stop offset="55%" stop-color="#16375C"/>
      <stop offset="100%" stop-color="#0E2743"/>
    </linearGradient>
    <!-- ç©¹é¡¶ -->
    <linearGradient id="dome" x1="0.15" y1="0" x2="0.85" y2="1">
      <stop offset="0%" stop-color="#2A5B8C"/>
      <stop offset="60%" stop-color="#17395E"/>
      <stop offset="100%" stop-color="#0D2340"/>
    </linearGradient>
    <!-- é‡‘色窗光 -->
    <linearGradient id="winLight" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="#EBD07E"/>
      <stop offset="100%" stop-color="#C9A227"/>
    </linearGradient>
    <!-- æ°´é¢ -->
    <linearGradient id="pool" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="#0C2036"/>
      <stop offset="100%" stop-color="#061320"/>
    </linearGradient>
    <!-- æ°´é¢é‡‘色倒影 -->
    <linearGradient id="reflect" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="#D4AF37" stop-opacity="0.22"/>
      <stop offset="100%" stop-color="#D4AF37" stop-opacity="0"/>
    </linearGradient>
    <!-- åº•部压暗,给文字留出可读区域 -->
    <linearGradient id="vignette" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="#08182C" stop-opacity="0"/>
      <stop offset="62%" stop-color="#08182C" stop-opacity="0.35"/>
      <stop offset="100%" stop-color="#08182C" stop-opacity="0.85"/>
    </linearGradient>
  </defs>
  <!-- ===== å¤œç©º ===== -->
  <rect width="900" height="900" fill="url(#sky)"/>
  <!-- æœˆäº®ä¸Žå…‰æ™• -->
  <circle cx="686" cy="176" r="210" fill="url(#moonGlow)"/>
  <circle cx="686" cy="176" r="46" fill="#EBD07E" opacity="0.92"/>
  <circle cx="686" cy="176" r="46" fill="none" stroke="#F5E7C0" stroke-width="1" opacity="0.5"/>
  <!-- æœˆé¢æš—部,让它不是纯色圆 -->
  <circle cx="672" cy="164" r="38" fill="#D4AF37" opacity="0.18"/>
  <circle cx="700" cy="188" r="14" fill="#08182C" opacity="0.12"/>
  <circle cx="672" cy="196" r="9" fill="#08182C" opacity="0.10"/>
  <!-- æ˜Ÿç‚¹ -->
  <g fill="#EBD07E">
    <circle cx="120" cy="96" r="1.8" opacity="0.55"/>
    <circle cx="238" cy="152" r="1.3" opacity="0.42"/>
    <circle cx="352" cy="82" r="2.1" opacity="0.6"/>
    <circle cx="470" cy="134" r="1.4" opacity="0.4"/>
    <circle cx="560" cy="72" r="1.7" opacity="0.5"/>
    <circle cx="800" cy="118" r="1.5" opacity="0.45"/>
    <circle cx="176" cy="228" r="1.2" opacity="0.32"/>
    <circle cx="420" cy="206" r="1.6" opacity="0.44"/>
    <circle cx="742" cy="268" r="1.3" opacity="0.34"/>
    <circle cx="86" cy="330" r="1.5" opacity="0.3"/>
    <circle cx="598" cy="184" r="1.1" opacity="0.3"/>
    <circle cx="300" cy="286" r="1.2" opacity="0.28"/>
  </g>
  <!-- ===== è¿œå±± ===== -->
  <path d="M0 556 L118 484 L214 534 L322 452 L430 528 L548 470 L662 540 L784 486 L900 552 L900 900 L0 900 Z"
        fill="#0B1E36" opacity="0.75"/>
  <!-- ===== è¿œæ™¯æ¥¼ç¾¤ ===== -->
  <g fill="#0D2440" opacity="0.85">
    <rect x="42" y="520" width="46" height="72"/>
    <rect x="98" y="546" width="34" height="46"/>
    <rect x="748" y="530" width="42" height="62"/>
    <rect x="800" y="556" width="30" height="36"/>
  </g>
  <!-- ===== ä¸»æ¥¼ï¼šå°é˜¶ ===== -->
  <g>
    <rect x="238" y="742" width="424" height="13" fill="#132E4E"/>
    <rect x="256" y="729" width="388" height="13" fill="#17395E"/>
    <rect x="274" y="716" width="352" height="13" fill="#1B4168"/>
  </g>
  <!-- ===== ä¸»æ¥¼ï¼šæŸ±å»Š ===== -->
  <rect x="286" y="560" width="328" height="156" fill="url(#facade)"/>
  <!-- ===== å±‹é¡¶å±±èŠ±ï¼ˆä¸‰è§’æ¥£ï¼‰ ===== -->
  <path d="M286 560 L450 470 L614 560 Z" fill="#17395E"/>
  <path d="M286 560 L450 470 L614 560 Z" fill="none" stroke="#D4AF37" stroke-width="2" opacity="0.55"/>
  <rect x="272" y="556" width="356" height="12" fill="#1B4168"/>
  <!-- å±±èŠ±å†…çš„åœ†å¾½ -->
  <circle cx="450" cy="528" r="24" fill="#0E2743" stroke="#D4AF37" stroke-width="1.6" opacity="0.9"/>
  <circle cx="450" cy="528" r="13" fill="none" stroke="#D4AF37" stroke-width="1.2" opacity="0.65"/>
  <circle cx="450" cy="528" r="4.5" fill="#D4AF37" opacity="0.85"/>
  <!-- ===== ç©¹é¡¶ ===== -->
  <path d="M450 470 A62 62 0 0 1 512 470 Z" fill="url(#dome)" transform="translate(-62,0)"/>
  <path d="M388 470 A62 62 0 0 1 512 470 Z" fill="url(#dome)"/>
  <path d="M388 470 A62 62 0 0 1 512 470" fill="none" stroke="#D4AF37" stroke-width="1.6" opacity="0.6"/>
  <rect x="444" y="382" width="12" height="28" fill="#17395E"/>
  <circle cx="450" cy="376" r="8" fill="#D4AF37" opacity="0.9"/>
  <!-- å¡”尖光点 -->
  <circle cx="450" cy="376" r="18" fill="#EBD07E" opacity="0.16"/>
  <!-- ===== å…­æ ¹ç«‹æŸ± ===== -->
  <g fill="#1B4168">
    <rect x="302" y="578" width="26" height="138"/>
    <rect x="356" y="578" width="26" height="138"/>
    <rect x="410" y="578" width="26" height="138"/>
    <rect x="464" y="578" width="26" height="138"/>
    <rect x="518" y="578" width="26" height="138"/>
    <rect x="572" y="578" width="26" height="138"/>
  </g>
  <!-- æŸ±å¤´ä¸ŽæŸ±ç¡€ -->
  <g fill="#24507C">
    <rect x="298" y="572" width="34" height="10"/>
    <rect x="352" y="572" width="34" height="10"/>
    <rect x="406" y="572" width="34" height="10"/>
    <rect x="460" y="572" width="34" height="10"/>
    <rect x="514" y="572" width="34" height="10"/>
    <rect x="568" y="572" width="34" height="10"/>
    <rect x="300" y="708" width="30" height="10"/>
    <rect x="354" y="708" width="30" height="10"/>
    <rect x="408" y="708" width="30" height="10"/>
    <rect x="462" y="708" width="30" height="10"/>
    <rect x="516" y="708" width="30" height="10"/>
    <rect x="570" y="708" width="30" height="10"/>
  </g>
  <!-- ===== æŸ±é—´çª—å…‰ ===== -->
  <g fill="url(#winLight)">
    <rect x="332" y="612" width="20" height="34" opacity="0.82"/>
    <rect x="386" y="612" width="20" height="34" opacity="0.62"/>
    <rect x="440" y="612" width="20" height="34" opacity="0.9"/>
    <rect x="494" y="612" width="20" height="34" opacity="0.58"/>
    <rect x="548" y="612" width="20" height="34" opacity="0.78"/>
  </g>
  <g fill="url(#winLight)">
    <rect x="332" y="662" width="20" height="30" opacity="0.55"/>
    <rect x="386" y="662" width="20" height="30" opacity="0.72"/>
    <rect x="440" y="662" width="20" height="30" opacity="0.6"/>
    <rect x="494" y="662" width="20" height="30" opacity="0.8"/>
    <rect x="548" y="662" width="20" height="30" opacity="0.5"/>
  </g>
  <!-- ä¸­å¤®å¤§é—¨ -->
  <path d="M432 716 L432 676 A18 18 0 0 1 468 676 L468 716 Z" fill="#0E2743"/>
  <path d="M432 716 L432 676 A18 18 0 0 1 468 676 L468 716" fill="none" stroke="#D4AF37" stroke-width="1.4" opacity="0.6"/>
  <rect x="448" y="676" width="1.6" height="40" fill="#D4AF37" opacity="0.45"/>
  <!-- é—¨å‰çš„两盏壁灯 -->
  <circle cx="414" cy="700" r="5" fill="#EBD07E" opacity="0.9"/>
  <circle cx="414" cy="700" r="14" fill="#EBD07E" opacity="0.16"/>
  <circle cx="486" cy="700" r="5" fill="#EBD07E" opacity="0.9"/>
  <circle cx="486" cy="700" r="14" fill="#EBD07E" opacity="0.16"/>
  <!-- ===== ä¸¤ä¾§æ ‘木 ===== -->
  <g fill="#0A1B2F">
    <!-- å·¦æ ‘ -->
    <rect x="146" y="700" width="10" height="60"/>
    <ellipse cx="151" cy="666" rx="52" ry="42"/>
    <ellipse cx="120" cy="690" rx="34" ry="28"/>
    <ellipse cx="182" cy="688" rx="30" ry="26"/>
    <!-- å³æ ‘ -->
    <rect x="742" y="704" width="10" height="56"/>
    <ellipse cx="747" cy="672" rx="48" ry="40"/>
    <ellipse cx="718" cy="694" rx="32" ry="26"/>
    <ellipse cx="776" cy="692" rx="28" ry="24"/>
  </g>
  <!-- æ ‘冠受光边 -->
  <g fill="none" stroke="#D4AF37" stroke-width="1.1" opacity="0.18">
    <ellipse cx="151" cy="666" rx="52" ry="42"/>
    <ellipse cx="747" cy="672" rx="48" ry="40"/>
  </g>
  <!-- ===== å‰æ™¯æ°´é¢ ===== -->
  <rect x="0" y="755" width="900" height="145" fill="url(#pool)"/>
  <rect x="0" y="755" width="900" height="1.6" fill="#D4AF37" opacity="0.22"/>
  <!-- å»ºç­‘在水面的倒影 -->
  <g opacity="0.5">
    <rect x="300" y="758" width="300" height="66" fill="url(#facade)" opacity="0.35"/>
    <rect x="330" y="758" width="18" height="52" fill="#D4AF37" opacity="0.16"/>
    <rect x="384" y="758" width="18" height="52" fill="#D4AF37" opacity="0.12"/>
    <rect x="438" y="758" width="18" height="52" fill="#D4AF37" opacity="0.2"/>
    <rect x="492" y="758" width="18" height="52" fill="#D4AF37" opacity="0.12"/>
    <rect x="546" y="758" width="18" height="52" fill="#D4AF37" opacity="0.16"/>
  </g>
  <rect x="0" y="755" width="900" height="120" fill="url(#reflect)"/>
  <!-- æ°´é¢æ³¢çº¹ -->
  <g stroke="#EBD07E" stroke-width="1.2" opacity="0.16" stroke-linecap="round">
    <line x1="286" y1="792" x2="356" y2="792"/>
    <line x1="404" y1="806" x2="490" y2="806"/>
    <line x1="520" y1="786" x2="596" y2="786"/>
    <line x1="330" y1="826" x2="420" y2="826"/>
    <line x1="470" y1="842" x2="556" y2="842"/>
    <line x1="180" y1="812" x2="238" y2="812"/>
    <line x1="660" y1="820" x2="726" y2="820"/>
  </g>
  <!-- ===== åº•部压暗 ===== -->
  <rect width="900" height="900" fill="url(#vignette)"/>
</svg>
src/api/archive/borrow/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ArchiveBorrowApi {
  /** å€Ÿé˜…单 */
  export interface Borrow {
    id?: number; // å€Ÿé˜…单编号
    no?: string; // å€Ÿé˜…单号(后端生成:JY+yyyyMMdd+6位流水,只读)
    documentId: number; // æ¡£æ¡ˆ ID
    documentNo?: string; // æ¡£æ¡ˆç¼–号(快照)
    documentTitle?: string; // æ¡£æ¡ˆæ ‡é¢˜ï¼ˆå¿«ç…§ï¼‰
    borrowerUserId?: number; // å€Ÿé˜…人用户 ID
    borrowerName: string; // å€Ÿé˜…人姓名
    borrowerDept?: string; // å€Ÿé˜…人部门
    borrowerPhone?: string; // å€Ÿé˜…人联系电话
    borrowDate: string; // å€Ÿé˜…日期
    borrowPurpose?: string; // å€Ÿé˜…目的
    returnDeadline: string; // å½’还期限
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ archive_borrow_status
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ ID
    approveTime?: string; // å®¡æ‰¹é€šè¿‡ï¼ˆå®žé™…借出)时间
    returnTime?: string; // å®žé™…归还时间
    returnConfirmUserId?: number; // å½’还确认人用户 ID
    returnConfirmUserName?: string; // å½’还确认人姓名
    returnRemark?: string; // å½’还备注
    rejectReason?: string; // é©³å›žåŽŸå› 
    rejectTime?: string; // é©³å›žæ—¶é—´
    remark?: string; // å¤‡æ³¨
    overdue?: boolean; // æ˜¯å¦é€¾æœŸï¼ˆåŽç«¯å®žæ—¶è®¡ç®—)
    overdueDays?: number; // é€¾æœŸå¤©æ•°ï¼ˆåŽç«¯å®žæ—¶è®¡ç®—)
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
  /** å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ProcessDefinition {
    id: string;
    key: string;
    name: string;
  }
  /** å½’还登记请求 */
  export interface ReturnReq {
    id: number; // å€Ÿé˜…单编号
    returnRemark?: string; // å½’还备注
  }
}
/** æŸ¥è¯¢å€Ÿé˜…单分页 */
export function getBorrowPage(params: PageParam) {
  return requestClient.get<PageResult<ArchiveBorrowApi.Borrow>>(
    '/archive/borrow/page',
    { params },
  );
}
/** æŸ¥è¯¢é€¾æœŸæœªè¿˜å€Ÿé˜…单分页 */
export function getOverdueBorrowPage(params: PageParam) {
  return requestClient.get<PageResult<ArchiveBorrowApi.Borrow>>(
    '/archive/borrow/overdue-page',
    { params },
  );
}
/** æŸ¥è¯¢å€Ÿé˜…单详情 */
export function getBorrow(id: number) {
  return requestClient.get<ArchiveBorrowApi.Borrow>(
    `/archive/borrow/get?id=${id}`,
  );
}
/** æ–°å¢žå€Ÿé˜…单 */
export function createBorrow(data: ArchiveBorrowApi.Borrow) {
  return requestClient.post('/archive/borrow/create', data);
}
/** ä¿®æ”¹å€Ÿé˜…单 */
export function updateBorrow(data: ArchiveBorrowApi.Borrow) {
  return requestClient.put('/archive/borrow/update', data);
}
/** åˆ é™¤å€Ÿé˜…单 */
export function deleteBorrow(id: number) {
  return requestClient.delete('/archive/borrow/delete', {
    params: { id },
  });
}
/** æäº¤å€Ÿé˜…单审批 */
export function submitBorrow(id: number, processDefinitionKey?: string) {
  return requestClient.post('/archive/borrow/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–æ¡£æ¡ˆå€Ÿé˜…å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getBorrowApproveProcessList() {
  return requestClient.get<ArchiveBorrowApi.ProcessDefinition[]>(
    '/archive/borrow/approve-process-list',
  );
}
/** å½’还登记 */
export function returnBorrow(data: ArchiveBorrowApi.ReturnReq) {
  return requestClient.post('/archive/borrow/return', data);
}
src/api/archive/category/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
import { requestClient } from '#/api/request';
export namespace ArchiveCategoryApi {
  /** æ¡£æ¡ˆåˆ†ç±»ï¼ˆä¹¦æž¶/分类树) */
  export interface Category {
    id?: number; // åˆ†ç±»ç¼–号
    parentId?: number; // çˆ¶åˆ†ç±» ID,0=顶级
    name?: string; // åˆ†ç±»åç§°
    code?: string; // åˆ†ç±»ç¼–码,唯一(member/research/meeting/finance/document/party)
    sort?: number; // æ˜¾ç¤ºæŽ’序
    description?: string; // åˆ†ç±»è¯´æ˜Ž
    defaultSecretLevel?: number; // è¯¥åˆ†ç±»ä¸‹æ¡£æ¡ˆé»˜è®¤å¯†çº§ï¼Œå­—å…¸ archive_secret_level
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ archive_category_status
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    children?: Category[]; // å­åˆ†ç±»ï¼ˆæ ‘形返回)
  }
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†ç±»åˆ—表(平铺) */
export function getCategoryList(params?: ArchiveCategoryApi.Category) {
  return requestClient.get<ArchiveCategoryApi.Category[]>(
    '/archive/category/list',
    { params },
  );
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†ç±»æ ‘ */
export function getCategoryTree() {
  return requestClient.get<ArchiveCategoryApi.Category[]>(
    '/archive/category/tree',
  );
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†ç±»è¯¦æƒ… */
export function getCategory(id: number) {
  return requestClient.get<ArchiveCategoryApi.Category>(
    `/archive/category/get?id=${id}`,
  );
}
/** æ–°å¢žæ¡£æ¡ˆåˆ†ç±» */
export function createCategory(data: ArchiveCategoryApi.Category) {
  return requestClient.post('/archive/category/create', data);
}
/** ä¿®æ”¹æ¡£æ¡ˆåˆ†ç±» */
export function updateCategory(data: ArchiveCategoryApi.Category) {
  return requestClient.put('/archive/category/update', data);
}
/** åˆ é™¤æ¡£æ¡ˆåˆ†ç±» */
export function deleteCategory(id: number) {
  return requestClient.delete('/archive/category/delete', {
    params: { id },
  });
}
src/api/archive/document/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace ArchiveDocumentApi {
  /** æ¡£æ¡ˆï¼ˆç”µå­é™„ä»¶ + å®žä½“存放位置) */
  export interface Document {
    id?: number; // æ¡£æ¡ˆç¼–号
    no?: string; // æ¡£æ¡ˆç¼–号(后端生成:DA+yyyyMMdd+6位流水,只读)
    title: string; // æ¡£æ¡ˆæ ‡é¢˜
    categoryId: number; // æ¡£æ¡ˆåˆ†ç±» ID
    categoryName?: string; // åˆ†ç±»åç§°ï¼ˆåŽç«¯å†—余)
    secretLevel?: number; // å¯†çº§ï¼Œå­—å…¸ archive_secret_level
    carrierType?: number; // è½½ä½“,字典 archive_carrier_type
    shelfNo?: string; // å®žä½“-书架号
    layerNo?: string; // å®žä½“-层号
    columnNo?: string; // å®žä½“-列号
    boxNo?: string; // å®žä½“-册号/档案盒号
    storePlace?: string; // å®žä½“存放位置描述
    sourceType?: number; // æ¥æºï¼Œå­—å…¸ archive_source_type(后端写,前端只读)
    sourceNo?: string; // æ¥æºå•号(立项编号/会员编号/报销单号)
    sourceId?: number; // æ¥æºå•据 ID
    sourceTitle?: string; // æ¥æºå•据标题
    archiveDate?: string; // å½’档日期
    archivistUserId?: number; // å½’档人用户 ID
    archivistName?: string; // å½’档人姓名
    status?: number; // æ¡£æ¡ˆçŠ¶æ€ï¼Œå­—å…¸ archive_document_status
    summary?: string; // æ‘˜è¦
    tags?: string; // å…³é”®è¯ï¼Œè‹±æ–‡é€—号分隔
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰é™„ä»¶
    blobIds?: number[]; // æäº¤æ—¶çš„附件 ID åˆ—表(全量)
  }
}
/** æŸ¥è¯¢æ¡£æ¡ˆåˆ†é¡µ */
export function getDocumentPage(params: PageParam) {
  return requestClient.get<PageResult<ArchiveDocumentApi.Document>>(
    '/archive/document/page',
    { params },
  );
}
/** æŸ¥è¯¢æ¡£æ¡ˆè¯¦æƒ… */
export function getDocument(id: number) {
  return requestClient.get<ArchiveDocumentApi.Document>(
    `/archive/document/get?id=${id}`,
  );
}
/** æ–°å¢žæ¡£æ¡ˆ */
export function createDocument(data: ArchiveDocumentApi.Document) {
  return requestClient.post('/archive/document/create', data);
}
/** ä¿®æ”¹æ¡£æ¡ˆ */
export function updateDocument(data: ArchiveDocumentApi.Document) {
  return requestClient.put('/archive/document/update', data);
}
/** åˆ é™¤æ¡£æ¡ˆ */
export function deleteDocument(id: number) {
  return requestClient.delete('/archive/document/delete', {
    params: { id },
  });
}
/** å¯¼å‡ºæ¡£æ¡ˆ Excel */
export function exportDocument(params: ArchiveDocumentApi.Document) {
  return requestClient.download('/archive/document/export-excel', { params });
}
src/api/archive/stat/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import { requestClient } from '#/api/request';
export namespace ArchiveStatApi {
  /** åˆ†ç±»ç»´åº¦ç»Ÿè®¡é¡¹ */
  export interface StatCategory {
    categoryId?: number; // åˆ†ç±» ID
    name?: string; // åˆ†ç±»åç§°
    total?: number; // æ¡£æ¡ˆæ•°é‡
  }
  /** å­—典值维度统计项 */
  export interface StatValue {
    value?: number; // å­—典值
    name?: string; // å­—典标签(后端转义)
    total?: number; // æ¡£æ¡ˆæ•°é‡
  }
  /** æ¡£æ¡ˆç»Ÿè®¡æ¦‚览 */
  export interface StatOverview {
    totalCount?: number; // æ¡£æ¡ˆæ€»æ•°
    inStockCount?: number; // åœ¨åº“档案数
    borrowedCount?: number; // å€Ÿå‡ºä¸­æ¡£æ¡ˆæ•°
    borrowingCount?: number; // å€Ÿå‡ºä¸­å€Ÿé˜…单数
    overdueCount?: number; // é€¾æœŸæœªè¿˜å€Ÿé˜…单数
    byCategory?: StatCategory[]; // æŒ‰åˆ†ç±»åˆ†å¸ƒ
    bySecretLevel?: StatValue[]; // æŒ‰å¯†çº§åˆ†å¸ƒ
    byCarrierType?: StatValue[]; // æŒ‰è½½ä½“分布
  }
}
/** æŸ¥è¯¢æ¡£æ¡ˆç»Ÿè®¡æ¦‚览 */
export function getArchiveStatOverview() {
  return requestClient.get<ArchiveStatApi.StatOverview>(
    '/archive/stat/overview',
  );
}
src/api/finance/reimbursement/index.ts
@@ -29,6 +29,8 @@
    applyDate: string; // ç”³è¯·æ—¥æœŸ yyyy-MM-dd
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ ID
    archiveTime?: string; // å½’档时间
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已生成档案)
    archived?: boolean; // æ˜¯å¦å·²ç”Ÿæˆæ¡£æ¡ˆ
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
@@ -91,9 +93,9 @@
  });
}
/** å½’档报销单(仅已通过可归档) */
/** å½’档报销单(仅已通过可归档),返回生成的档案编号 */
export function archiveReimbursement(id: number) {
  return requestClient.post('/finance/reimbursement/archive', null, {
  return requestClient.post<string>('/finance/reimbursement/archive', null, {
    params: { id },
  });
}
src/api/research/application/index.ts
@@ -30,6 +30,8 @@
    researchNo?: string; // ç«‹é¡¹ç¼–号,审批通过后生成
    approveTime?: string; // ç«‹é¡¹æ—¶é—´
    noticeId?: number; // å·²å‘布的立项通知公告 ID
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已归档)
    archived?: boolean; // æ˜¯å¦å·²å½’æ¡£
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
@@ -120,6 +122,13 @@
  });
}
/** å½’档到档案管理(仅已立项可归档),返回生成的档案编号 */
export function archiveApplication(id: number) {
  return requestClient.post<string>('/research/application/archive', null, {
    params: { id },
  });
}
/** æŸ¥è¯¢æŸç”³æŠ¥ä¹¦çš„专家评审明细列表 */
export function getTopicReviewList(applicationId: number) {
  return requestClient.get<ResearchApplicationApi.TopicReview[]>(
src/api/research/fund/index.ts
@@ -11,7 +11,11 @@
    amount: number; // æ‹¨ä»˜é‡‘额(元)
    payDate?: string; // æ‹¨ä»˜æ—¥æœŸ
    payee?: string; // æ”¶æ¬¾å•位/账户名
    status?: number; // æ‹¨ä»˜çŠ¶æ€ï¼Œå­—å…¸ research_fund_status
    status?: number; // æ‹¨ä»˜çŠ¶æ€ï¼Œå­—å…¸ research_fund_status(0-待拨付 15-报销审批中 20-已拨付 30-已驳回)
    reimbursementId?: number; // å…³è”财务报销单 ID
    reimbursementNo?: string; // å…³è”财务报销单号
    rejectReason?: string; // è´¢åŠ¡æŠ¥é”€é©³å›žåŽŸå› ï¼ˆBPM å›žå†™ï¼‰
    rejectTime?: string; // è´¢åŠ¡æŠ¥é”€é©³å›žæ—¶é—´
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
@@ -30,12 +34,12 @@
  );
}
/** æ–°å¢žç»è´¹æ‹¨ä»˜è®°å½• */
/** ç™»è®°ç»è´¹æ‹¨ä»˜è®°å½•(登记后自动生成并提交财务报销审批) */
export function createFundRecord(data: ResearchFundRecordApi.FundRecord) {
  return requestClient.post('/research/fund-record/create', data);
}
/** ä¿®æ”¹ç»è´¹æ‹¨ä»˜è®°å½•(仅待拨付可改) */
/** ä¿®æ”¹å¹¶é‡æ–°å‘起报销(仅「待拨付」「已驳回」可改) */
export function updateFundRecord(data: ResearchFundRecordApi.FundRecord) {
  return requestClient.put('/research/fund-record/update', data);
}
src/api/ums/member/index.ts
@@ -29,6 +29,8 @@
    joinDate?: string; // å…¥ä¼šæ—¥æœŸ
    expireDate?: string; // æœ‰æ•ˆæœŸè‡³
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ ums_member_status
    archiveDocumentId?: number; // å½’档生成的档案 ID(非空即已归档)
    archived?: boolean; // æ˜¯å¦å·²å½’æ¡£
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
@@ -71,4 +73,11 @@
  return requestClient.delete('/ums/member/delete', {
    params: { id },
  });
}
/** å½’档到档案管理(仅正常状态会员可归档),返回生成的档案编号 */
export function archiveMember(id: number) {
  return requestClient.post<string>('/ums/member/archive', null, {
    params: { id },
  });
}
src/api/wxcp/activity.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,254 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WxCpActivityApi {
  /** æ´»åŠ¨çŠ¶æ€ï¼š0草稿 1已发布 2已结束 3已取消 */
  export const ACTIVITY_STATUS = {
    DRAFT: 0,
    PUBLISHED: 1,
    FINISHED: 2,
    CANCELED: 3,
  } as const;
  /** æŽ¨é€èŒƒå›´ï¼š0全员 1指定部门 2指定成员 */
  export const PUSH_SCOPE = {
    ALL: 0,
    PARTY: 1,
    USER: 2,
  } as const;
  /** æ´»åЍ */
  export interface Activity {
    id?: number;
    title?: string;
    content?: string;
    coverUrl?: string;
    location?: string;
    startTime?: string;
    endTime?: string;
    signupStartTime?: string;
    signupEndTime?: string;
    /** æŠ¥åä¸Šé™ï¼Œ0 è¡¨ç¤ºä¸é™ */
    signupLimit?: number;
    signupCount?: number;
    status?: number;
    statusName?: string;
    pushScope?: number;
    pushScopeName?: string;
    pushTarget?: string;
    pushStatus?: number;
    pushTime?: string;
    remark?: string;
    createTime?: string;
  }
  /** æ´»åŠ¨åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface ActivityPageParams extends PageParam {
    title?: string;
    status?: number;
    pushStatus?: number;
    startTime?: [string, string];
  }
  /** æŠ¥åè®°å½• */
  export interface Signup {
    id?: number;
    activityId?: number;
    activityTitle?: string;
    wxUserId?: string;
    name?: string;
    mobile?: string;
    deptName?: string;
    content?: string;
    /** çŠ¶æ€ï¼š0已报名 1已取消 2已签到 */
    status?: number;
    statusName?: string;
    signupTime?: string;
    checkInTime?: string;
    remark?: string;
    createTime?: string;
  }
  /** æŠ¥åè®°å½•分页查询参数 */
  export interface SignupPageParams extends PageParam {
    activityId?: number;
    name?: string;
    mobile?: string;
    status?: number;
  }
  /** åç§°-数量统计项 */
  export interface NameCount {
    name: string;
    count: number;
  }
  /** æŠ¥åç»Ÿè®¡ */
  export interface ActivityStats {
    activityId: number;
    activityTitle?: string;
    signupLimit?: number;
    totalSignup: number;
    signedUpCount: number;
    checkedInCount: number;
    canceledCount: number;
    /** ä¸é™åé¢æ—¶ä¸º null */
    remaining?: null | number;
    signupPercent?: null | number;
    checkInPercent: number;
    deptStats: NameCount[];
    dateStats: NameCount[];
    sourceStats: NameCount[];
  }
  /** æŽ¨é€è¯·æ±‚ */
  export interface PushParams {
    id: number;
    pushScope?: number;
    pushTarget?: string;
  }
  /** æŽ¨é€ç»“æžœ */
  export interface PushResult {
    success: boolean;
    errCode?: null | number;
    message?: string;
    toUser?: string;
    toParty?: string;
  }
  /** æŠ¥åé™„ä»¶ */
  export interface SignupAttachment {
    id: number;
    name?: string;
    originalFilename?: string;
    contentType?: string;
    byteSize?: number;
    url?: string;
    previewURL?: string;
    downloadURL?: string;
  }
}
/** èŽ·å¾—æ´»åŠ¨åˆ†é¡µ */
export function getActivityPage(params: WxCpActivityApi.ActivityPageParams) {
  return requestClient.get<PageResult<WxCpActivityApi.Activity>>(
    '/wxcp/activity/page',
    { params },
  );
}
/** èŽ·å¾—æ´»åŠ¨è¯¦æƒ… */
export function getActivity(id: number) {
  return requestClient.get<WxCpActivityApi.Activity>(
    '/wxcp/activity/get',
    { params: { id } },
  );
}
/** åˆ›å»ºæ´»åŠ¨ï¼ˆå­˜ä¸ºè‰ç¨¿ï¼‰ */
export function createActivity(data: WxCpActivityApi.Activity) {
  return requestClient.post<number>('/wxcp/activity/create', data);
}
/** ä¿®æ”¹æ´»åЍ */
export function updateActivity(data: WxCpActivityApi.Activity) {
  return requestClient.put<boolean>('/wxcp/activity/update', data);
}
/** åˆ é™¤æ´»åЍ */
export function deleteActivity(id: number) {
  return requestClient.delete<boolean>('/wxcp/activity/delete', {
    params: { id },
  });
}
/** å‘布活动 */
export function publishActivity(id: number) {
  return requestClient.put<boolean>('/wxcp/activity/publish', null, {
    params: { id },
  });
}
/** ç»“束活动 */
export function finishActivity(id: number) {
  return requestClient.put<boolean>('/wxcp/activity/finish', null, {
    params: { id },
  });
}
/** å–消活动 */
export function cancelActivity(id: number) {
  return requestClient.put<boolean>('/wxcp/activity/cancel', null, {
    params: { id },
  });
}
/** æŽ¨é€æ´»åŠ¨ç»™ä¼ä¸šå¾®ä¿¡æˆå‘˜ */
export function pushActivity(data: WxCpActivityApi.PushParams) {
  return requestClient.post<WxCpActivityApi.PushResult>(
    '/wxcp/activity/push',
    data,
  );
}
/** èŽ·å¾—æ´»åŠ¨æŠ¥åé¡µåœ°å€ */
export function getActivitySignupUrl(id: number) {
  return requestClient.get<string>('/wxcp/activity/signup-url', {
    params: { id },
  });
}
/**
 * èŽ·å¾—æ´»åŠ¨æŠ¥åäºŒç»´ç ï¼ˆPNG Blob)
 *
 * è¯¥æŽ¥å£è¿”回图片二进制,而 <img src> æ— æ³•携带请求头,
 * æ‰€ä»¥è¿™é‡Œç”¨ raw æ¨¡å¼å–回 Blob,再由调用方转成 objectURL æ˜¾ç¤º/下载。
 */
export function getActivityQrCodeBlob(id: number, size?: number) {
  return requestClient.request('/wxcp/activity/qrcode', {
    method: 'GET',
    params: { id, size },
    responseType: 'blob',
    responseReturn: 'raw',
  });
}
/** èŽ·å¾—æ´»åŠ¨æŠ¥åç»Ÿè®¡ */
export function getActivityStats(id: number) {
  return requestClient.get<WxCpActivityApi.ActivityStats>(
    '/wxcp/activity/stats',
    { params: { id } },
  );
}
/** èŽ·å¾—æŠ¥åè®°å½•åˆ†é¡µ */
export function getSignupPage(params: WxCpActivityApi.SignupPageParams) {
  return requestClient.get<PageResult<WxCpActivityApi.Signup>>(
    '/wxcp/activity/signup/page',
    { params },
  );
}
/** å–消报名 */
export function cancelSignup(id: number) {
  return requestClient.put<boolean>('/wxcp/activity/signup/cancel', null, {
    params: { id },
  });
}
/** æŠ¥åç­¾åˆ° */
export function checkInSignup(id: number) {
  return requestClient.put<boolean>('/wxcp/activity/signup/check-in', null, {
    params: { id },
  });
}
/** èŽ·å¾—æŸæ¡æŠ¥åè®°å½•ä¸Šä¼ çš„å›¾ç‰‡/附件 */
export function getSignupAttachments(signupId: number) {
  return requestClient.get<WxCpActivityApi.SignupAttachment[]>(
    '/wxcp/activity/signup/attachments',
    { params: { signupId } },
  );
}
src/api/wxcp/member.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
import { requestClient } from '#/api/request';
export namespace WxCpMemberApi {
  /** ä¼ä¸šå¾®ä¿¡éƒ¨é—¨ */
  export interface Department {
    id: number;
    name?: string;
    parentId?: number;
    order?: number;
    children?: Department[];
  }
  /** ä¼ä¸šå¾®ä¿¡æˆå‘˜ */
  export interface Member {
    /** æˆå‘˜è´¦å·ï¼ŒæŽ¨é€æ—¶ä½œä¸º pushTarget ä½¿ç”¨ */
    userId: string;
    name?: string;
    mobile?: string;
    email?: string;
    avatar?: string;
    position?: string;
    /** 0未定义 1男 2女 */
    gender?: string;
    /** 1已启用 2已禁用 4未激活 5退出企业 */
    status?: number;
    departmentIds?: number[];
    departmentNames?: string;
  }
}
/**
 * èŽ·å¾—éƒ¨é—¨æ ‘
 *
 * æ³¨æ„ï¼šè¯¥æŽ¥å£å®žæ—¶è°ƒç”¨ä¼ä¸šå¾®ä¿¡ï¼Œæœ‰é¢‘率限制,调用方需自行缓存,
 * ä¸è¦åœ¨è¾“入框里每敲一个字就请求一次。
 */
export function getWxCpDepartmentTree() {
  return requestClient.get<WxCpMemberApi.Department[]>(
    '/wxcp/member/department/tree',
  );
}
/** èŽ·å¾—éƒ¨é—¨å¹³é“ºåˆ—è¡¨ï¼ˆåšä¸‹æ‹‰é€‰æ‹©æ›´æ–¹ä¾¿ï¼‰ */
export function getWxCpDepartmentList() {
  return requestClient.get<WxCpMemberApi.Department[]>(
    '/wxcp/member/department/list',
  );
}
/** èŽ·å¾—ä¼ä¸šå¾®ä¿¡æˆå‘˜åˆ—è¡¨ */
export function getWxCpMemberList(params?: {
  departmentId?: number;
  fetchChild?: boolean;
}) {
  return requestClient.get<WxCpMemberApi.Member[]>('/wxcp/member/list', {
    params,
  });
}
src/api/wxcp/notice.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WxCpNoticeApi {
  /** é€šçŸ¥çŠ¶æ€ï¼š0草稿 1已发布 2已撤回 */
  export const NOTICE_STATUS = {
    DRAFT: 0,
    PUBLISHED: 1,
    REVOKED: 2,
  } as const;
  /** é€šçŸ¥ */
  export interface Notice {
    id?: number;
    title?: string;
    content?: string;
    status?: number;
    statusName?: string;
    pushScope?: number;
    pushScopeName?: string;
    pushTarget?: string;
    pushStatus?: number;
    pushTime?: string;
    remark?: string;
    createTime?: string;
  }
  /** é€šçŸ¥åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface NoticePageParams extends PageParam {
    title?: string;
    status?: number;
    pushStatus?: number;
  }
}
/** èŽ·å¾—é€šçŸ¥åˆ†é¡µ */
export function getNoticePage(params: WxCpNoticeApi.NoticePageParams) {
  return requestClient.get<PageResult<WxCpNoticeApi.Notice>>(
    '/wxcp/notice/page',
    { params },
  );
}
/** èŽ·å¾—é€šçŸ¥è¯¦æƒ… */
export function getNotice(id: number) {
  return requestClient.get<WxCpNoticeApi.Notice>('/wxcp/notice/get', {
    params: { id },
  });
}
/** åˆ›å»ºé€šçŸ¥ï¼ˆå­˜ä¸ºè‰ç¨¿ï¼‰ */
export function createNotice(data: WxCpNoticeApi.Notice) {
  return requestClient.post<number>('/wxcp/notice/create', data);
}
/** ä¿®æ”¹é€šçŸ¥ */
export function updateNotice(data: WxCpNoticeApi.Notice) {
  return requestClient.put<boolean>('/wxcp/notice/update', data);
}
/** åˆ é™¤é€šçŸ¥ */
export function deleteNotice(id: number) {
  return requestClient.delete<boolean>('/wxcp/notice/delete', {
    params: { id },
  });
}
/** å‘布通知 */
export function publishNotice(id: number) {
  return requestClient.put<boolean>('/wxcp/notice/publish', null, {
    params: { id },
  });
}
/** æ’¤å›žé€šçŸ¥ */
export function revokeNotice(id: number) {
  return requestClient.put<boolean>('/wxcp/notice/revoke', null, {
    params: { id },
  });
}
/** æŽ¨é€é€šçŸ¥ç»™ä¼ä¸šå¾®ä¿¡æˆå‘˜ */
export function pushNotice(data: {
  id: number;
  pushScope?: number;
  pushTarget?: string;
}) {
  return requestClient.post<{
    success: boolean;
    errCode?: null | number;
    message?: string;
  }>('/wxcp/notice/push', data);
}
src/packages/constants/src/dict-enum.ts
@@ -169,6 +169,16 @@
  RESEARCH_FUND_STATUS: 'research_fund_status', // ç»è´¹æ‹¨ä»˜çŠ¶æ€
} as const;
/** ========== ARCHIVE - æ¡£æ¡ˆç®¡ç†æ¨¡å— ========== */
const ARCHIVE_DICT = {
  ARCHIVE_CATEGORY_STATUS: 'archive_category_status', // æ¡£æ¡ˆåˆ†ç±»å¯ç”¨/停用
  ARCHIVE_DOCUMENT_STATUS: 'archive_document_status', // æ¡£æ¡ˆçŠ¶æ€ï¼ˆåœ¨åº“/借出中)
  ARCHIVE_SECRET_LEVEL: 'archive_secret_level', // æ¡£æ¡ˆå¯†çº§ï¼ˆå…¬å¼€/内部/保密)
  ARCHIVE_CARRIER_TYPE: 'archive_carrier_type', // æ¡£æ¡ˆè½½ä½“(电子/实体/电子+实体)
  ARCHIVE_SOURCE_TYPE: 'archive_source_type', // æ¡£æ¡ˆæ¥æºï¼ˆæ‰‹å·¥/课题/会员/财务)
  ARCHIVE_BORROW_STATUS: 'archive_borrow_status', // å€Ÿé˜…单状态
} as const;
/** ========== AI - äººå·¥æ™ºèƒ½æ¨¡å— ========== */
const AI_DICT = {
  AI_GENERATE_MODE: 'ai_generate_mode', // AI ç”Ÿæˆæ¨¡å¼
@@ -373,6 +383,7 @@
const DICT_TYPE = {
  ...AFTERSALES_DICT,
  ...AI_DICT,
  ...ARCHIVE_DICT,
  ...BPM_DICT,
  ...CRM_DICT,
  ...ERP_DICT,
src/packages/effects/layouts/src/authentication/authentication.vue
@@ -7,7 +7,6 @@
import { Copyright } from '../basic/copyright';
import AuthenticationFormView from './form.vue';
import SloganIcon from './icons/slogan.vue';
import Toolbar from './toolbar.vue';
interface Props {
@@ -103,34 +102,39 @@
      </div>
    </slot>
    <!-- ç³»ç»Ÿä»‹ç» -->
    <!-- ç³»ç»Ÿä»‹ç»ï¼šæ ¡å›­å¤œæ™¯æ’画铺满 + å·¦ä¸‹è§’品牌文案 -->
    <div v-if="!authPanelCenter" class="relative hidden w-0 flex-1 lg:block">
      <div
        class="absolute inset-0 size-full bg-background-deep dark:bg-[#070709]"
      >
        <div class="login-background absolute top-0 left-0 size-full"></div>
        <!-- æ‰‹ç»˜æ ¡å›­æ’画;外部传入 sloganImage æ—¶ä¼˜å…ˆç”¨å¤–部图 -->
        <img
          v-if="sloganImage"
          alt=""
          class="campus-illustration"
          :src="sloganImage"
        />
        <img
          v-else
          alt=""
          class="campus-illustration"
          src="/static/imgs/jssm-campus.svg"
        />
        <!-- åŽ‹æš—å å±‚ï¼šåº•éƒ¨åŠ é‡ï¼Œä¿è¯æ–‡å­—å¯è¯» -->
        <div class="campus-overlay"></div>
        <div class="academic-bg-pattern"></div>
        <div
          :key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'"
          class="mr-20 flex-col-center h-full"
          class="campus-content"
          :class="{
            'enter-x': authPanelLeft,
            '-enter-x': authPanelRight,
          }"
        >
          <template v-if="sloganImage">
            <img
              :alt="appName"
              :src="sloganImage"
              class="h-64 w-2/5 animate-float"
            />
          </template>
          <SloganIcon v-else :alt="appName" class="h-64 w-2/5 animate-float" />
          <div class="text-1xl mt-6 font-sans text-foreground lg:text-2xl">
            {{ pageTitle }}
          </div>
          <div class="mt-2 dark:text-muted-foreground">
            {{ pageDescription }}
          </div>
          <div class="campus-rule"></div>
          <div class="academic-brand-title">{{ pageTitle }}</div>
          <div class="academic-brand-desc">{{ pageDescription }}</div>
        </div>
      </div>
    </div>
@@ -138,6 +142,8 @@
    <!-- ä¸­å¿ƒè®¤è¯é¢æ¿ -->
    <div v-if="authPanelCenter" class="relative flex-center w-full">
      <div class="login-background absolute top-0 left-0 size-full"></div>
      <!-- å­¦æœ¯é£Žè£…饰网格背景 -->
      <div class="academic-bg-pattern"></div>
      <AuthenticationFormView
        class="w-full rounded-3xl pb-20 shadow-float shadow-primary/5 md:w-2/3 md:bg-background lg:w-1/2 xl:w-[36%]"
        data-side="bottom"
@@ -172,25 +178,85 @@
</template>
<style scoped>
.login-background {
  background: linear-gradient(
    154deg,
    #07070915 30%,
    hsl(var(--primary) / 30%) 48%,
    #07070915 64%
  );
  filter: blur(100px);
/* æ ¡å›­æ’画铺满左侧品牌区;object-position ç•¥åä¸Šï¼Œä¿ä½å»ºç­‘主体 */
.campus-illustration {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 34%;
}
/* å å±‚:底部压暗给文字,左侧略暗以突出白色标题 */
.campus-overlay {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(
      180deg,
      rgba(8, 24, 44, 0.12) 0%,
      rgba(8, 24, 44, 0.22) 42%,
      rgba(8, 24, 44, 0.86) 100%
    ),
    linear-gradient(
      90deg,
      rgba(8, 24, 44, 0.4) 0%,
      rgba(8, 24, 44, 0) 58%
    );
}
/* è£…饰网格:压到几乎不可见的金色细线 */
.academic-bg-pattern {
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(rgba(212, 175, 55, 0.03) 1px, transparent 1px),
    linear-gradient(90deg, rgba(212, 175, 55, 0.03) 1px, transparent 1px);
  background-size: 72px 72px;
  pointer-events: none;
}
/* å“ç‰Œæ–‡æ¡ˆï¼šå®šä½åˆ°å·¦ä¸‹è§’ */
.campus-content {
  position: absolute;
  bottom: 68px;
  left: 56px;
  z-index: 10;
  max-width: 64%;
}
/* æ ‡é¢˜ä¸Šæ–¹çš„金色短横线 */
.campus-rule {
  width: 46px;
  height: 2px;
  margin-bottom: 20px;
  border-radius: 2px;
  background: linear-gradient(90deg, #e3c567, #b8901f);
}
/* å“ç‰ŒåŒºæ ‡é¢˜ï¼šç±³ç™½ï¼Œé˜´å½±æ”¶æ•›ä»¥ä¿æŒåº„重 */
.academic-brand-title {
  color: #f5f0e4 !important;
  font-size: 26px;
  font-weight: 600;
  letter-spacing: 5px;
  text-shadow: 0 1px 10px rgba(0, 0, 0, 0.4);
}
.academic-brand-desc {
  margin-top: 12px;
  color: rgba(212, 175, 55, 0.85) !important;
  font-size: 13.5px;
  letter-spacing: 3px;
}
.dark {
  .login-background {
    background: linear-gradient(
      154deg,
      #07070915 30%,
      hsl(var(--primary) / 20%) 48%,
      #07070915 64%
    );
    filter: blur(100px);
  .academic-bg-pattern {
    background-image:
      linear-gradient(rgba(212, 175, 55, 0.045) 1px, transparent 1px),
      linear-gradient(90deg, rgba(212, 175, 55, 0.045) 1px, transparent 1px);
    background-size: 72px 72px;
  }
}
</style>
src/packages/locales/src/langs/en-US/authentication.json
@@ -1,10 +1,10 @@
{
  "welcomeBack": "Welcome Back",
  "pageTitle": "Plug-and-play Admin system",
  "pageDesc": "Efficient, versatile frontend template",
  "pageTitle": "Unified Management Platform",
  "pageDesc": "Collaboration Â· Data-driven Â· Standardized Process",
  "loginSuccess": "Login Successful",
  "loginSuccessDesc": "Welcome Back",
  "loginSubtitle": "Enter your account details to manage your projects",
  "loginSubtitle": "Enter your account details to sign in",
  "selectAccount": "Quick Select Account",
  "username": "Username",
  "password": "Password",
@@ -46,7 +46,7 @@
  "githubLogin": "Github Login",
  "googleLogin": "Google Login",
  "dingdingLogin": "Dingding Login",
  "codeSubtitle": "Enter your phone number to start managing your project",
  "codeSubtitle": "Enter your mobile number to sign in",
  "code": "Security code",
  "codeTip": "Security code required {0} characters",
  "mobile": "Mobile",
@@ -67,4 +67,4 @@
    "alignLeft": "Align Left",
    "alignRight": "Align Right"
  }
}
}
src/packages/locales/src/langs/zh-CN/authentication.json
@@ -1,10 +1,10 @@
{
  "welcomeBack": "欢迎回来",
  "pageTitle": "开箱即用的大型中后台管理系统",
  "pageDesc": "工程化、高性能、跨组件库的前端模版",
  "pageTitle": "统一管理平台",
  "pageDesc": "协同办公 Â· æ•°æ®å†³ç­– Â· æµç¨‹è§„范",
  "loginSuccess": "登录成功",
  "loginSuccessDesc": "欢迎回来",
  "loginSubtitle": "请输入您的账户信息以开始管理您的项目",
  "loginSubtitle": "请输入账号信息登录统一管理平台",
  "selectAccount": "快速选择账号",
  "username": "账号",
  "password": "密码",
@@ -46,7 +46,7 @@
  "githubLogin": "Github登录",
  "googleLogin": "Google登录",
  "dingdingLogin": "钉钉登录",
  "codeSubtitle": "请输入您的手机号码以开始管理您的项目",
  "codeSubtitle": "请输入手机号码登录统一管理平台",
  "code": "验证码",
  "codeTip": "请输入{0}位验证码",
  "mobile": "手机号码",
@@ -67,4 +67,4 @@
    "alignLeft": "居左",
    "alignRight": "居右"
  }
}
}
src/views/_core/authentication/login.vue
@@ -5,8 +5,9 @@
import { computed, onMounted, ref } from 'vue';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { AuthenticationLogin, VbenButton, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { SvgWeChatIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
@@ -15,10 +16,14 @@
  getCaptcha,
  getTenantByWebsite,
  getTenantSimpleList,
  socialAuthRedirect,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
// vite.config.ts æ³¨å…¥çš„公司 Logo è·¯å¾„
declare const __APP_LOGO__: string;
const authStore = useAuthStore();
const accessStore = useAccessStore();
@@ -29,6 +34,12 @@
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[]>([]); // ç§Ÿæˆ·åˆ—表
@@ -72,27 +83,39 @@
  fetchTenantList();
});
// ========== ä¼ä¸šå¾®ä¿¡ç™»å½• ==========
/** ä¼ä¸šå¾®ä¿¡çš„社交类型,对应后端 SocialTypeEnum.WECHAT_ENTERPRISE */
const WXCP_SOCIAL_TYPE = 30;
const wxcpLoading = ref(false);
/**
 * ä¼ä¸šå¾®ä¿¡æ‰«ç ç™»å½•
 *
 * æµç¨‹ï¼šå–授权地址 -> æ•´é¡µè·³è½¬åˆ°ä¼ä¸šå¾®ä¿¡æ‰«ç é¡µ -> æ‰«ç åŽä¼ä¸šå¾®ä¿¡å¸¦ code/state
 * å›žè·³åˆ° social-login é¡µé¢ -> è¯¥é¡µé¢è°ƒç”¨ /system/auth/social-login æ¢å–令牌
 */
async function handleWxcpLogin() {
  try {
    wxcpLoading.value = true;
    // æ³¨æ„ï¼štype éœ€è¦å…ˆ encode ä¸€æ¬¡ï¼Œé…åˆ social-login é¡µé¢çš„ getUrlValue() ä½¿ç”¨ï¼›
    // å¦åˆ™ä¼ä¸šå¾®ä¿¡å›žè·³åŽ type å‚数会被吞掉,导致无法识别社交类型
    const redirectUri = `${location.origin}/auth/social-login?${encodeURIComponent(
      `type=${WXCP_SOCIAL_TYPE}`,
    )}`;
    window.location.href = await socialAuthRedirect(
      WXCP_SOCIAL_TYPE,
      redirectUri,
    );
  } catch (error) {
    console.error('企业微信登录失败:', error);
    wxcpLoading.value = false;
  }
}
const formSchema = computed((): VbenFormSchema[] => {
  return [
    // {
    //   component: 'VbenSelect',
    //   componentProps: {
    //     options: tenantList.value.map((item) => ({
    //       label: item.name,
    //       value: item.id.toString(),
    //     })),
    //     placeholder: $t('authentication.tenantTip'),
    //   },
    //   fieldName: 'tenantId',
    //   label: $t('authentication.tenant'),
    //   dependencies: {
    //     triggerFields: ['tenantId'],
    //     if: tenantEnable,
    //     trigger(values) {
    //       accessStore.setTenantId(values.tenantId ? Number(values.tenantId) : null);
    //     },
    //   },
    // },
    {
      component: 'VbenInput',
      componentProps: {
@@ -122,7 +145,14 @@
</script>
<template>
  <div>
  <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"
@@ -133,8 +163,39 @@
      :show-register="false"
      :show-forget-password="false"
      :show-doc-link="false"
      class="academic-login-form"
      @submit="handleLogin"
    />
    >
      <!-- è‡ªå®šä¹‰æ ‡é¢˜ï¼šè¦†ç›–欢迎语 -->
      <template #title>
        <div class="academic-title-area">
          <h3 class="academic-title">账号登录</h3>
          <p class="academic-subtitle">{{ $t('authentication.loginSubtitle') }}</p>
        </div>
      </template>
      <!-- ç¬¬ä¸‰æ–¹ç™»å½• -->
      <template #third-party-login>
        <div class="academic-third-party">
          <div class="academic-divider">
            <span class="academic-divider-line"></span>
            <span class="academic-divider-text">其他登录方式</span>
            <span class="academic-divider-line"></span>
          </div>
          <VbenButton
            class="academic-wxcp-btn"
            variant="outline"
            :loading="wxcpLoading"
            @click="handleWxcpLogin"
          >
            <SvgWeChatIcon class="mr-2 size-4" />
            ä¼ä¸šå¾®ä¿¡ç™»å½•
          </VbenButton>
        </div>
      </template>
    </AuthenticationLogin>
    <!-- éªŒè¯ç ï¼ˆæ»‘块拼图),captchaEnable æ—¶å¯ç”¨ -->
    <Verification
      ref="verifyRef"
      v-if="captchaEnable"
@@ -147,3 +208,203 @@
    />
  </div>
</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>
src/views/archive/borrow/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,307 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getDocumentPage } from '#/api/archive/document';
import { getSimpleUserList } from '#/api/system/user';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** å€Ÿé˜…单状态(对应字典 archive_borrow_status) */
export const BorrowStatus = {
  DRAFT: 0, // è‰ç¨¿
  APPROVING: 15, // å®¡æ‰¹ä¸­
  BORROWED: 20, // å€Ÿå‡ºä¸­
  RETURNED: 30, // å·²å½’还
  REJECTED: 35, // å·²é©³å›ž
} as const;
/** å€Ÿé˜…登记表单配置 */
export function useFormSchema(
  formType: FormType,
  documentId?: number,
): VbenFormSchema[] {
  const readonly = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'documentId',
      label: '借阅档案',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '请选择在库档案',
        api: async () => {
          const { list } = await getDocumentPage({
            pageNo: 1,
            pageSize: 100,
            status: 10,
          });
          return list.map((item) => ({
            id: item.id,
            label: item.no ? `${item.no} ${item.title}` : item.title,
          }));
        },
        labelField: 'label',
        valueField: 'id',
        disabled: readonly || !!documentId,
      },
      rules: 'selectRequired',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'borrowerUserId',
      label: '借阅人',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '请选择借阅人',
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'borrowerName',
      label: '借阅人姓名',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅人姓名',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'borrowerDept',
      label: '借阅人部门',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅人部门',
        disabled: readonly,
      },
    },
    {
      fieldName: 'borrowerPhone',
      label: '联系电话',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入联系电话',
        disabled: readonly,
      },
    },
    {
      fieldName: 'borrowDate',
      label: '借阅日期',
      component: 'DatePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择借阅日期',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'returnDeadline',
      label: '归还期限',
      component: 'DatePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择归还期限',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'borrowPurpose',
      label: '借阅目的',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入借阅目的',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入备注',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '借阅单号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅单号',
      },
    },
    {
      fieldName: 'documentNo',
      label: '档案编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案编号',
      },
    },
    {
      fieldName: 'documentTitle',
      label: '档案标题',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案标题',
      },
    },
    {
      fieldName: 'borrowerName',
      label: '借阅人',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入借阅人姓名',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_BORROW_STATUS, 'number'),
        allowClear: true,
        placeholder: '请选择状态',
      },
    },
    {
      fieldName: 'borrowDate',
      label: '借阅日期',
      component: 'RangePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: ['开始日期', '结束日期'],
      },
    },
    {
      fieldName: 'returnDeadline',
      label: '归还期限',
      component: 'RangePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: ['开始日期', '结束日期'],
      },
    },
  ];
}
/** è¡¨æ ¼åˆ—配置 */
export function useGridColumns(): VxeTableGridOptions<ArchiveBorrowApi.Borrow>['columns'] {
  return [
    {
      field: 'no',
      title: '借阅单号',
      minWidth: 170,
    },
    {
      field: 'documentNo',
      title: '档案编号',
      minWidth: 170,
    },
    {
      field: 'documentTitle',
      title: '档案标题',
      minWidth: 200,
    },
    {
      field: 'borrowerName',
      title: '借阅人',
      minWidth: 100,
    },
    {
      field: 'borrowerDept',
      title: '部门',
      minWidth: 120,
    },
    {
      field: 'borrowDate',
      title: '借阅日期',
      minWidth: 120,
    },
    {
      field: 'returnDeadline',
      title: '归还期限',
      minWidth: 120,
    },
    {
      field: 'overdue',
      title: '逾期',
      minWidth: 110,
      slots: { default: 'overdue' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_BORROW_STATUS },
      },
    },
    {
      field: 'approveTime',
      title: '借出时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'returnTime',
      title: '归还时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 230,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/archive/borrow/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
import { getBorrow } from '#/api/archive/borrow';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'ArchiveBorrowApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ArchiveBorrowApi.Borrow>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** æ—¥æœŸæ—¶é—´å­—段空值占位 */
function timeRender(val: unknown) {
  return val ? (formatDateTime(val as string) as string) : '-';
}
/** é€¾æœŸå±•示 */
function overdueRender(_: unknown, record: ArchiveBorrowApi.Borrow) {
  return record?.overdue
    ? h(Tag, { color: 'error' }, () => `逾期 ${record.overdueDays} å¤©`)
    : h(Tag, { color: 'success' }, () => '未逾期');
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '借阅单号', field: 'no', render: textRender },
    {
      label: '状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.ARCHIVE_BORROW_STATUS, value: val }),
    },
    { label: '档案编号', field: 'documentNo', render: textRender },
    { label: '档案标题', field: 'documentTitle', render: textRender },
    { label: '借阅人', field: 'borrowerName', render: textRender },
    { label: '借阅人部门', field: 'borrowerDept', render: textRender },
    { label: '联系电话', field: 'borrowerPhone', render: textRender },
    { label: '借阅日期', field: 'borrowDate', render: textRender },
    { label: '归还期限', field: 'returnDeadline', render: textRender },
    {
      label: '逾期情况',
      field: 'overdue',
      render: (val, record) => overdueRender(val, record as ArchiveBorrowApi.Borrow),
    },
    {
      label: '借出时间',
      field: 'approveTime',
      render: timeRender,
    },
    {
      label: '归还时间',
      field: 'returnTime',
      render: timeRender,
    },
    { label: '归还确认人', field: 'returnConfirmUserName', render: textRender },
    {
      label: '借阅目的',
      field: 'borrowPurpose',
      span: 2,
      render: textRender,
    },
    {
      label: '归还备注',
      field: 'returnRemark',
      span: 2,
      render: textRender,
    },
    {
      label: '驳回原因',
      field: 'rejectReason',
      span: 2,
      render: textRender,
    },
    {
      label: '驳回时间',
      field: 'rejectTime',
      render: timeRender,
    },
    { label: '创建时间', field: 'createTime', render: timeRender },
    { label: '备注', field: 'remark', span: 2, render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getBorrow(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="rounded-lg border border-border bg-background p-4">
        <div class="mb-4 flex items-center gap-2">
          <span class="inline-block h-4 w-1 rounded bg-primary" />
          <span class="text-base font-semibold">档案借阅单信息</span>
        </div>
        <Descriptions :data="formData" />
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/archive/borrow/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,256 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteBorrow,
  getBorrowApproveProcessList,
  getBorrowPage,
  getOverdueBorrowPage,
} from '#/api/archive/borrow';
import { $t } from '#/locales';
import { BorrowStatus, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
import ReturnForm from './modules/return-form.vue';
/** å€Ÿé˜…登记 */
defineOptions({ name: 'ArchiveBorrow' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
const [ReturnModal, returnModalApi] = useVbenModal({
  connectedComponent: ReturnForm,
  destroyOnClose: true,
});
/** æ˜¯å¦åªçœ‹é€¾æœŸæœªè¿˜ */
const overdueOnly = ref(false);
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ‡æ¢ã€Œåªçœ‹é€¾æœŸã€ */
function handleToggleOverdue() {
  overdueOnly.value = !overdueOnly.value;
  gridApi.query();
}
/** ç™»è®°å€Ÿé˜… */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑借阅单 */
function handleEdit(row: ArchiveBorrowApi.Borrow) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹å€Ÿé˜…单详情 */
function handleDetail(row: ArchiveBorrowApi.Borrow) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤å€Ÿé˜…单 */
async function handleDelete(row: ArchiveBorrowApi.Borrow) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.no]),
    duration: 0,
  });
  try {
    await deleteBorrow(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ï¼Œæµç¨‹ key ä¸å†™æ­» */
async function handleSubmit(row: ArchiveBorrowApi.Borrow) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getBorrowApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning(
        '暂无可用审批流程,请先到 BPM æµç¨‹è®¾è®¡å™¨å‘布档案借阅审批流程',
      );
      return;
    }
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
  }
}
/** å½’还登记 */
function handleReturn(row: ArchiveBorrowApi.Borrow) {
  returnModalApi.setData(row).open();
}
/** æŸ¥çœ‹å®¡æ‰¹è¿›åº¦ */
function handleProcessDetail(row: ArchiveBorrowApi.Borrow) {
  if (!row.processInstanceId) {
    message.warning('该借阅单尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          const params = {
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          };
          return overdueOnly.value
            ? await getOverdueBorrowPage(params)
            : await getBorrowPage(params);
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ArchiveBorrowApi.Borrow>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <ReturnModal @success="handleRefresh" />
    <Grid table-title="借阅登记">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '登记借阅',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['archive:borrow:create'],
              onClick: handleCreate,
            },
            {
              label: overdueOnly ? '查看全部' : '只看逾期',
              type: overdueOnly ? 'primary' : 'default',
              danger: overdueOnly,
              icon: ACTION_ICON.SEARCH,
              auth: ['archive:borrow:query'],
              onClick: handleToggleOverdue,
            },
          ]"
        />
      </template>
      <template #overdue="{ row }">
        <Tag v-if="row.overdue" color="error">逾期 {{ row.overdueDays }} å¤©</Tag>
        <span v-else>-</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['archive:borrow:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:borrow:update'],
              ifShow: () =>
                row.status === BorrowStatus.DRAFT ||
                row.status === BorrowStatus.REJECTED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['archive:borrow:submit'],
              ifShow: () =>
                row.status === BorrowStatus.DRAFT ||
                row.status === BorrowStatus.REJECTED,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '归还登记',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:borrow:return'],
              ifShow: () => row.status === BorrowStatus.BORROWED,
              onClick: handleReturn.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['archive:borrow:query'],
              ifShow: () => !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['archive:borrow:delete'],
              ifShow: () => row.status === BorrowStatus.DRAFT,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/archive/borrow/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import type { FormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import {
  createBorrow,
  getBorrow,
  updateBorrow,
} from '#/api/archive/borrow';
import { useFormSchema } from '../data';
defineOptions({ name: 'ArchiveBorrowForm' });
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
/** ä»Žæ¡£æ¡ˆåˆ—表「登记借阅」带入的档案 ID */
const fixedDocumentId = ref<number>();
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return '登记借阅';
  }
  if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['借阅单']);
  }
  return '借阅单详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 110,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: [],
  showDefaultActions: false,
});
/** åªæäº¤åŽç«¯ SaveReqVO æŽ¥å—的字段 */
async function buildSubmitData() {
  const values = (await formApi.getValues()) as ArchiveBorrowApi.Borrow;
  return {
    id: values.id,
    documentId: values.documentId,
    borrowerUserId: values.borrowerUserId,
    borrowerName: values.borrowerName,
    borrowerDept: values.borrowerDept,
    borrowerPhone: values.borrowerPhone,
    borrowDate: values.borrowDate,
    borrowPurpose: values.borrowPurpose,
    returnDeadline: values.returnDeadline,
    remark: values.remark,
  } as ArchiveBorrowApi.Borrow;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = await buildSubmitData();
    try {
      await (formType.value === 'create'
        ? createBorrow(data)
        : updateBorrow(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      fixedDocumentId.value = undefined;
      return;
    }
    const data = modalApi.getData<{
      formType: FormType;
      id?: number;
      documentId?: number;
    }>();
    formType.value = data.formType;
    fixedDocumentId.value = data.documentId;
    formApi.setState({ schema: useFormSchema(formType.value, data.documentId) });
    formApi.setDisabled(formType.value === 'detail');
    if (data?.id) {
      modalApi.lock();
      try {
        const detail = await getBorrow(data.id);
        await formApi.setValues(detail, false);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
      // æ–°å»ºæ—¶å¸¦å‡ºæ¡£æ¡ˆä¸Žé»˜è®¤å€Ÿé˜…日期(今天)
      await formApi.setValues({
        documentId: data.documentId,
        borrowDate: dayjs().format('YYYY-MM-DD'),
      });
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-2/3"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/archive/borrow/modules/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { submitBorrow } from '#/api/archive/borrow';
defineOptions({ name: 'ArchiveBorrowProcessSelect' });
const emit = defineEmits(['success']);
const borrowId = ref<number>();
const processList = ref<ArchiveBorrowApi.ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
/** æµç¨‹ key åªèƒ½æ¥è‡ªåŽç«¯å®žæ—¶åˆ—表,禁止前端写死 */
const processOptions = computed(() =>
  processList.value.map((item) => ({ label: item.name, value: item.key })),
);
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    modalApi.lock();
    try {
      await submitBorrow(borrowId.value!, selectedProcessKey.value);
      await modalApi.close();
      emit('success');
      message.success('提交成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      borrowId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data = modalApi.getData<{
      id: number;
      processList: ArchiveBorrowApi.ProcessDefinition[];
    }>();
    borrowId.value = data.id;
    processList.value = data.processList || [];
    // é»˜è®¤é€‰æ‹©ç¬¬ä¸€ä¸ªæµç¨‹
    if (processList.value.length > 0) {
      selectedProcessKey.value = processList.value[0]!.key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
        v-model:value="selectedProcessKey"
        :options="processOptions"
        style="width: 240px"
        placeholder="请选择审批流程"
      />
    </div>
    <p class="text-sm text-gray-500">
      æäº¤åŽä¾æ¬¡æµè½¬ï¼šæ¡£æ¡ˆç®¡ç†å‘˜å®¡æ ¸ â†’ åˆ†ç®¡é¢†å¯¼å®¡æ‰¹
    </p>
  </Modal>
</template>
src/views/archive/borrow/modules/return-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { ArchiveBorrowApi } from '#/api/archive/borrow';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { returnBorrow } from '#/api/archive/borrow';
defineOptions({ name: 'ArchiveBorrowReturnForm' });
const emit = defineEmits(['success']);
/** å½“前归还的借阅单 */
const current = ref<ArchiveBorrowApi.Borrow>();
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
  },
  wrapperClass: 'grid-cols-1',
  layout: 'vertical',
  schema: [
    {
      fieldName: 'returnRemark',
      label: '归还备注',
      component: 'Textarea',
      componentProps: {
        rows: 3,
        placeholder: '如归还时档案完好、是否有缺页等',
      },
    },
  ],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const values = (await formApi.getValues()) as { returnRemark?: string };
    modalApi.lock();
    try {
      await returnBorrow({
        id: current.value!.id!,
        returnRemark: values.returnRemark,
      });
      await modalApi.close();
      emit('success');
      message.success('归还登记成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      current.value = undefined;
      return;
    }
    current.value = modalApi.getData<ArchiveBorrowApi.Borrow>();
    await formApi.resetForm();
  },
});
</script>
<template>
  <Modal title="归还登记" class="w-1/2">
    <div class="mx-3 mb-3 text-sm text-gray-600">
      <p>
        å€Ÿé˜…单号:{{ current?.no }} 档案:{{ current?.documentNo }}
        {{ current?.documentTitle }}
      </p>
      <p>
        å€Ÿé˜…人:{{ current?.borrowerName }} 归还期限:{{ current?.returnDeadline }}
      </p>
    </div>
    <Form class="mx-3" />
  </Modal>
</template>
src/views/archive/category/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,192 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveCategoryApi } from '#/api/archive/category';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { getCategoryList } from '#/api/archive/category';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'parentId',
      label: '上级分类',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        api: async () => {
          const data = await getCategoryList();
          data.unshift({
            id: 0,
            name: '顶级分类',
          } as ArchiveCategoryApi.Category);
          return handleTree(data);
        },
        fieldNames: { label: 'name', value: 'id', children: 'children' },
        placeholder: '请选择上级分类',
        showSearch: true,
        treeDefaultExpandAll: true,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'name',
      label: '分类名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入分类名称',
      },
      rules: 'required',
    },
    {
      fieldName: 'code',
      label: '分类编码',
      component: 'Input',
      componentProps: {
        placeholder: '英文编码,如 member/research/finance',
      },
      rules: 'required',
    },
    {
      fieldName: 'defaultSecretLevel',
      label: '默认密级',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SECRET_LEVEL, 'number'),
        placeholder: '该分类下档案的默认密级',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'sort',
      label: '显示排序',
      component: 'InputNumber',
      componentProps: {
        min: 0,
        placeholder: '数字越小越靠前',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CATEGORY_STATUS, 'number'),
        placeholder: '请选择状态',
      },
    },
    {
      fieldName: 'description',
      label: '分类说明',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入分类说明',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入备注',
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '分类名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入分类名称',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CATEGORY_STATUS, 'number'),
        allowClear: true,
        placeholder: '请选择状态',
      },
    },
  ];
}
/** è¡¨æ ¼åˆ—配置 */
export function useGridColumns(): VxeTableGridOptions<ArchiveCategoryApi.Category>['columns'] {
  return [
    {
      field: 'name',
      title: '分类名称',
      treeNode: true,
    },
    {
      field: 'code',
      title: '分类编码',
      minWidth: 130,
    },
    {
      field: 'defaultSecretLevel',
      title: '默认密级',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_SECRET_LEVEL },
      },
    },
    {
      field: 'sort',
      title: '排序',
      minWidth: 80,
    },
    {
      field: 'description',
      title: '分类说明',
      minWidth: 180,
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_CATEGORY_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'actions',
      title: '操作',
      width: 250,
      fixed: 'right',
      slots: {
        default: 'actions',
      },
    },
  ];
}
src/views/archive/category/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveCategoryApi } from '#/api/archive/category';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteCategory,
  getCategoryList,
} from '#/api/archive/category';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æ¡£æ¡ˆåˆ†ç±»ï¼ˆä¹¦æž¶ï¼‰ */
defineOptions({ name: 'ArchiveCategory' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ‡æ¢æ ‘形展开/收缩状态 */
const isExpanded = ref(false);
function handleExpand() {
  isExpanded.value = !isExpanded.value;
  gridApi.grid.setAllTreeExpand(isExpanded.value);
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ›å»ºåˆ†ç±» */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** æ·»åŠ ä¸‹çº§åˆ†ç±» */
function handleAppend(row: ArchiveCategoryApi.Category) {
  formModalApi.setData({ parentId: row.id }).open();
}
/** ç¼–辑分类 */
function handleEdit(row: ArchiveCategoryApi.Category) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤åˆ†ç±» */
async function handleDelete(row: ArchiveCategoryApi.Category) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.name]),
    duration: 0,
  });
  try {
    await deleteCategory(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      ajax: {
        query: async (_, formValues) => {
          return await getCategoryList(formValues);
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
    treeConfig: {
      parentField: 'parentId',
      rowField: 'id',
      transform: true,
      expandAll: true,
      reserve: true,
    },
  } as VxeTableGridOptions<ArchiveCategoryApi.Category>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="档案分类(书架)">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['档案分类']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['archive:category:create'],
              onClick: handleCreate,
            },
            {
              label: isExpanded ? '收缩' : '展开',
              type: 'primary',
              onClick: handleExpand,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '新增下级',
              type: 'link',
              icon: ACTION_ICON.ADD,
              auth: ['archive:category:create'],
              onClick: handleAppend.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:category:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['archive:category:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/archive/category/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { ArchiveCategoryApi } from '#/api/archive/category';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createCategory,
  getCategory,
  updateCategory,
} from '#/api/archive/category';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'ArchiveCategoryForm' });
const emit = defineEmits(['success']);
const formData = ref<ArchiveCategoryApi.Category>();
const getTitle = computed(() => {
  if (formData.value?.id) {
    return $t('ui.actionTitle.edit', ['档案分类']);
  }
  return formData.value?.parentId
    ? '新增下级分类'
    : $t('ui.actionTitle.create', ['档案分类']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-2',
    labelWidth: 90,
  },
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as ArchiveCategoryApi.Category;
    try {
      await (formData.value?.id
        ? updateCategory({ ...data, id: formData.value.id })
        : createCategory(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    let data = modalApi.getData<ArchiveCategoryApi.Category>();
    // æ–°å¢žï¼ˆé¡¶çº§ / ä¸‹çº§ï¼‰æ—¶åªå¸¦ parentId ä½œä¸ºé»˜è®¤å€¼
    if (!data || !data.id) {
      formData.value = data ?? undefined;
      await formApi.resetForm();
      if (data?.parentId !== undefined) {
        await formApi.setValues({ parentId: data.parentId });
      }
      return;
    }
    modalApi.lock();
    try {
      data = await getCategory(data.id);
      formData.value = data;
      await formApi.setValues(data);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal class="w-2/5" :title="getTitle">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/archive/document/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,442 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveDocumentApi } from '#/api/archive/document';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { getCategoryList } from '#/api/archive/category';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** æ¡£æ¡ˆè¡¨å•配置 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  const readonly = formType === 'detail';
  // æ¡£æ¡ˆç¼–号、来源、状态、归档人由后端维护,仅编辑/详情可见
  const showBackendFields = formType !== 'create';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '档案编号',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        disabled: true,
        placeholder: '系统自动生成',
      },
    },
    {
      fieldName: 'status',
      label: '档案状态',
      component: 'Select',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_DOCUMENT_STATUS, 'number'),
        disabled: true,
      },
    },
    {
      fieldName: 'title',
      label: '档案标题',
      component: 'Input',
      componentProps: {
        allowClear: true,
        disabled: readonly,
        placeholder: '请输入档案标题',
      },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'categoryId',
      label: '档案分类',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        api: async () => handleTree(await getCategoryList({ status: 0 })),
        fieldNames: { label: 'name', value: 'id', children: 'children' },
        placeholder: '请选择档案分类',
        showSearch: true,
        treeDefaultExpandAll: true,
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'secretLevel',
      label: '密级',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SECRET_LEVEL, 'number'),
        placeholder: '请选择密级',
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'carrierType',
      label: '载体',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CARRIER_TYPE, 'number'),
        placeholder: '请选择载体',
        disabled: readonly,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'archiveDate',
      label: '归档日期',
      component: 'DatePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择归档日期',
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'shelfNo',
      label: '书架号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 A架',
        disabled: readonly,
      },
    },
    {
      fieldName: 'layerNo',
      label: '层号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 2层',
        disabled: readonly,
      },
    },
    {
      fieldName: 'columnNo',
      label: '列号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 3列',
        disabled: readonly,
      },
    },
    {
      fieldName: 'boxNo',
      label: '册号/档案盒号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 æ¡£æ¡ˆç›’-001',
        disabled: readonly,
      },
    },
    {
      fieldName: 'storePlace',
      label: '存放位置',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '如 3号库房A架2层',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'tags',
      label: '关键词',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '多个关键词用英文逗号分隔,便于检索',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'summary',
      label: '摘要',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入摘要',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'sourceType',
      label: '来源类型',
      component: 'Select',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SOURCE_TYPE, 'number'),
        disabled: true,
      },
    },
    {
      fieldName: 'sourceNo',
      label: '来源单号',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'archivistName',
      label: '归档人',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => showBackendFields,
      },
      componentProps: {
        disabled: true,
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        rows: 2,
        placeholder: '请输入备注',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '电子文件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        showDescription: !readonly,
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '档案编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案编号',
      },
    },
    {
      fieldName: 'title',
      label: '档案标题',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入档案标题',
      },
    },
    {
      fieldName: 'categoryId',
      label: '档案分类',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        api: async () => handleTree(await getCategoryList({ status: 0 })),
        fieldNames: { label: 'name', value: 'id', children: 'children' },
        placeholder: '请选择档案分类',
        showSearch: true,
        treeDefaultExpandAll: true,
      },
    },
    {
      fieldName: 'secretLevel',
      label: '密级',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SECRET_LEVEL, 'number'),
        allowClear: true,
        placeholder: '请选择密级',
      },
    },
    {
      fieldName: 'carrierType',
      label: '载体',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_CARRIER_TYPE, 'number'),
        allowClear: true,
        placeholder: '请选择载体',
      },
    },
    {
      fieldName: 'sourceType',
      label: '来源类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_SOURCE_TYPE, 'number'),
        allowClear: true,
        placeholder: '请选择来源类型',
      },
    },
    {
      fieldName: 'sourceNo',
      label: '来源单号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入来源单号',
      },
    },
    {
      fieldName: 'tags',
      label: '关键词',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入关键词',
      },
    },
    {
      fieldName: 'status',
      label: '档案状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ARCHIVE_DOCUMENT_STATUS, 'number'),
        allowClear: true,
        placeholder: '请选择档案状态',
      },
    },
    {
      fieldName: 'archiveDate',
      label: '归档日期',
      component: 'RangePicker',
      componentProps: {
        allowClear: true,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        placeholder: ['开始日期', '结束日期'],
      },
    },
  ];
}
/** è¡¨æ ¼åˆ—配置 */
export function useGridColumns(): VxeTableGridOptions<ArchiveDocumentApi.Document>['columns'] {
  return [
    {
      field: 'no',
      title: '档案编号',
      minWidth: 170,
    },
    {
      field: 'title',
      title: '档案标题',
      minWidth: 220,
    },
    {
      field: 'categoryName',
      title: '档案分类',
      minWidth: 120,
    },
    {
      field: 'secretLevel',
      title: '密级',
      minWidth: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_SECRET_LEVEL },
      },
    },
    {
      field: 'carrierType',
      title: '载体',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_CARRIER_TYPE },
      },
    },
    {
      field: 'sourceType',
      title: '来源',
      minWidth: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_SOURCE_TYPE },
      },
    },
    {
      field: 'sourceNo',
      title: '来源单号',
      minWidth: 160,
    },
    {
      field: 'storePlace',
      title: '存放位置',
      minWidth: 170,
    },
    {
      field: 'archiveDate',
      title: '归档日期',
      minWidth: 120,
    },
    {
      field: 'status',
      title: '档案状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ARCHIVE_DOCUMENT_STATUS },
      },
    },
    {
      field: 'hasAttachment',
      title: '电子文件',
      minWidth: 100,
      slots: { default: 'hasAttachment' },
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/archive/document/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ArchiveDocumentApi } from '#/api/archive/document';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteDocument,
  exportDocument,
  getDocumentPage,
} from '#/api/archive/document';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æ¡£æ¡ˆç®¡ç† */
defineOptions({ name: 'ArchiveDocument' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žæ¡£æ¡ˆ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑档案 */
function handleEdit(row: ArchiveDocumentApi.Document) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹æ¡£æ¡ˆè¯¦æƒ… */
function handleDetail(row: ArchiveDocumentApi.Document) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤æ¡£æ¡ˆ */
async function handleDelete(row: ArchiveDocumentApi.Document) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.title]),
    duration: 0,
  });
  try {
    await deleteDocument(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.title]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å¯¼å‡º Excel */
async function handleExport() {
  const data = await exportDocument(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '档案.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getDocumentPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ArchiveDocumentApi.Document>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="档案管理">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['档案']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['archive:document:create'],
              onClick: handleCreate,
            },
            {
              label: '导出',
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['archive:document:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['archive:document:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['archive:document:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['archive:document:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/archive/document/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
<script lang="ts" setup>
import type { ArchiveDocumentApi } from '#/api/archive/document';
import type { FormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createDocument,
  getDocument,
  updateDocument,
} from '#/api/archive/document';
import { useFormSchema } from '../data';
defineOptions({ name: 'ArchiveDocumentForm' });
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['档案']);
  }
  if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['档案']);
  }
  return '档案详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: [],
  showDefaultActions: false,
});
/** åªæäº¤åŽç«¯ SaveReqVO æŽ¥å—的字段,避免回传 no/status/source* ç­‰åªè¯»å­—段 */
async function buildSubmitData() {
  const values = (await formApi.getValues()) as ArchiveDocumentApi.Document;
  return {
    id: values.id,
    title: values.title,
    categoryId: values.categoryId,
    secretLevel: values.secretLevel,
    carrierType: values.carrierType,
    shelfNo: values.shelfNo,
    layerNo: values.layerNo,
    columnNo: values.columnNo,
    boxNo: values.boxNo,
    storePlace: values.storePlace,
    archiveDate: values.archiveDate,
    summary: values.summary,
    tags: values.tags,
    remark: values.remark,
    // é™„件为全量替换语义:清空全部附件时必须显式传空数组
    blobIds: values.blobIds ?? [],
  } as ArchiveDocumentApi.Document;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = await buildSubmitData();
    try {
      await (formType.value === 'create'
        ? createDocument(data)
        : updateDocument(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(formType.value) });
    formApi.setDisabled(formType.value === 'detail');
    if (data?.id) {
      modalApi.lock();
      try {
        const detail = await getDocument(data.id);
        // å·²æœ‰é™„件转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
        const blobIds = (detail.attachmentList ?? []).map((item) => ({
          uid: String(item.id),
          name: item.name || '',
          url: item.url || '',
          status: 'done',
          id: item.id,
        }));
        await formApi.setValues({ ...detail, blobIds }, false);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/archive/stat/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
import type { EChartsOption } from '@vben/plugins/echarts';
import type { ArchiveStatApi } from '#/api/archive/stat';
/** é¡¶éƒ¨ç»Ÿè®¡å¡ç‰‡ */
export interface StatCard {
  key: keyof ArchiveStatApi.StatOverview;
  label: string;
  color: string;
}
export const STAT_CARDS: StatCard[] = [
  { key: 'totalCount', label: '档案总数', color: '#1677ff' },
  { key: 'inStockCount', label: '在库档案', color: '#52c41a' },
  { key: 'borrowedCount', label: '借出中档案', color: '#faad14' },
  { key: 'overdueCount', label: '逾期未还', color: '#ff4d4f' },
];
/** é€šç”¨é¥¼å›¾é…ç½® */
export function buildPieOption(
  title: string,
  rows: { name: string; value: number }[],
): EChartsOption {
  return {
    title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
    tooltip: { trigger: 'item' },
    legend: { orient: 'vertical', left: 'left', top: 'middle' },
    series: [
      {
        name: title,
        type: 'pie',
        radius: ['40%', '65%'],
        center: ['60%', '55%'],
        data: rows,
        emphasis: {
          itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' },
        },
      },
    ],
  };
}
/** é€šç”¨æŸ±çŠ¶å›¾é…ç½® */
export function buildBarOption(
  title: string,
  rows: { name: string; value: number }[],
): EChartsOption {
  return {
    title: { text: title, left: 'center', textStyle: { fontSize: 14 } },
    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
    grid: { left: 20, right: 20, bottom: 20, containLabel: true },
    xAxis: {
      type: 'category',
      data: rows.map((item) => item.name),
      axisTick: { alignWithLabel: true },
    },
    yAxis: { type: 'value', minInterval: 1 },
    series: [
      {
        name: title,
        type: 'bar',
        barMaxWidth: 48,
        data: rows.map((item) => item.value),
        label: { show: true, position: 'top' },
      },
    ],
  };
}
src/views/archive/stat/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { ArchiveStatApi } from '#/api/archive/stat';
import { computed, onMounted, ref } from 'vue';
import { ContentWrap, Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Spin } from 'ant-design-vue';
import { getArchiveStatOverview } from '#/api/archive/stat';
import { buildBarOption, buildPieOption, STAT_CARDS } from './data';
/** æ¡£æ¡ˆç»Ÿè®¡ */
defineOptions({ name: 'ArchiveStat' });
const loading = ref(false);
const overview = ref<ArchiveStatApi.StatOverview>({});
const categoryChartRef = ref<EchartsUIType>();
const secretChartRef = ref<EchartsUIType>();
const carrierChartRef = ref<EchartsUIType>();
const { renderEcharts: renderCategory } = useEcharts(categoryChartRef);
const { renderEcharts: renderSecret } = useEcharts(secretChartRef);
const { renderEcharts: renderCarrier } = useEcharts(carrierChartRef);
/** å¡ç‰‡å–值 */
const cards = computed(() =>
  STAT_CARDS.map((card) => ({
    ...card,
    value: (overview.value[card.key] as number | undefined) ?? 0,
  })),
);
async function loadData() {
  loading.value = true;
  try {
    const data = await getArchiveStatOverview();
    overview.value = data;
    await Promise.all([
      renderCategory(
        buildPieOption(
          '按分类分布',
          (data.byCategory ?? []).map((item) => ({
            name: item.name ?? '未命名',
            value: item.total ?? 0,
          })),
        ),
      ),
      renderSecret(
        buildBarOption(
          '按密级分布',
          (data.bySecretLevel ?? []).map((item) => ({
            name: item.name ?? '未设置',
            value: item.total ?? 0,
          })),
        ),
      ),
      renderCarrier(
        buildPieOption(
          '按载体分布',
          (data.byCarrierType ?? []).map((item) => ({
            name: item.name ?? '未设置',
            value: item.total ?? 0,
          })),
        ),
      ),
    ]);
  } finally {
    loading.value = false;
  }
}
onMounted(loadData);
</script>
<template>
  <Page auto-content-height>
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- ç»Ÿè®¡å¡ç‰‡ -->
        <div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
          <ContentWrap
            v-for="card in cards"
            :key="card.key"
            class="flex items-center justify-between"
          >
            <div>
              <p class="text-sm text-gray-500">{{ card.label }}</p>
              <p
                class="mt-1 text-2xl font-semibold"
                :style="{ color: card.color }"
              >
                {{ card.value }}
              </p>
            </div>
            <span
              class="inline-block h-10 w-1 rounded"
              :style="{ backgroundColor: card.color }"
            />
          </ContentWrap>
        </div>
        <!-- åˆ†å¸ƒå›¾è¡¨ -->
        <div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
          <ContentWrap>
            <EchartsUI class="h-72 w-full" ref="categoryChartRef" />
          </ContentWrap>
          <ContentWrap>
            <EchartsUI class="h-72 w-full" ref="secretChartRef" />
          </ContentWrap>
        </div>
        <ContentWrap>
          <EchartsUI class="h-72 w-full" ref="carrierChartRef" />
        </ContentWrap>
      </div>
    </Spin>
  </Page>
</template>
src/views/dashboard/analytics/data.ts
ÎļþÒÑɾ³ý
src/views/dashboard/analytics/index.vue
@@ -1,25 +1,23 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Card, Col, Row } from 'ant-design-vue';
import { Col, Row } from 'ant-design-vue';
import { getHomeSummary } from '#/api/mes/home';
import { defaultSummary } from './data';
import AiInsights from './modules/ai-insights.vue';
import AlertPanel from './modules/alert-panel.vue';
import BizCards from './modules/biz-cards.vue';
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
import QuickActions from './modules/quick-actions.vue';
import RecentApplications from './modules/recent-applications.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 router = useRouter();
const summary = ref(defaultSummary);
const greeting = computed(() => {
  const hour = new Date().getHours();
@@ -40,94 +38,103 @@
function handleNavigate(name: string) {
  router.push({ name });
}
async function loadSummary() {
  summary.value = await getHomeSummary();
}
onMounted(() => {
  loadSummary();
});
</script>
<template>
  <Page auto-content-height>
    <!-- æ¬¢è¿Žæ  -->
    <div class="glass-card mb-6 flex items-center justify-between rounded-2xl p-6">
      <div>
        <h1 class="bg-gradient-to-r from-blue-600 via-purple-600 to-pink-500 bg-clip-text text-2xl font-bold text-transparent dark:from-blue-400 dark:via-purple-400 dark:to-pink-400">
          {{ greeting }},欢迎回来
        </h1>
        <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ today }}</p>
      </div>
      <div class="hidden items-center gap-2 sm:flex">
        <div class="flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400">
          <span class="relative flex size-2">
            <span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-75" />
            <span class="relative inline-flex size-2 rounded-full bg-emerald-500" />
          </span>
    <!-- æ¬¢è¿Žæ ï¼šè—é’底 + é‡‘色描边 -->
    <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>
    </div>
    <!-- MES ç”Ÿäº§ KPI -->
    <KpiCards :summary="summary" @navigate="handleNavigate" />
    <!-- ç³»ç»ŸæŒ‡æ ‡ -->
    <KpiCards @navigate="handleNavigate" />
    <!-- ERP/WMS/BPM ç»è¥ KPI -->
    <BizCards @navigate="handleNavigate" />
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      :active-order-count="summary.workOrderActiveCount"
      @navigate="handleNavigate"
    />
    <!-- ç”Ÿäº§è¶‹åŠ¿ + é¢„警面板 -->
    <!-- å¾…办 + å¿«æ·å…¥å£ -->
    <Row :gutter="16" class="mb-4">
      <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4">
        <div class="glass-card rounded-2xl p-5">
          <h3 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-100">
            ç”Ÿäº§è¶‹åŠ¿ï¼ˆè¿‘7天)
          </h3>
          <ProductionTrend />
        </div>
        <TodoPanel @navigate="handleNavigate" />
      </Col>
      <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4">
        <AlertPanel
          :repair-active-count="summary.repairActiveCount"
          @navigate="handleNavigate"
        />
      </Col>
    </Row>
    <!-- å¿«æ·æ“ä½œ -->
    <Row :gutter="16" class="mb-4">
      <Col :span="24">
        <QuickActions @navigate="handleNavigate" />
      </Col>
    </Row>
    <!-- æœ€è¿‘课题申报 -->
    <RecentApplications @navigate="handleNavigate" />
  </Page>
</template>
<style scoped>
.glass-card {
  background: linear-gradient(135deg, rgba(255,255,255,0.8), rgba(255,255,255,0.5));
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255,255,255,0.6);
  box-shadow:
    0 8px 32px rgba(0,0,0,0.04),
    0 2px 8px rgba(0,0,0,0.02),
    inset 0 1px 0 rgba(255,255,255,0.8);
/* ===== æ¬¢è¿Žæ  ===== */
.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);
}
.dark .glass-card {
  background: linear-gradient(135deg, rgba(30,30,50,0.7), rgba(20,20,40,0.5));
  border: 1px solid rgba(255,255,255,0.08);
  box-shadow:
    0 8px 32px rgba(0,0,0,0.3),
    0 2px 8px rgba(0,0,0,0.2),
    inset 0 1px 0 rgba(255,255,255,0.05);
/* å³ä¾§æ·¡æ·¡çš„徽标水印 */
.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;
}
</style>
src/views/dashboard/analytics/modules/ai-insights.vue
ÎļþÒÑɾ³ý
src/views/dashboard/analytics/modules/alert-panel.vue
ÎļþÒÑɾ³ý
src/views/dashboard/analytics/modules/biz-cards.vue
ÎļþÒÑɾ³ý
src/views/dashboard/analytics/modules/kpi-cards.vue
@@ -1,210 +1,251 @@
<script lang="ts" setup>
import type { MesHomeApi } from '#/api/mes/home';
import { computed, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Col, Row } from 'ant-design-vue';
defineOptions({ name: 'DashboardKpiCards' });
import { getTaskTodoPage } from '#/api/bpm/task';
import { getReimbursementPage } from '#/api/finance/reimbursement';
import { getApplicationPage } from '#/api/research/application';
import { getGuidelinePage } from '#/api/research/guideline';
const props = defineProps<{
  summary: MesHomeApi.Summary;
}>();
defineOptions({ name: 'DashboardKpiCards' });
const emit = defineEmits<{
  navigate: [name: string];
}>();
const qualityRate = computed(() => {
  const total =
    props.summary.todayQualifiedQuantity +
    props.summary.todayUnqualifiedQuantity;
  return total === 0 ? 0 : (props.summary.todayQualifiedQuantity / total) * 100;
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 */
  }
}
onMounted(() => {
  loadTodo();
  loadApplication();
  loadReimbursement();
  loadGuideline();
});
const equipmentRate = computed(() => {
  if (props.summary.machineryTotal === 0) return 0;
  return (props.summary.machineryProducing / props.summary.machineryTotal) * 100;
});
/** 3D å€¾æ–œæ•ˆæžœ */
interface TiltState { x: number; y: number }
const tilts = ref<Record<string, TiltState>>({});
function onMouseMove(e: MouseEvent, id: string) {
  const el = e.currentTarget as HTMLElement;
  const rect = el.getBoundingClientRect();
  const x = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
  const y = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
  tilts.value[id] = { x: x * -6, y: y * 6 };
}
function onMouseLeave(id: string) {
  tilts.value[id] = { x: 0, y: 0 };
}
function tiltStyle(id: string) {
  const t = tilts.value[id] ?? { x: 0, y: 0 };
  return {
    transform: `perspective(600px) rotateX(${t.y}deg) rotateY(${t.x}deg)`,
    transition: t.x === 0 && t.y === 0 ? 'transform 0.5s ease' : 'transform 0.1s ease',
  };
}
</script>
<template>
  <Row :gutter="16">
    <!-- ç”Ÿäº§è®¢å• -->
    <!-- æˆ‘的待办 -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('order')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'order')"
        @mouseleave="onMouseLeave('order')"
        @click="emit('navigate', 'MesProWorkOrder')"
        class="elite-card group cursor-pointer"
        @click="emit('navigate', 'BpmTaskTodo')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 shadow-lg shadow-blue-500/25 transition-transform duration-300 group-hover:scale-110">
            <IconifyIcon class="size-6 text-white" icon="lucide:clipboard-list" />
        <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="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">生产订单</div>
            <div class="flex items-baseline gap-1">
              <CountTo class="text-2xl font-bold leading-tight text-blue-600 dark:text-blue-400" :end-val="summary.workOrderActiveCount" :duration="1200" />
              <span class="text-xs text-gray-400">进行中</span>
            </div>
            <div class="mt-1 flex items-center gap-1 text-xs text-gray-400">
              <span class="inline-block size-1.5 rounded-full bg-amber-400" /> å¾…排产 {{ summary.workOrderPrepareCount }}
              <span class="mx-1">|</span>
              <span class="inline-block size-1.5 rounded-full bg-emerald-400" /> å·²å®Œæˆ {{ summary.workOrderFinishedCount }}
            </div>
          </div>
        </div>
        <div class="absolute inset-0 -z-0 bg-gradient-to-br from-blue-500/5 to-cyan-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
        <div class="absolute -right-6 -top-6 size-24 rounded-full bg-blue-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
      </div>
    </Col>
    <!-- ä»Šæ—¥äº§é‡ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('output')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'output')"
        @mouseleave="onMouseLeave('output')"
        @click="emit('navigate', 'MesProFeedback')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-green-400 shadow-lg shadow-emerald-500/25 transition-transform duration-300 group-hover:scale-110">
            <IconifyIcon class="size-6 text-white" icon="lucide:trending-up" />
          </div>
          <div class="min-w-0 flex-1">
            <div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">今日产量</div>
            <div class="flex items-baseline gap-1">
              <CountTo class="text-2xl font-bold leading-tight text-emerald-600 dark:text-emerald-400" :end-val="summary.todayOutput" :duration="1200" />
              <span class="text-xs text-gray-400">ä»¶</span>
            </div>
            <div class="mt-1 text-xs text-gray-400">
              æ˜¨æ—¥ {{ summary.yesterdayOutput }} ä»¶
              <span v-if="summary.yesterdayOutput > 0 && summary.todayOutput >= summary.yesterdayOutput" class="text-emerald-500">↑</span>
              <span v-else-if="summary.yesterdayOutput > 0" class="text-red-500">↓</span>
            </div>
          </div>
        </div>
        <div class="absolute inset-0 -z-0 bg-gradient-to-br from-emerald-500/5 to-green-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
        <div class="absolute -right-6 -top-6 size-24 rounded-full bg-emerald-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
      </div>
    </Col>
    <!-- è´¨é‡åˆæ ¼çއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('quality')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'quality')"
        @mouseleave="onMouseLeave('quality')"
        @click="emit('navigate', 'MesQcRecord')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25 transition-transform duration-300 group-hover:scale-110">
            <IconifyIcon class="size-6 text-white" icon="lucide:shield-check" />
          </div>
          <div class="min-w-0 flex-1">
            <div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">质量合格率</div>
            <div class="elite-label">我的待办</div>
            <div class="flex items-baseline gap-1">
              <CountTo
                class="text-2xl font-bold leading-tight"
                :class="qualityRate >= 95 ? 'text-emerald-600 dark:text-emerald-400' : qualityRate >= 80 ? 'text-orange-600 dark:text-orange-400' : 'text-red-600 dark:text-red-400'"
                :decimals="1" :end-val="qualityRate" :duration="1200"
                class="elite-value"
                :class="todoCount > 0 ? 'text-[#b8901f] dark:text-[#e3c567]' : ''"
                :duration="1200"
                :end-val="todoCount"
              />
              <span class="text-xs text-gray-400">%</span>
              <span class="elite-unit">项</span>
            </div>
            <div class="mt-1 text-xs text-gray-400">
              åˆæ ¼ {{ summary.todayQualifiedQuantity }} | ä¸è‰¯ {{ summary.todayUnqualifiedQuantity }}
            </div>
            <div class="elite-meta">审批中心 Â· å¾…处理任务</div>
          </div>
        </div>
        <div class="absolute inset-0 -z-0 bg-gradient-to-br from-orange-500/5 to-amber-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
        <div class="absolute -right-6 -top-6 size-24 rounded-full bg-orange-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
      </div>
    </Col>
    <!-- è®¾å¤‡è¿è¡Œçއ -->
    <!-- è¯¾é¢˜ç”³æŠ¥ -->
    <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('equip')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'equip')"
        @mouseleave="onMouseLeave('equip')"
        @click="emit('navigate', 'MesDvMachinery')"
        class="elite-card group cursor-pointer"
        @click="emit('navigate', 'ResearchApplication')"
      >
        <div class="relative z-10 flex items-center gap-4 p-5">
          <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-400 shadow-lg shadow-violet-500/25 transition-transform duration-300 group-hover:scale-110">
            <IconifyIcon class="size-6 text-white" icon="lucide:cpu" />
        <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="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">设备运行率</div>
            <div class="elite-label">课题申报</div>
            <div class="flex items-baseline gap-1">
              <CountTo class="text-2xl font-bold leading-tight text-violet-600 dark:text-violet-400" :decimals="1" :end-val="equipmentRate" :duration="1200" />
              <span class="text-xs text-gray-400">%</span>
              <CountTo
                class="elite-value"
                :duration="1200"
                :end-val="applicationCount"
              />
              <span class="elite-unit">项</span>
            </div>
            <div class="mt-1 flex items-center gap-1 text-xs text-gray-400">
              <span class="text-red-400">停机 {{ summary.machineryStop }}</span>
              <span class="mx-1">|</span>
              <span class="text-amber-400">维护 {{ summary.machineryMaintenance }}</span>
            </div>
            <div class="elite-meta">课题管理 Â· ç”³æŠ¥ä¹¦</div>
          </div>
        </div>
        <div class="absolute inset-0 -z-0 bg-gradient-to-br from-violet-500/5 to-purple-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
        <div class="absolute -right-6 -top-6 size-24 rounded-full bg-violet-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
      </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>
</template>
<style scoped>
.glass-tilt {
  background: linear-gradient(135deg, rgba(255,255,255,0.85), rgba(255,255,255,0.55));
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255,255,255,0.6);
  box-shadow:
    0 4px 24px rgba(0,0,0,0.04),
    0 1px 4px rgba(0,0,0,0.02),
    inset 0 1px 0 rgba(255,255,255,0.8);
  transform-style: preserve-3d;
/* ===== å­¦æœ¯å…¸é›…卡片 ===== */
.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;
}
.dark .glass-tilt {
  background: linear-gradient(135deg, rgba(30,30,55,0.75), rgba(20,20,40,0.5));
  border: 1px solid rgba(255,255,255,0.08);
  box-shadow:
    0 4px 24px rgba(0,0,0,0.3),
    0 1px 4px rgba(0,0,0,0.2),
    inset 0 1px 0 rgba(255,255,255,0.04);
.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;
}
</style>
src/views/dashboard/analytics/modules/production-trend.vue
ÎļþÒÑɾ³ý
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -11,62 +11,125 @@
  title: string;
  icon: string;
  route: string;
  gradient: string;
  shadow: string;
}
/** å¿«æ·å…¥å£ï¼šå¯¹åº”系统实际菜单(审批中心 / è¯¾é¢˜ç®¡ç† / è´¢åŠ¡æŠ¥é”€ / é€šçŸ¥å…¬å‘Šï¼‰ */
const actions: QuickAction[] = [
  { title: '生产工单', icon: 'lucide:file-text', route: 'MesProWorkOrder', gradient: 'from-blue-500 to-cyan-500', shadow: 'shadow-blue-500/15' },
  { title: '生产报工', icon: 'lucide:check-square', route: 'MesProFeedback', gradient: 'from-emerald-500 to-green-500', shadow: 'shadow-emerald-500/15' },
  { title: '质检管理', icon: 'lucide:clipboard-check', route: 'MesQcRecord', gradient: 'from-orange-500 to-amber-500', shadow: 'shadow-orange-500/15' },
  { title: '设备点检', icon: 'lucide:settings', route: 'MesDvMachinery', gradient: 'from-violet-500 to-purple-500', shadow: 'shadow-violet-500/15' },
  { title: '生产排程', icon: 'lucide:calendar-range', route: 'MesProScheduling', gradient: 'from-cyan-500 to-teal-500', shadow: 'shadow-cyan-500/15' },
  { title: '物料库存', icon: 'lucide:package-search', route: 'WlsMaterialStock', gradient: 'from-indigo-500 to-blue-500', shadow: 'shadow-indigo-500/15' },
  { title: '工艺路线', icon: 'lucide:route', route: 'MesPdRouting', gradient: 'from-teal-500 to-emerald-500', shadow: 'shadow-teal-500/15' },
  { title: '数据报表', icon: 'lucide:bar-chart-big', route: 'MesHomeIndex', gradient: 'from-pink-500 to-rose-500', shadow: 'shadow-pink-500/15' },
  { 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="glass-card rounded-2xl p-5">
    <h3 class="mb-4 text-sm font-semibold text-gray-800 dark:text-gray-100">快捷操作</h3>
    <div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
  <div class="elite-card p-5">
    <div class="elite-head">
      <h3 class="elite-title">
        <span class="elite-bar"></span>
        å¿«æ·å…¥å£
      </h3>
    </div>
    <div class="grid grid-cols-4 gap-3">
      <div
        v-for="action in actions"
        :key="action.route"
        class="group flex cursor-pointer flex-col items-center gap-2 rounded-xl p-3 transition-all duration-300 hover:-translate-y-1"
        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="flex size-10 items-center justify-center rounded-xl bg-gradient-to-br transition-shadow duration-300 group-hover:shadow-lg"
          :class="[action.gradient, action.shadow]"
          class="elite-icon flex size-10 items-center justify-center rounded-xl transition-shadow duration-300"
        >
          <IconifyIcon :icon="action.icon" class="size-5 text-white" />
          <IconifyIcon :icon="action.icon" class="size-5 text-[#e3c567]" />
        </div>
        <span class="text-xs font-medium text-gray-600 dark:text-gray-400">{{ action.title }}</span>
        <span class="elite-action-label">{{ action.title }}</span>
      </div>
    </div>
  </div>
</template>
<style scoped>
.glass-card {
  background: linear-gradient(135deg, rgba(255,255,255,0.85), rgba(255,255,255,0.55));
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255,255,255,0.6);
  box-shadow:
    0 4px 24px rgba(0,0,0,0.04),
    0 1px 4px rgba(0,0,0,0.02),
    inset 0 1px 0 rgba(255,255,255,0.8);
.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;
}
.dark .glass-card {
  background: linear-gradient(135deg, rgba(30,30,55,0.75), rgba(20,20,40,0.5));
  border: 1px solid rgba(255,255,255,0.08);
.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 4px 24px rgba(0,0,0,0.3),
    0 1px 4px rgba(0,0,0,0.2),
    inset 0 1px 0 rgba(255,255,255,0.04);
    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;
}
</style>
src/views/dashboard/analytics/modules/recent-applications.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,272 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { getApplicationPage } from '#/api/research/application';
defineOptions({ name: 'DashboardRecentApplications' });
const emit = defineEmits<{
  navigate: [name: string];
}>();
interface Row {
  id?: number;
  no?: string;
  title: string;
  applicantName: string;
  status?: number;
  createTime?: string;
}
/** çŠ¶æ€ç  â†’ å±•示文案与配色,与 research_application_status å­—典保持一致 */
const STATUS_MAP: Record<number, { text: string; tone: string }> = {
  0: { text: '草稿', tone: 'gray' },
  15: { text: '审批中', tone: 'gold' },
  20: { text: '已立项', tone: 'green' },
  35: { text: '已驳回', tone: 'red' },
};
function statusOf(code?: number) {
  if (code === undefined || code === null) {
    return { text: '—', tone: 'gray' };
  }
  return STATUS_MAP[code] ?? { text: String(code), tone: 'gray' };
}
const loading = ref(true);
const rows = ref<Row[]>([]);
async function load() {
  loading.value = true;
  try {
    const result = await getApplicationPage({ pageNo: 1, pageSize: 6 });
    rows.value = (result.list ?? []) as Row[];
  } catch {
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
onMounted(load);
</script>
<template>
  <div class="elite-section">
    <div class="elite-head">
      <h3 class="elite-title">
        <span class="elite-bar"></span>
        æœ€è¿‘课题申报
      </h3>
      <span class="elite-more" @click="emit('navigate', 'ResearchApplication')">
        å…¨éƒ¨
        <IconifyIcon class="size-3.5" icon="lucide:chevron-right" />
      </span>
    </div>
    <div v-if="loading" class="elite-empty">加载中…</div>
    <div v-else-if="rows.length === 0" class="elite-empty">
      <IconifyIcon class="mb-2 size-8 opacity-40" icon="lucide:file-text" />
      <div>暂无课题申报记录</div>
    </div>
    <table v-else class="elite-table">
      <thead>
        <tr>
          <th class="col-title">课题名称</th>
          <th class="col-applicant">申报人</th>
          <th class="col-status">状态</th>
          <th class="col-time">申报时间</th>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="row in rows"
          :key="row.id ?? row.no"
          @click="emit('navigate', 'ResearchApplication')"
        >
          <td class="col-title">
            <span class="elite-ellipsis">{{ row.title }}</span>
          </td>
          <td class="col-applicant">{{ row.applicantName || '—' }}</td>
          <td class="col-status">
            <span class="elite-chip" :class="`tone-${statusOf(row.status).tone}`">
              {{ statusOf(row.status).text }}
            </span>
          </td>
          <td class="col-time">{{ row.createTime || '—' }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</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-table {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
}
.elite-table th {
  padding: 0 8px 10px;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 1px;
  color: #8a94a6;
  text-align: left;
  border-bottom: 1px solid rgba(15, 36, 64, 0.08);
}
.elite-table td {
  padding: 12px 8px;
  font-size: 13.5px;
  color: #0f2440;
  border-bottom: 1px solid rgba(15, 36, 64, 0.05);
}
.dark .elite-table td {
  color: #e6e9ef;
}
.elite-table tbody tr {
  cursor: pointer;
  transition: background-color 0.2s ease;
}
.elite-table tbody tr:hover {
  background: rgba(212, 175, 55, 0.07);
}
.elite-table tbody tr:last-child td {
  border-bottom: none;
}
.col-title {
  width: 44%;
}
.col-applicant {
  width: 18%;
}
.col-status {
  width: 16%;
}
.col-time {
  width: 22%;
  color: #8a94a6;
}
.elite-ellipsis {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* çŠ¶æ€æ ‡ç­¾ï¼šé‡‘/绿/红/灰四档 */
.elite-chip {
  display: inline-block;
  padding: 2px 10px;
  font-size: 12px;
  border-radius: 999px;
  border: 1px solid transparent;
}
.tone-gold {
  color: #b8901f;
  background: rgba(212, 175, 55, 0.1);
  border-color: rgba(212, 175, 55, 0.35);
}
.tone-green {
  color: #2f6b4f;
  background: rgba(47, 107, 79, 0.1);
  border-color: rgba(47, 107, 79, 0.3);
}
.tone-red {
  color: #b4453c;
  background: rgba(180, 69, 60, 0.1);
  border-color: rgba(180, 69, 60, 0.3);
}
.tone-gray {
  color: #8a94a6;
  background: rgba(138, 148, 166, 0.1);
  border-color: rgba(138, 148, 166, 0.28);
}
</style>
src/views/dashboard/analytics/modules/todo-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,208 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
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())}`;
}
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;
  }
}
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>
<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;
}
</style>
src/views/finance/reimbursement/index.vue
@@ -110,8 +110,8 @@
    duration: 0,
  });
  try {
    await archiveReimbursement(row.id!);
    message.success('归档成功');
    const archiveNo = await archiveReimbursement(row.id!);
    message.success(`已归档并生成档案 ${archiveNo}`);
    handleRefresh();
  } finally {
    hideLoading();
src/views/research/application/data.ts
@@ -28,7 +28,9 @@
/** ç»è´¹æ‹¨ä»˜çŠ¶æ€ï¼ˆå¯¹åº”å­—å…¸ research_fund_status) */
export const FundStatus = {
  PENDING: 0, // å¾…拨付
  APPROVING: 15, // æŠ¥é”€å®¡æ‰¹ä¸­
  PAID: 20, // å·²æ‹¨ä»˜
  REJECTED: 30, // å·²é©³å›ž
} as const;
/** è¯¾é¢˜ç”³æŠ¥ä¹¦è¡¨å•配置 */
@@ -516,13 +518,25 @@
    {
      field: 'status',
      title: '拨付状态',
      minWidth: 100,
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.RESEARCH_FUND_STATUS },
      },
    },
    {
      field: 'reimbursementNo',
      title: '财务报销单号',
      minWidth: 160,
      formatter: dashIfEmpty,
    },
    {
      field: 'rejectReason',
      title: '驳回原因',
      minWidth: 200,
      formatter: dashIfEmpty,
    },
    {
      field: 'hasAttachment',
      title: '拨付凭证',
      minWidth: 100,
src/views/research/application/index.vue
@@ -12,6 +12,7 @@
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  archiveApplication,
  deleteApplication,
  getApplicationApproveProcessList,
  getApplicationPage,
@@ -142,6 +143,18 @@
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** å½’档到档案管理(仅已立项可归档) */
async function handleArchive(row: ResearchApplicationApi.Application) {
  const hideLoading = message.loading({ content: '归档中...', duration: 0 });
  try {
    const archiveNo = await archiveApplication(row.id!);
    message.success(`归档成功,已生成档案 ${archiveNo}`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
@@ -284,6 +297,19 @@
              onClick: handlePublishNotice.bind(null, row),
            },
            {
              label: '归档',
              type: 'link',
              icon: ACTION_ICON.UPLOAD,
              auth: ['research:application:archive'],
              ifShow: () =>
                row.status === ApplicationStatus.APPROVED &&
                !row.archiveDocumentId,
              popConfirm: {
                title: `确认归档课题 ${row.researchNo || row.no} å—?归档后将在档案管理生成对应档案`,
                confirm: handleArchive.bind(null, row),
              },
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
src/views/research/application/modules/fund-form.vue
@@ -3,9 +3,6 @@
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message } from 'ant-design-vue';
@@ -22,6 +19,8 @@
const isEdit = ref(false);
/** ç”³è¯·ç»è´¹å‡åŽ»å·²ç™»è®°æ‹¨ä»˜é‡‘é¢åŽçš„å¯ç”¨ä½™é¢ */
const remainder = ref<number>();
/** ç¼–辑「已驳回」记录时展示的财务驳回原因 */
const rejectReason = ref('');
const alertMessage = computed(() => {
  if (remainder.value === undefined) {
@@ -98,17 +97,6 @@
      },
    },
    {
      fieldName: 'status',
      label: '拨付状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_FUND_STATUS, 'number'),
        placeholder: '请选择拨付状态',
      },
      rules: 'selectRequired',
      help: '选择「已拨付」时必须上传拨付凭证附件',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
@@ -128,6 +116,8 @@
        multiple: true,
        showDescription: true,
      },
      rules: 'required',
      help: '必填:凭证将随自动生成的财务报销单一起送交财务审批',
      formItemClass: 'col-span-2',
    },
  ],
@@ -142,12 +132,9 @@
    }
    const values =
      (await formApi.getValues()) as ResearchFundRecordApi.FundRecord;
    // ã€Œå·²æ‹¨ä»˜ã€å¿…须有凭证,先在前端拦一道,避免用户白等一次请求
    if (
      values.status === FundStatus.PAID &&
      (!values.blobIds || values.blobIds.length === 0)
    ) {
      message.warning('拨付状态为「已拨付」时,请先上传拨付凭证附件');
    // å‡­è¯å¿…填,先在前端拦一道,避免用户白等一次请求
    if (!values.blobIds || values.blobIds.length === 0) {
      message.warning('请先上传本次拨付凭证,凭证将随财务报销单送交财务审批');
      return;
    }
    modalApi.lock();
@@ -159,15 +146,16 @@
      amount: values.amount,
      payDate: values.payDate,
      payee: values.payee,
      status: values.status,
      remark: values.remark,
      blobIds: values.blobIds ?? [],
      blobIds: values.blobIds,
    } as ResearchFundRecordApi.FundRecord;
    try {
      await (data.id ? updateFundRecord(data) : createFundRecord(data));
      await modalApi.close();
      emit('success');
      message.success('保存成功');
      message.success(
        data.id ? '已重新发起财务报销审批' : '登记成功,已自动提交财务报销审批',
      );
    } finally {
      modalApi.unlock();
    }
@@ -176,6 +164,7 @@
    if (!isOpen) {
      isEdit.value = false;
      remainder.value = undefined;
      rejectReason.value = '';
      return;
    }
    const data = modalApi.getData<{
@@ -185,6 +174,10 @@
    }>();
    isEdit.value = !!data.record;
    remainder.value = data.remainder;
    rejectReason.value =
      data.record?.status === FundStatus.REJECTED
        ? data.record.rejectReason || '财务未填写驳回原因'
        : '';
    await formApi.resetForm();
    if (data.record) {
      const blobIds = (data.record.attachmentList ?? []).map((item) => ({
@@ -206,7 +199,6 @@
      await formApi.setValues(
        {
          applicationId: data.applicationId,
          status: FundStatus.PENDING,
        },
        false,
      );
@@ -217,11 +209,18 @@
<template>
  <Modal
    :title="isEdit ? '编辑拨款记录' : '登记拨款记录'"
    :title="isEdit ? '修改并重新发起报销' : '登记拨款(登记后自动提交财务报销审批)'"
    class="w-1/2"
    :close-on-click-modal="false"
  >
    <Alert
      v-if="rejectReason"
      class="mx-3 mb-3"
      type="error"
      show-icon
      :message="`财务报销已驳回:${rejectReason}`"
    />
    <Alert
      v-if="alertMessage"
      class="mx-3 mb-3"
      type="info"
src/views/research/application/modules/fund-panel.vue
@@ -39,6 +39,10 @@
  () => (Number(application.value?.budgetAmount) || 0) - registeredAmount.value,
);
/** å„状态记录数量,用于顶部提示(0 æ¡æ—¶ä¸æç¤ºï¼‰ */
const statusCount = (status: number) =>
  records.value.filter((item) => item.status === status).length;
const [FundFormModal, fundFormModalApi] = useVbenModal({
  connectedComponent: FundForm,
  destroyOnClose: true,
@@ -176,6 +180,25 @@
        </div>
      </div>
      <Alert
        v-if="statusCount(FundStatus.APPROVING) > 0"
        type="info"
        show-icon
        :message="`有 ${statusCount(FundStatus.APPROVING)} æ¡æ‹¨ä»˜è®°å½•正在报销审批中,暂不可修改;财务审批结果将自动回写本记录`"
      />
      <Alert
        v-if="statusCount(FundStatus.PAID) > 0"
        type="success"
        show-icon
        :message="`有 ${statusCount(FundStatus.PAID)} æ¡æ‹¨ä»˜è®°å½•财务已通过,拨款完成`"
      />
      <Alert
        v-if="statusCount(FundStatus.REJECTED) > 0"
        type="error"
        show-icon
        :message="`有 ${statusCount(FundStatus.REJECTED)} æ¡æ‹¨ä»˜è®°å½•财务已驳回,请修改金额或凭证后点「重新发起报销」`"
      />
      <div v-if="editable">
        <TableAction
          :actions="[
@@ -193,13 +216,7 @@
      <Grid>
        <template #hasAttachment="{ row }">
          <Tag :color="row.hasAttachment ? 'success' : 'warning'">
            {{
              row.hasAttachment
                ? '已上传'
                : row.status === FundStatus.PAID
                  ? '缺失'
                  : '不需要'
            }}
            {{ row.hasAttachment ? '已上传' : '缺失' }}
          </Tag>
        </template>
        <template #actions="{ row }">
@@ -215,6 +232,14 @@
                onClick: handleEdit.bind(null, row),
              },
              {
                label: '重新发起报销',
                type: 'link',
                icon: ACTION_ICON.REFRESH,
                auth: ['research:fund:update'],
                ifShow: () => row.status === FundStatus.REJECTED,
                onClick: handleEdit.bind(null, row),
              },
              {
                label: '删除',
                type: 'link',
                danger: true,
src/views/ums/member/index.vue
@@ -13,9 +13,18 @@
import { getDictOptions } from '@vben/hooks';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMember, getMemberPage } from '#/api/ums/member';
import {
  archiveMember,
  deleteMember,
  getMemberPage,
} from '#/api/ums/member';
import { MemberType, useGridColumns, useGridFormSchema } from './data';
import {
  MemberStatus,
  MemberType,
  useGridColumns,
  useGridFormSchema,
} from './data';
import Form from './modules/form.vue';
/** ä¼šå‘˜åˆ—表 */
@@ -64,6 +73,18 @@
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({ records }: { records: UmsMemberApi.Member[] }) {
  checkedIds.value = records.map((item) => item.id!);
}
/** å½’档到档案管理(仅正常状态会员可归档) */
async function handleArchive(row: UmsMemberApi.Member) {
  const hideLoading = message.loading({ content: '归档中...', duration: 0 });
  try {
    const archiveNo = await archiveMember(row.id!);
    message.success(`归档成功,已生成档案 ${archiveNo}`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** ç­‰çº§å­—典映射:根据会员类型显示对应的等级名称 */
@@ -165,6 +186,18 @@
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '归档',
              type: 'link',
              icon: ACTION_ICON.UPLOAD,
              auth: ['ums:member:archive'],
              ifShow: () =>
                row.status === MemberStatus.NORMAL && !row.archiveDocumentId,
              popConfirm: {
                title: `确认归档会员 ${row.name} å—?归档后将在档案管理生成对应档案`,
                confirm: handleArchive.bind(null, row),
              },
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
src/views/wxcp/activity/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,240 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import {
  ACTIVITY_STATUS_OPTIONS,
  PUSH_SCOPE_OPTIONS,
  PUSH_STATUS_OPTIONS,
  SIGNUP_STATUS_OPTIONS,
} from '../data';
/** è¡¨å•类型 */
export type ActivityFormType = 'create' | 'update' | 'detail';
const DATETIME_PROPS = {
  showTime: true,
  valueFormat: 'YYYY-MM-DD HH:mm:ss',
  format: 'YYYY-MM-DD HH:mm:ss',
} as const;
/** æ´»åŠ¨è¡¨å• */
export function useActivityFormSchema(
  formType: ActivityFormType,
): VbenFormSchema[] {
  const readonly = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'title',
      label: '活动标题',
      component: 'Input',
      componentProps: {
        placeholder: '请输入活动标题',
        disabled: readonly,
      },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'location',
      label: '活动地点',
      component: 'Input',
      componentProps: { placeholder: '如:公司大会议室', disabled: readonly },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'startTime',
      label: '活动开始',
      component: 'DatePicker',
      componentProps: { ...DATETIME_PROPS, disabled: readonly },
    },
    {
      fieldName: 'endTime',
      label: '活动结束',
      component: 'DatePicker',
      componentProps: { ...DATETIME_PROPS, disabled: readonly },
    },
    {
      fieldName: 'signupStartTime',
      label: '报名开始',
      component: 'DatePicker',
      componentProps: { ...DATETIME_PROPS, disabled: readonly },
    },
    {
      fieldName: 'signupEndTime',
      label: '报名截止',
      component: 'DatePicker',
      componentProps: { ...DATETIME_PROPS, disabled: readonly },
    },
    {
      fieldName: 'signupLimit',
      label: '报名上限',
      component: 'InputNumber',
      componentProps: {
        placeholder: '不限请填 0',
        min: 0,
        class: 'w-full',
        disabled: readonly,
      },
      rules: 'required',
      help: 'å¡« 0 è¡¨ç¤ºä¸é™åé¢',
    },
    {
      fieldName: 'coverUrl',
      label: '封面图',
      component: 'Input',
      componentProps: { placeholder: '封面图地址(选填)', disabled: readonly },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'content',
      label: '活动详情',
      component: 'Textarea',
      componentProps: {
        rows: 4,
        placeholder: '活动介绍、注意事项等(报名页按纯文本展示)',
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: { rows: 2, placeholder: '内部备注,不展示给成员', disabled: readonly },
      formItemClass: 'col-span-2',
    },
  ];
}
/** æ´»åŠ¨æœç´¢è¡¨å• */
export function useActivitySearchFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'title',
      label: '活动标题',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入活动标题' },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: ACTIVITY_STATUS_OPTIONS,
        placeholder: '请选择状态',
      },
    },
    {
      fieldName: 'pushStatus',
      label: '推送状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: PUSH_STATUS_OPTIONS,
        placeholder: '请选择推送状态',
      },
    },
  ];
}
/** æ´»åŠ¨åˆ—è¡¨å­—æ®µ */
export function useActivityGridColumns(): VxeTableGridOptions<WxCpActivityApi.Activity>['columns'] {
  return [
    {
      field: 'title',
      title: '活动标题',
      minWidth: 200,
      slots: { default: 'title' },
    },
    { field: 'location', title: '活动地点', minWidth: 140 },
    { field: 'startTime', title: '活动开始', width: 170 },
    { field: 'endTime', title: '活动结束', width: 170 },
    {
      field: 'signupCount',
      title: '报名/上限',
      width: 110,
      slots: { default: 'signupCount' },
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      slots: { default: 'status' },
    },
    {
      field: 'pushStatus',
      title: '推送',
      width: 90,
      slots: { default: 'pushStatus' },
    },
    { field: 'createTime', title: '创建时间', width: 170 },
    { title: '操作', width: 300, fixed: 'right', slots: { default: 'actions' } },
  ];
}
/** æŠ¥åè®°å½•搜索表单 */
export function useSignupSearchFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '姓名',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入姓名' },
    },
    {
      fieldName: 'mobile',
      label: '手机号',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入手机号' },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: SIGNUP_STATUS_OPTIONS,
        placeholder: '请选择状态',
      },
    },
  ];
}
/** æŠ¥åè®°å½•列表字段 */
export function useSignupGridColumns(): VxeTableGridOptions<WxCpActivityApi.Signup>['columns'] {
  return [
    { field: 'name', title: '姓名', minWidth: 100 },
    { field: 'mobile', title: '手机号', width: 130 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    { field: 'wxUserId', title: '企业微信账号', minWidth: 110 },
    {
      field: 'content',
      title: '参与说明',
      minWidth: 160,
      slots: { default: 'content' },
    },
    {
      field: 'attachment',
      title: '附件',
      width: 80,
      slots: { default: 'attachment' },
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      slots: { default: 'status' },
    },
    { field: 'signupTime', title: '报名时间', width: 170 },
    { field: 'checkInTime', title: '签到时间', width: 170 },
    { title: '操作', width: 150, fixed: 'right', slots: { default: 'actions' } },
  ];
}
src/views/wxcp/activity/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,266 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  cancelActivity,
  deleteActivity,
  finishActivity,
  getActivityPage,
  publishActivity,
} from '#/api/wxcp/activity';
import {
  ACTIVITY_STATUS,
  ACTIVITY_STATUS_OPTIONS,
  toLabelMap,
} from '../data';
import {
  useActivityGridColumns,
  useActivitySearchFormSchema,
} from './data';
import ActivityForm from './modules/form.vue';
import PushModal from './modules/push-modal.vue';
import QrCodeModal from './modules/qrcode-modal.vue';
import SignupListModal from './modules/signup-list.vue';
import StatsModal from './modules/stats-modal.vue';
defineOptions({ name: 'WxCpActivityList' });
const statusLabelMap = toLabelMap(ACTIVITY_STATUS_OPTIONS);
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: ActivityForm,
  destroyOnClose: true,
});
const [PushModalComp, pushModalApi] = useVbenModal({
  connectedComponent: PushModal,
  destroyOnClose: true,
});
const [QrCodeModalComp, qrCodeModalApi] = useVbenModal({
  connectedComponent: QrCodeModal,
  destroyOnClose: true,
});
const [StatsModalComp, statsModalApi] = useVbenModal({
  connectedComponent: StatsModal,
  destroyOnClose: true,
});
const [SignupModalComp, signupModalApi] = useVbenModal({
  connectedComponent: SignupListModal,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleDetail(row: WxCpActivityApi.Activity) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
function handleEdit(row: WxCpActivityApi.Activity) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
async function handleDelete(row: WxCpActivityApi.Activity) {
  await deleteActivity(row.id!);
  message.success('删除成功');
  handleRefresh();
}
async function handlePublish(row: WxCpActivityApi.Activity) {
  await publishActivity(row.id!);
  message.success('发布成功,现在可以推送和生成二维码了');
  handleRefresh();
}
async function handleFinish(row: WxCpActivityApi.Activity) {
  await finishActivity(row.id!);
  message.success('已结束');
  handleRefresh();
}
async function handleCancel(row: WxCpActivityApi.Activity) {
  await cancelActivity(row.id!);
  message.success('已取消');
  handleRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useActivitySearchFormSchema(),
  },
  gridOptions: {
    columns: useActivityGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getActivityPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<WxCpActivityApi.Activity>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <PushModalComp @success="handleRefresh" />
    <QrCodeModalComp />
    <StatsModalComp />
    <SignupModalComp @success="handleRefresh" />
    <Grid table-title="企业微信活动列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新建活动',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['wxcp:activity:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #title="{ row }">
        <Button type="link" class="p-0" @click="handleDetail(row)">
          {{ row.title }}
        </Button>
      </template>
      <template #signupCount="{ row }">
        <span>
          {{ row.signupCount ?? 0 }} /
          {{ row.signupLimit && row.signupLimit > 0 ? row.signupLimit : '不限' }}
        </span>
      </template>
      <template #status="{ row }">
        <Tag
          :color="
            row.status === ACTIVITY_STATUS.PUBLISHED
              ? 'green'
              : row.status === ACTIVITY_STATUS.DRAFT
                ? 'default'
                : row.status === ACTIVITY_STATUS.FINISHED
                  ? 'blue'
                  : 'red'
          "
        >
          {{ statusLabelMap[row.status!] || '-' }}
        </Tag>
      </template>
      <template #pushStatus="{ row }">
        <Tag :color="row.pushStatus === 1 ? 'green' : 'default'">
          {{ row.pushStatus === 1 ? '已推送' : '未推送' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '编辑',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['wxcp:activity:update'],
              ifShow: row.status !== ACTIVITY_STATUS.FINISHED && row.status !== ACTIVITY_STATUS.CANCELED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '发布',
              type: 'link',
              auth: ['wxcp:activity:update'],
              ifShow: row.status === ACTIVITY_STATUS.DRAFT,
              popConfirm: {
                title: '发布后成员即可通过二维码报名,确认发布?',
                confirm: handlePublish.bind(null, row),
              },
            },
            {
              label: '推送',
              type: 'link',
              auth: ['wxcp:activity:push'],
              ifShow: row.status === ACTIVITY_STATUS.PUBLISHED,
              onClick: () => pushModalApi.setData({ activity: row }).open(),
            },
            {
              label: '二维码',
              type: 'link',
              ifShow: row.status === ACTIVITY_STATUS.PUBLISHED,
              onClick: () => qrCodeModalApi.setData({ activity: row }).open(),
            },
            {
              label: '统计',
              type: 'link',
              auth: ['wxcp:activity:query'],
              onClick: () => statsModalApi.setData({ activity: row }).open(),
            },
            {
              label: '报名记录',
              type: 'link',
              auth: ['wxcp:signup:query'],
              onClick: () => signupModalApi.setData({ activity: row }).open(),
            },
          ]"
          :drop-down-actions="[
            {
              label: '结束',
              type: 'link',
              auth: ['wxcp:activity:update'],
              ifShow: row.status === ACTIVITY_STATUS.PUBLISHED,
              popConfirm: {
                title: '结束后成员将无法再报名,确认结束?',
                confirm: handleFinish.bind(null, row),
              },
            },
            {
              label: '取消',
              type: 'link',
              auth: ['wxcp:activity:update'],
              ifShow: row.status === ACTIVITY_STATUS.DRAFT || row.status === ACTIVITY_STATUS.PUBLISHED,
              popConfirm: {
                title: `确认取消活动【${row.title}】?`,
                confirm: handleCancel.bind(null, row),
              },
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['wxcp:activity:delete'],
              ifShow: row.status === ACTIVITY_STATUS.DRAFT,
              popConfirm: {
                title: `确认删除活动【${row.title}】?`,
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/wxcp/activity/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import type { ActivityFormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createActivity,
  getActivity,
  updateActivity,
} from '#/api/wxcp/activity';
import { useActivityFormSchema } from '../data';
defineOptions({ name: 'WxCpActivityForm' });
const emit = defineEmits<{ success: [] }>();
const formType = ref<ActivityFormType>('create');
const id = ref<number>();
const submitting = ref(false);
const isEdit = computed(() => formType.value !== 'detail');
const title = computed(() => {
  if (formType.value === 'detail') return '查看活动';
  if (id.value) return '编辑活动';
  return '新建活动';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 90,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      id.value = undefined;
      return;
    }
    const data = modalApi.getData<{ formType: ActivityFormType; id?: number }>();
    formType.value = data.formType;
    id.value = data.id;
    formApi.setState({ schema: useActivityFormSchema(formType.value) });
    formApi.setDisabled(formType.value === 'detail');
    if (data.id) {
      modalApi.lock();
      try {
        const detail = await getActivity(data.id);
        await formApi.setValues(detail as unknown as Record<string, unknown>);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
    }
  },
});
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid) {
    return;
  }
  submitting.value = true;
  try {
    const data = (await formApi.getValues()) as WxCpActivityApi.Activity;
    if (id.value) {
      await updateActivity({ ...data, id: id.value });
      message.success('修改成功');
    } else {
      await createActivity(data);
      message.success('创建成功,请在列表中发布后再推送');
    }
    await modalApi.close();
    emit('success');
  } catch (error: unknown) {
    message.error((error as Error)?.message || '提交失败');
  } finally {
    submitting.value = false;
  }
}
</script>
<template>
  <Modal :title="title" class="w-3/5">
    <Form class="mx-4" />
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
        <Button
          v-if="isEdit"
          type="primary"
          :loading="submitting"
          @click="handleSubmit"
        >
          ç¡®è®¤
        </Button>
        <Button v-else type="primary" @click="modalApi.close()">关闭</Button>
      </div>
    </template>
  </Modal>
</template>
src/views/wxcp/activity/modules/push-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, message } from 'ant-design-vue';
import { pushActivity } from '#/api/wxcp/activity';
import PushScopePicker from '../../components/push-scope-picker.vue';
import { PUSH_SCOPE } from '../../data';
defineOptions({ name: 'WxCpActivityPushModal' });
const emit = defineEmits<{ success: [] }>();
const activity = ref<WxCpActivityApi.Activity>({});
const scope = ref<number>(PUSH_SCOPE.ALL);
const target = ref<string>('');
const pushing = ref(false);
const resultMessage = ref('');
const resultSuccess = ref(true);
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      activity.value = {};
      resultMessage.value = '';
      return;
    }
    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
    activity.value = data.activity ?? {};
    // æ²¿ç”¨æ´»åŠ¨ä¸Šå·²é…ç½®çš„æŽ¨é€èŒƒå›´ä¸Žç›®æ ‡ï¼Œé¿å…æ¯æ¬¡éƒ½è¦é‡é€‰
    scope.value = data.activity?.pushScope ?? PUSH_SCOPE.ALL;
    target.value = data.activity?.pushTarget ?? '';
  },
});
async function handlePush() {
  if (!activity.value.id) {
    return;
  }
  if (scope.value !== PUSH_SCOPE.ALL && !target.value.trim()) {
    message.warning('选择「指定部门」或「指定成员」时,请先选择推送目标');
    return;
  }
  pushing.value = true;
  resultMessage.value = '';
  try {
    const res = await pushActivity({
      id: activity.value.id,
      pushScope: scope.value,
      pushTarget: scope.value === PUSH_SCOPE.ALL ? '' : target.value,
    });
    resultSuccess.value = !!res?.success;
    if (res?.success) {
      message.success('推送成功');
      emit('success');
      await modalApi.close();
    } else {
      // æŽ¨é€å¤±è´¥ä¸æŠ›å¼‚常,把企业微信给的原因直接展示给用户
      resultMessage.value = res?.message || '推送失败';
    }
  } catch (error: unknown) {
    resultSuccess.value = false;
    resultMessage.value = (error as Error)?.message || '推送失败';
  } finally {
    pushing.value = false;
  }
}
</script>
<template>
  <Modal title="推送活动" class="w-2/5">
    <div class="px-4">
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm dark:bg-gray-800">
        <div class="mb-1 font-medium">{{ activity.title }}</div>
        <div class="text-gray-500">
          æŽ¨é€åŽæˆå‘˜çš„企业微信会收到卡片消息,点击「立即报名」直接打开报名页。
        </div>
      </div>
      <div class="mb-2 text-sm font-medium">推送范围</div>
      <PushScopePicker v-model:scope="scope" v-model:target="target" />
      <Alert
        v-if="resultMessage"
        class="mt-4"
        :type="resultSuccess ? 'success' : 'error'"
        :message="resultMessage"
        show-icon
      />
    </div>
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
        <Button type="primary" :loading="pushing" @click="handlePush">
          ç¡®è®¤æŽ¨é€
        </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/wxcp/activity/modules/qrcode-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,148 @@
<script lang="ts" setup>
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { onUnmounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message, Spin } from 'ant-design-vue';
import {
  getActivityQrCodeBlob,
  getActivitySignupUrl,
} from '#/api/wxcp/activity';
defineOptions({ name: 'WxCpActivityQrCodeModal' });
const activity = ref<WxCpActivityApi.Activity>({});
const qrCodeUrl = ref('');
const signupUrl = ref('');
const loading = ref(false);
const loadError = ref('');
/** é‡Šæ”¾ä¸Šä¸€æ¬¡çš„ objectURL,避免内存泄漏 */
function releaseObjectUrl() {
  if (qrCodeUrl.value) {
    URL.revokeObjectURL(qrCodeUrl.value);
    qrCodeUrl.value = '';
  }
}
async function loadQrCode(id: number) {
  loading.value = true;
  loadError.value = '';
  releaseObjectUrl();
  try {
    // äºŒç»´ç æŽ¥å£è¿”回 PNG äºŒè¿›åˆ¶ï¼Œä¸” <img> å¸¦ä¸äº†è¯·æ±‚头,因此走 blob + objectURL
    const res = (await getActivityQrCodeBlob(id, 400)) as unknown as {
      data?: Blob;
    };
    const blob = res?.data instanceof Blob ? res.data : (res as unknown as Blob);
    if (!(blob instanceof Blob)) {
      throw new Error('二维码返回格式异常');
    }
    qrCodeUrl.value = URL.createObjectURL(blob);
  } catch (error: unknown) {
    loadError.value = (error as Error)?.message || '二维码加载失败';
  } finally {
    loading.value = false;
  }
}
async function loadSignupUrl(id: number) {
  try {
    signupUrl.value = await getActivitySignupUrl(id);
  } catch {
    // æŠ¥åé“¾æŽ¥å–不到不影响二维码展示,静默处理
    signupUrl.value = '';
  }
}
function handleDownload() {
  if (!qrCodeUrl.value || !activity.value.id) {
    return;
  }
  const link = document.createElement('a');
  link.href = qrCodeUrl.value;
  link.download = `活动报名码-${activity.value.title || activity.value.id}.png`;
  document.body.append(link);
  link.click();
  link.remove();
}
async function handleCopyLink() {
  if (!signupUrl.value) {
    message.warning('暂无报名链接');
    return;
  }
  try {
    await navigator.clipboard.writeText(signupUrl.value);
    message.success('报名链接已复制');
  } catch {
    message.warning('复制失败,请手动选中链接复制');
  }
}
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      activity.value = {};
      signupUrl.value = '';
      loadError.value = '';
      releaseObjectUrl();
      return;
    }
    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
    activity.value = data.activity ?? {};
    if (activity.value.id) {
      loadQrCode(activity.value.id);
      loadSignupUrl(activity.value.id);
    }
  },
});
onUnmounted(releaseObjectUrl);
</script>
<template>
  <Modal title="活动报名二维码" class="w-2/5">
    <div class="flex flex-col items-center px-4">
      <div class="mb-4 text-center text-sm text-gray-500">
        {{ activity.title }}
      </div>
      <Spin :spinning="loading">
        <div
          class="flex size-70 items-center justify-center rounded border border-dashed border-gray-300"
        >
          <img
            v-if="qrCodeUrl"
            :src="qrCodeUrl"
            alt="活动报名二维码"
            class="size-65 object-contain"
          />
          <div v-else class="px-6 text-center text-sm text-gray-400">
            {{ loadError || '二维码加载中…' }}
          </div>
        </div>
      </Spin>
      <div class="mt-4 w-full text-xs text-gray-400 break-all">
        <div v-if="signupUrl">报名链接:{{ signupUrl }}</div>
        <div class="mt-2">
          æˆå‘˜ç”¨ä¼ä¸šå¾®ä¿¡æ‰«ä¸€æ‰«å³å¯æ‰“开报名页并提交报名。
        </div>
      </div>
    </div>
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button :disabled="!signupUrl" @click="handleCopyLink">
          å¤åˆ¶æŠ¥åé“¾æŽ¥
        </Button>
        <Button type="primary" :disabled="!qrCodeUrl" @click="handleDownload">
          ä¸‹è½½äºŒç»´ç 
        </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/wxcp/activity/modules/signup-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,242 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
  Button,
  Empty,
  message,
  Modal as AntModal,
  Spin,
  Tag,
  Tooltip,
} from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  cancelSignup,
  checkInSignup,
  getSignupAttachments,
  getSignupPage,
} from '#/api/wxcp/activity';
import { SIGNUP_STATUS, SIGNUP_STATUS_OPTIONS, toLabelMap } from '../../data';
import { useSignupGridColumns, useSignupSearchFormSchema } from '../data';
defineOptions({ name: 'WxCpActivitySignupList' });
const emit = defineEmits<{ success: [] }>();
const statusLabelMap = toLabelMap(SIGNUP_STATUS_OPTIONS);
const activity = ref<WxCpActivityApi.Activity>({});
/** é™„件查看 */
const attachmentVisible = ref(false);
const attachmentLoading = ref(false);
const attachments = ref<WxCpActivityApi.SignupAttachment[]>([]);
const attachmentTitle = ref('');
async function handleViewAttachments(row: WxCpActivityApi.Signup) {
  if (!row.id) {
    return;
  }
  attachmentTitle.value = `${row.name || ''} æäº¤çš„附件`;
  attachmentVisible.value = true;
  attachmentLoading.value = true;
  attachments.value = [];
  try {
    attachments.value = (await getSignupAttachments(row.id)) ?? [];
  } catch (error: unknown) {
    message.error((error as Error)?.message || '附件加载失败');
  } finally {
    attachmentLoading.value = false;
  }
}
function isImage(item: WxCpActivityApi.SignupAttachment) {
  return (item.contentType || '').startsWith('image/');
}
function formatSize(bytes?: number) {
  if (!bytes || bytes <= 0) {
    return '';
  }
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
async function handleCancel(row: WxCpActivityApi.Signup) {
  await cancelSignup(row.id!);
  message.success('已取消该报名');
  gridApi.query();
  emit('success');
}
async function handleCheckIn(row: WxCpActivityApi.Signup) {
  await checkInSignup(row.id!);
  message.success('签到成功');
  gridApi.query();
  emit('success');
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useSignupSearchFormSchema(),
  },
  gridOptions: {
    columns: useSignupGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          if (!activity.value.id) {
            return { list: [], total: 0 };
          }
          return await getSignupPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            activityId: activity.value.id,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<WxCpActivityApi.Signup>,
});
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      activity.value = {};
      return;
    }
    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
    activity.value = data.activity ?? {};
    // æ´»åЍID就绪后再查询
    gridApi.query();
  },
});
</script>
<template>
  <Modal :title="`报名记录 - ${activity.title || ''}`" class="w-11/12">
    <div class="px-4">
      <Grid table-title="报名记录">
        <template #content="{ row }">
          <Tooltip v-if="row.content" :title="row.content">
            <span class="line-clamp-2">{{ row.content }}</span>
          </Tooltip>
          <span v-else class="text-gray-400">-</span>
        </template>
        <template #attachment="{ row }">
          <Button type="link" class="p-0" @click="handleViewAttachments(row)">
            æŸ¥çœ‹
          </Button>
        </template>
        <template #status="{ row }">
          <Tag
            :color="
              row.status === SIGNUP_STATUS.CHECKED_IN
                ? 'green'
                : row.status === SIGNUP_STATUS.CANCELED
                  ? 'default'
                  : 'blue'
            "
          >
            {{ statusLabelMap[row.status!] || '-' }}
          </Tag>
        </template>
        <template #actions="{ row }">
          <TableAction
            :actions="[
              {
                label: '签到',
                type: 'link',
                auth: ['wxcp:signup:update'],
                ifShow: row.status === SIGNUP_STATUS.SIGNED_UP,
                popConfirm: {
                  title: `确认为【${row.name}】签到?`,
                  confirm: handleCheckIn.bind(null, row),
                },
              },
              {
                label: '取消报名',
                type: 'link',
                danger: true,
                auth: ['wxcp:signup:update'],
                ifShow: row.status !== SIGNUP_STATUS.CANCELED,
                popConfirm: {
                  title: `确认取消【${row.name}】的报名?取消后报名人数会相应减少。`,
                  confirm: handleCancel.bind(null, row),
                },
              },
            ]"
          />
        </template>
      </Grid>
    </div>
    <template #footer>
      <div class="flex justify-end">
        <Button @click="modalApi.close()">关闭</Button>
      </div>
    </template>
    <!-- é™„件查看 -->
    <AntModal
      v-model:open="attachmentVisible"
      :title="attachmentTitle"
      :footer="null"
      width="640px"
    >
      <Spin :spinning="attachmentLoading">
        <div v-if="attachments.length" class="space-y-3">
          <div
            v-for="item in attachments"
            :key="item.id"
            class="rounded border border-gray-200 p-3 dark:border-gray-700"
          >
            <div class="mb-2 text-sm break-all">
              {{ item.originalFilename || item.name || `附件${item.id}` }}
              <span class="ml-2 text-xs text-gray-400">
                {{ formatSize(item.byteSize) }}
              </span>
            </div>
            <a
              v-if="isImage(item)"
              :href="item.previewURL || item.url"
              target="_blank"
              rel="noopener noreferrer"
            >
              <img
                :src="item.previewURL || item.url"
                alt="附件预览"
                class="max-h-40 rounded object-contain"
              />
            </a>
            <a
              :href="item.downloadURL || item.url"
              target="_blank"
              rel="noopener noreferrer"
              class="text-xs text-blue-500 hover:underline"
            >
              ä¸‹è½½
            </a>
          </div>
        </div>
        <Empty v-else-if="!attachmentLoading" description="该报名未上传附件" />
      </Spin>
    </AntModal>
  </Modal>
</template>
src/views/wxcp/activity/modules/stats-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { WxCpActivityApi } from '#/api/wxcp/activity';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Progress, Spin } from 'ant-design-vue';
import { getActivityStats } from '#/api/wxcp/activity';
defineOptions({ name: 'WxCpActivityStatsModal' });
const activity = ref<WxCpActivityApi.Activity>({});
const stats = ref<null | WxCpActivityApi.ActivityStats>(null);
const loading = ref(false);
const loadError = ref('');
/** ä¸é™åé¢æ—¶ä¸å±•示进度条 */
const hasLimit = computed(
  () => !!stats.value?.signupLimit && stats.value.signupLimit > 0,
);
async function loadStats(id: number) {
  loading.value = true;
  loadError.value = '';
  try {
    stats.value = await getActivityStats(id);
  } catch (error: unknown) {
    loadError.value = (error as Error)?.message || '统计加载失败';
  } finally {
    loading.value = false;
  }
}
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      stats.value = null;
      activity.value = {};
      loadError.value = '';
      return;
    }
    const data = modalApi.getData<{ activity: WxCpActivityApi.Activity }>();
    activity.value = data.activity ?? {};
    if (activity.value.id) {
      loadStats(activity.value.id);
    }
  },
});
</script>
<template>
  <Modal title="报名统计" class="w-3/5">
    <Spin :spinning="loading">
      <div class="px-4">
        <div class="mb-3 text-sm text-gray-500">
          {{ stats?.activityTitle || activity.title }}
        </div>
        <div v-if="loadError" class="py-6 text-center text-sm text-red-500">
          {{ loadError }}
        </div>
        <template v-else-if="stats">
          <!-- å…³é”®æŒ‡æ ‡ -->
          <div class="mb-4 grid grid-cols-4 gap-3">
            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
              <div class="text-xl font-semibold">
                {{ stats.totalSignup }}
              </div>
              <div class="mt-1 text-xs text-gray-500">有效报名</div>
            </div>
            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
              <div class="text-xl font-semibold">
                {{ stats.signedUpCount }}
              </div>
              <div class="mt-1 text-xs text-gray-500">未签到</div>
            </div>
            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
              <div class="text-xl font-semibold">
                {{ stats.checkedInCount }}
              </div>
              <div class="mt-1 text-xs text-gray-500">已签到</div>
            </div>
            <div class="rounded bg-gray-50 p-3 text-center dark:bg-gray-800">
              <div class="text-xl font-semibold">
                {{ stats.canceledCount }}
              </div>
              <div class="mt-1 text-xs text-gray-500">已取消</div>
            </div>
          </div>
          <!-- è¿›åº¦ -->
          <div v-if="hasLimit" class="mb-5">
            <div class="mb-1 flex justify-between text-xs text-gray-500">
              <span>报名进度</span>
              <span>
                {{ stats.totalSignup }} / {{ stats.signupLimit }}
                ï¼ˆå‰©ä½™ {{ stats.remaining ?? 0 }})
              </span>
            </div>
            <Progress :percent="stats.signupPercent ?? 0" />
          </div>
          <div v-else class="mb-5 text-xs text-gray-500">
            æœ¬æ¬¡æ´»åŠ¨ä¸é™åé¢ï¼Œå½“å‰å·²æŠ¥å {{ stats.totalSignup }} äºº
          </div>
          <!-- åˆ†å¸ƒ -->
          <div class="grid grid-cols-3 gap-4 text-sm">
            <div>
              <div class="mb-2 font-medium">按部门</div>
              <div v-if="stats.deptStats?.length" class="space-y-1">
                <div
                  v-for="item in stats.deptStats"
                  :key="item.name"
                  class="flex justify-between text-gray-600"
                >
                  <span class="truncate">{{ item.name }}</span>
                  <span>{{ item.count }}</span>
                </div>
              </div>
              <div v-else class="text-xs text-gray-400">暂无数据</div>
            </div>
            <div>
              <div class="mb-2 font-medium">按报名日期</div>
              <div v-if="stats.dateStats?.length" class="space-y-1">
                <div
                  v-for="item in stats.dateStats"
                  :key="item.name"
                  class="flex justify-between text-gray-600"
                >
                  <span>{{ item.name }}</span>
                  <span>{{ item.count }}</span>
                </div>
              </div>
              <div v-else class="text-xs text-gray-400">暂无数据</div>
            </div>
            <div>
              <div class="mb-2 font-medium">按来源</div>
              <div v-if="stats.sourceStats?.length" class="space-y-1">
                <div
                  v-for="item in stats.sourceStats"
                  :key="item.name"
                  class="flex justify-between text-gray-600"
                >
                  <span>{{ item.name }}</span>
                  <span>{{ item.count }}</span>
                </div>
              </div>
              <div v-else class="text-xs text-gray-400">暂无数据</div>
            </div>
          </div>
          <div class="mt-4 text-xs text-gray-400">
            ç­¾åˆ°çއ {{ stats.checkInPercent }}%(按有效报名数计算,不含已取消)
          </div>
        </template>
      </div>
    </Spin>
  </Modal>
</template>
src/views/wxcp/components/push-scope-picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { WxCpMemberApi } from '#/api/wxcp/member';
import { computed, onMounted, ref, watch } from 'vue';
import { Alert, Radio, Select, Spin } from 'ant-design-vue';
import {
  getWxCpDepartmentList,
  getWxCpMemberList,
} from '#/api/wxcp/member';
import { PUSH_SCOPE_OPTIONS, PUSH_SCOPE } from '../data';
defineOptions({ name: 'WxCpPushScopePicker' });
interface Props {
  /** æŽ¨é€èŒƒå›´ï¼š0全员 1指定部门 2指定成员 */
  scope?: number;
  /** æŽ¨é€ç›®æ ‡ï¼Œé€—号分隔;范围是全员时无意义 */
  target?: string;
  disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  scope: PUSH_SCOPE.ALL,
  target: '',
  disabled: false,
});
const emit = defineEmits<{
  'update:scope': [value: number];
  'update:target': [value: string];
}>();
/** é€šè®¯å½•数据:只在首次需要时拉取,避免每次打开弹窗都打企业微信接口 */
const departments = ref<WxCpMemberApi.Department[]>([]);
const members = ref<WxCpMemberApi.Member[]>([]);
const loading = ref(false);
const loaded = ref(false);
const loadError = ref('');
async function ensureContacts() {
  if (loaded.value || loading.value) {
    return;
  }
  loading.value = true;
  loadError.value = '';
  try {
    const [deptList, memberList] = await Promise.all([
      getWxCpDepartmentList(),
      getWxCpMemberList({ fetchChild: true }),
    ]);
    departments.value = deptList ?? [];
    members.value = memberList ?? [];
    loaded.value = true;
  } catch (error: unknown) {
    loadError.value =
      (error as Error)?.message || '加载企业微信通讯录失败,请稍后重试';
  } finally {
    loading.value = false;
  }
}
// é€‰åˆ°éœ€è¦é€šè®¯å½•的范围时才加载
watch(
  () => props.scope,
  (scope) => {
    if (scope === PUSH_SCOPE.PARTY || scope === PUSH_SCOPE.USER) {
      ensureContacts();
    }
  },
);
onMounted(() => {
  if (props.scope === PUSH_SCOPE.PARTY || props.scope === PUSH_SCOPE.USER) {
    ensureContacts();
  }
});
const deptOptions = computed(() =>
  departments.value.map((d) => ({ label: d.name || `部门${d.id}`, value: d.id })),
);
const memberOptions = computed(() =>
  members.value.map((m) => ({
    label: m.name ? `${m.name}(${m.userId})` : m.userId,
    value: m.userId,
  })),
);
/** ç›®æ ‡å­—符串 <-> æ•°ç»„ */
const selected = computed<string[] | number[]>({
  get() {
    const raw = (props.target || '').trim();
    if (!raw) {
      return [];
    }
    const parts = raw
      .split(/[,,]/)
      .map((s) => s.trim())
      .filter(Boolean);
    // éƒ¨é—¨ç”¨æ•°å­—,成员用字符串(成员账号是 LiuYi è¿™ç§ï¼‰
    return props.scope === PUSH_SCOPE.PARTY
      ? parts.map((p) => Number(p)).filter((n) => Number.isFinite(n))
      : parts;
  },
  set(value) {
    emit('update:target', (value as (number | string)[]).join(','));
  },
});
function handleScopeChange(e: unknown) {
  const value = Number((e as { target?: { value?: number } })?.target?.value);
  emit('update:scope', Number.isFinite(value) ? value : PUSH_SCOPE.ALL);
  // åˆ‡æ¢èŒƒå›´æ—¶æ¸…掉旧目标,避免把成员账号当成部门ID提交
  emit('update:target', '');
}
</script>
<template>
  <div>
    <Radio.Group
      :value="scope"
      :disabled="disabled"
      button-style="solid"
      @change="handleScopeChange"
    >
      <Radio.Button
        v-for="opt in PUSH_SCOPE_OPTIONS"
        :key="opt.value"
        :value="opt.value"
      >
        {{ opt.label }}
      </Radio.Button>
    </Radio.Group>
    <div v-if="scope !== PUSH_SCOPE.ALL" class="mt-3">
      <Spin :spinning="loading">
        <Alert
          v-if="loadError"
          :message="loadError"
          type="warning"
          show-icon
          class="mb-2"
        />
        <Select
          v-model:value="selected"
          :options="scope === PUSH_SCOPE.PARTY ? deptOptions : memberOptions"
          :disabled="disabled || !!loadError"
          :mode="scope === PUSH_SCOPE.PARTY ? 'multiple' : 'multiple'"
          :placeholder="
            scope === PUSH_SCOPE.PARTY
              ? '请选择部门,可多选'
              : '请选择成员,可多选'
          "
          option-filter-prop="label"
          show-search
          allow-clear
          class="w-full"
        />
      </Spin>
      <div class="mt-2 text-xs text-gray-400">
        {{
          scope === PUSH_SCOPE.PARTY
            ? '推送给所选部门下的全部成员'
            : '推送给所选成员,账号来自企业微信通讯录'
        }}
      </div>
    </div>
  </div>
</template>
src/views/wxcp/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
/**
 * ä¼ä¸šå¾®ä¿¡æ¨¡å—共享常量
 *
 * å€¼ä¸ŽåŽç«¯æžšä¸¾ä¸€ä¸€å¯¹åº”,改动时两边必须同步:
 * - WxCpPushScopeEnum:0全员 1指定部门 2指定成员
 * - WxCpActivityStatusEnum:0草稿 1已发布 2已结束 3已取消
 * - WxCpNoticeStatusEnum:0草稿 1已发布 2已撤回
 * - WxCpSignupStatusEnum:0已报名 1已取消 2已签到
 */
/** æŽ¨é€èŒƒå›´ */
export const PUSH_SCOPE = {
  ALL: 0,
  PARTY: 1,
  USER: 2,
} as const;
/** æŽ¨é€èŒƒå›´é€‰é¡¹ */
export const PUSH_SCOPE_OPTIONS = [
  { label: '全员', value: PUSH_SCOPE.ALL },
  { label: '指定部门', value: PUSH_SCOPE.PARTY },
  { label: '指定成员', value: PUSH_SCOPE.USER },
];
/** æŽ¨é€çŠ¶æ€ï¼ˆæ´»åŠ¨ä¸Žé€šçŸ¥å…±ç”¨ï¼‰ */
export const PUSH_STATUS_OPTIONS = [
  { label: '未推送', value: 0 },
  { label: '已推送', value: 1 },
];
/** æ´»åŠ¨çŠ¶æ€ */
export const ACTIVITY_STATUS = {
  DRAFT: 0,
  PUBLISHED: 1,
  FINISHED: 2,
  CANCELED: 3,
} as const;
/** æ´»åŠ¨çŠ¶æ€é€‰é¡¹ */
export const ACTIVITY_STATUS_OPTIONS = [
  { label: '草稿', value: ACTIVITY_STATUS.DRAFT },
  { label: '已发布', value: ACTIVITY_STATUS.PUBLISHED },
  { label: '已结束', value: ACTIVITY_STATUS.FINISHED },
  { label: '已取消', value: ACTIVITY_STATUS.CANCELED },
];
/** é€šçŸ¥çŠ¶æ€ */
export const NOTICE_STATUS = {
  DRAFT: 0,
  PUBLISHED: 1,
  REVOKED: 2,
} as const;
/** é€šçŸ¥çŠ¶æ€é€‰é¡¹ */
export const NOTICE_STATUS_OPTIONS = [
  { label: '草稿', value: NOTICE_STATUS.DRAFT },
  { label: '已发布', value: NOTICE_STATUS.PUBLISHED },
  { label: '已撤回', value: NOTICE_STATUS.REVOKED },
];
/** æŠ¥åçŠ¶æ€ */
export const SIGNUP_STATUS = {
  SIGNED_UP: 0,
  CANCELED: 1,
  CHECKED_IN: 2,
} as const;
/** æŠ¥åçŠ¶æ€é€‰é¡¹ */
export const SIGNUP_STATUS_OPTIONS = [
  { label: '已报名', value: SIGNUP_STATUS.SIGNED_UP },
  { label: '已取消', value: SIGNUP_STATUS.CANCELED },
  { label: '已签到', value: SIGNUP_STATUS.CHECKED_IN },
];
/** æŠŠé€‰é¡¹æ•°ç»„转成 value -> label çš„æ˜ å°„,便于表格里直接取中文名 */
export function toLabelMap(
  options: Array<{ label: string; value: number }>,
): Record<number, string> {
  return Object.fromEntries(options.map((o) => [o.value, o.label]));
}
src/views/wxcp/notice/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,118 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WxCpNoticeApi } from '#/api/wxcp/notice';
import { NOTICE_STATUS_OPTIONS, PUSH_STATUS_OPTIONS } from '../data';
/** è¡¨å•类型 */
export type NoticeFormType = 'create' | 'update' | 'detail';
/** é€šçŸ¥è¡¨å• */
export function useNoticeFormSchema(formType: NoticeFormType): VbenFormSchema[] {
  const readonly = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'title',
      label: '通知标题',
      component: 'Input',
      componentProps: { placeholder: '请输入通知标题', disabled: readonly },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'content',
      label: '通知正文',
      component: 'Textarea',
      componentProps: {
        rows: 8,
        placeholder: '请输入通知正文',
        disabled: readonly,
      },
      rules: 'required',
      help: '推送时超过 600 å­—会自动截断,建议把关键信息放前面',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: { rows: 2, placeholder: '内部备注,不推送', disabled: readonly },
      formItemClass: 'col-span-2',
    },
  ];
}
/** é€šçŸ¥æœç´¢è¡¨å• */
export function useNoticeSearchFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'title',
      label: '通知标题',
      component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入通知标题' },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: NOTICE_STATUS_OPTIONS,
        placeholder: '请选择状态',
      },
    },
    {
      fieldName: 'pushStatus',
      label: '推送状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: PUSH_STATUS_OPTIONS,
        placeholder: '请选择推送状态',
      },
    },
  ];
}
/** é€šçŸ¥åˆ—表字段 */
export function useNoticeGridColumns(): VxeTableGridOptions<WxCpNoticeApi.Notice>['columns'] {
  return [
    {
      field: 'title',
      title: '通知标题',
      minWidth: 220,
      slots: { default: 'title' },
    },
    {
      field: 'content',
      title: '正文摘要',
      minWidth: 260,
      slots: { default: 'content' },
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      slots: { default: 'status' },
    },
    {
      field: 'pushScopeName',
      title: '推送范围',
      width: 110,
    },
    {
      field: 'pushStatus',
      title: '推送',
      width: 90,
      slots: { default: 'pushStatus' },
    },
    { field: 'pushTime', title: '推送时间', width: 170 },
    { field: 'createTime', title: '创建时间', width: 170 },
    { title: '操作', width: 220, fixed: 'right', slots: { default: 'actions' } },
  ];
}
src/views/wxcp/notice/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,204 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WxCpNoticeApi } from '#/api/wxcp/notice';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, message, Tag, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteNotice,
  getNoticePage,
  publishNotice,
  revokeNotice,
} from '#/api/wxcp/notice';
import { NOTICE_STATUS, NOTICE_STATUS_OPTIONS, toLabelMap } from '../data';
import {
  useNoticeGridColumns,
  useNoticeSearchFormSchema,
} from './data';
import NoticeForm from './modules/form.vue';
import NoticePushModal from './modules/push-modal.vue';
defineOptions({ name: 'WxCpNoticeList' });
const statusLabelMap = toLabelMap(NOTICE_STATUS_OPTIONS);
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: NoticeForm,
  destroyOnClose: true,
});
const [PushModalComp, pushModalApi] = useVbenModal({
  connectedComponent: NoticePushModal,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleDetail(row: WxCpNoticeApi.Notice) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
function handleEdit(row: WxCpNoticeApi.Notice) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
async function handleDelete(row: WxCpNoticeApi.Notice) {
  await deleteNotice(row.id!);
  message.success('删除成功');
  handleRefresh();
}
async function handlePublish(row: WxCpNoticeApi.Notice) {
  await publishNotice(row.id!);
  message.success('发布成功,现在可以推送了');
  handleRefresh();
}
async function handleRevoke(row: WxCpNoticeApi.Notice) {
  await revokeNotice(row.id!);
  message.success('已撤回');
  handleRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useNoticeSearchFormSchema(),
  },
  gridOptions: {
    columns: useNoticeGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getNoticePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true },
  } as VxeTableGridOptions<WxCpNoticeApi.Notice>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <PushModalComp @success="handleRefresh" />
    <Grid table-title="企业微信通知列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新建通知',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['wxcp:notice:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #title="{ row }">
        <Button type="link" class="p-0" @click="handleDetail(row)">
          {{ row.title }}
        </Button>
      </template>
      <template #content="{ row }">
        <Tooltip v-if="row.content" :title="row.content">
          <span class="line-clamp-2">{{ row.content }}</span>
        </Tooltip>
        <span v-else class="text-gray-400">-</span>
      </template>
      <template #status="{ row }">
        <Tag
          :color="
            row.status === NOTICE_STATUS.PUBLISHED
              ? 'green'
              : row.status === NOTICE_STATUS.DRAFT
                ? 'default'
                : 'orange'
          "
        >
          {{ statusLabelMap[row.status!] || '-' }}
        </Tag>
      </template>
      <template #pushStatus="{ row }">
        <Tag :color="row.pushStatus === 1 ? 'green' : 'default'">
          {{ row.pushStatus === 1 ? '已推送' : '未推送' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '编辑',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['wxcp:notice:update'],
              ifShow: row.status !== NOTICE_STATUS.REVOKED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '发布',
              type: 'link',
              auth: ['wxcp:notice:update'],
              ifShow: row.status === NOTICE_STATUS.DRAFT,
              popConfirm: {
                title: '发布后即可推送给成员,确认发布?',
                confirm: handlePublish.bind(null, row),
              },
            },
            {
              label: '推送',
              type: 'link',
              auth: ['wxcp:notice:push'],
              ifShow: row.status === NOTICE_STATUS.PUBLISHED,
              onClick: () => pushModalApi.setData({ notice: row }).open(),
            },
            {
              label: '撤回',
              type: 'link',
              auth: ['wxcp:notice:update'],
              ifShow: row.status === NOTICE_STATUS.PUBLISHED,
              popConfirm: {
                title: '撤回后该通知不能再推送,确认撤回?',
                confirm: handleRevoke.bind(null, row),
              },
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['wxcp:notice:delete'],
              popConfirm: {
                title: `确认删除通知【${row.title}】?`,
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/wxcp/notice/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { WxCpNoticeApi } from '#/api/wxcp/notice';
import type { NoticeFormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createNotice,
  getNotice,
  updateNotice,
} from '#/api/wxcp/notice';
import { useNoticeFormSchema } from '../data';
defineOptions({ name: 'WxCpNoticeForm' });
const emit = defineEmits<{ success: [] }>();
const formType = ref<NoticeFormType>('create');
const id = ref<number>();
const submitting = ref(false);
const isEdit = computed(() => formType.value !== 'detail');
const title = computed(() => {
  if (formType.value === 'detail') return '查看通知';
  if (id.value) return '编辑通知';
  return '新建通知';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 90,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-1',
});
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      id.value = undefined;
      return;
    }
    const data = modalApi.getData<{ formType: NoticeFormType; id?: number }>();
    formType.value = data.formType;
    id.value = data.id;
    formApi.setState({ schema: useNoticeFormSchema(formType.value) });
    formApi.setDisabled(formType.value === 'detail');
    if (data.id) {
      modalApi.lock();
      try {
        const detail = await getNotice(data.id);
        await formApi.setValues(detail as unknown as Record<string, unknown>);
      } finally {
        modalApi.unlock();
      }
    } else {
      await formApi.resetForm();
    }
  },
});
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid) {
    return;
  }
  submitting.value = true;
  try {
    const data = (await formApi.getValues()) as WxCpNoticeApi.Notice;
    if (id.value) {
      await updateNotice({ ...data, id: id.value });
      message.success('修改成功');
    } else {
      await createNotice(data);
      message.success('创建成功,请在列表中发布后再推送');
    }
    await modalApi.close();
    emit('success');
  } catch (error: unknown) {
    message.error((error as Error)?.message || '提交失败');
  } finally {
    submitting.value = false;
  }
}
</script>
<template>
  <Modal :title="title" class="w-3/5">
    <Form class="mx-4" />
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
        <Button
          v-if="isEdit"
          type="primary"
          :loading="submitting"
          @click="handleSubmit"
        >
          ç¡®è®¤
        </Button>
        <Button v-else type="primary" @click="modalApi.close()">关闭</Button>
      </div>
    </template>
  </Modal>
</template>
src/views/wxcp/notice/modules/push-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { WxCpNoticeApi } from '#/api/wxcp/notice';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, message } from 'ant-design-vue';
import { pushNotice } from '#/api/wxcp/notice';
import PushScopePicker from '../../components/push-scope-picker.vue';
import { PUSH_SCOPE } from '../../data';
defineOptions({ name: 'WxCpNoticePushModal' });
const emit = defineEmits<{ success: [] }>();
const notice = ref<WxCpNoticeApi.Notice>({});
const scope = ref<number>(PUSH_SCOPE.ALL);
const target = ref<string>('');
const pushing = ref(false);
const resultMessage = ref('');
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      notice.value = {};
      resultMessage.value = '';
      return;
    }
    const data = modalApi.getData<{ notice: WxCpNoticeApi.Notice }>();
    notice.value = data.notice ?? {};
    scope.value = data.notice?.pushScope ?? PUSH_SCOPE.ALL;
    target.value = data.notice?.pushTarget ?? '';
  },
});
async function handlePush() {
  if (!notice.value.id) {
    return;
  }
  if (scope.value !== PUSH_SCOPE.ALL && !target.value.trim()) {
    message.warning('选择「指定部门」或「指定成员」时,请先选择推送目标');
    return;
  }
  pushing.value = true;
  resultMessage.value = '';
  try {
    const res = await pushNotice({
      id: notice.value.id,
      pushScope: scope.value,
      pushTarget: scope.value === PUSH_SCOPE.ALL ? '' : target.value,
    });
    if (res?.success) {
      message.success('推送成功');
      emit('success');
      await modalApi.close();
    } else {
      resultMessage.value = res?.message || '推送失败';
    }
  } catch (error: unknown) {
    resultMessage.value = (error as Error)?.message || '推送失败';
  } finally {
    pushing.value = false;
  }
}
</script>
<template>
  <Modal title="推送通知" class="w-2/5">
    <div class="px-4">
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm dark:bg-gray-800">
        <div class="mb-1 font-medium">{{ notice.title }}</div>
        <div class="text-gray-500">
          æŽ¨é€åŽæˆå‘˜çš„企业微信会收到纯文本消息,内容超过 600 å­—会自动截断。
        </div>
      </div>
      <div class="mb-2 text-sm font-medium">推送范围</div>
      <PushScopePicker v-model:scope="scope" v-model:target="target" />
      <Alert
        v-if="resultMessage"
        class="mt-4"
        type="error"
        :message="resultMessage"
        show-icon
      />
    </div>
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
        <Button type="primary" :loading="pushing" @click="handlePush">
          ç¡®è®¤æŽ¨é€
        </Button>
      </div>
    </template>
  </Modal>
</template>