create-jnrs-vue 1.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/bin/create.mjs +221 -0
  4. package/bin/upgrade.mjs +40 -0
  5. package/jnrs-template-vue/.env.development +13 -0
  6. package/jnrs-template-vue/.env.production +4 -0
  7. package/jnrs-template-vue/.prettierrc.json +12 -0
  8. package/jnrs-template-vue/README.md +48 -0
  9. package/jnrs-template-vue/auto-imports.d.ts +17 -0
  10. package/jnrs-template-vue/components.d.ts +51 -0
  11. package/jnrs-template-vue/eslint.config.ts +40 -0
  12. package/jnrs-template-vue/index.html +13 -0
  13. package/jnrs-template-vue/package.json +55 -0
  14. package/jnrs-template-vue/public/favicon.ico +0 -0
  15. package/jnrs-template-vue/public/system/menu.json +137 -0
  16. package/jnrs-template-vue/src/App.vue +45 -0
  17. package/jnrs-template-vue/src/api/common/index.ts +28 -0
  18. package/jnrs-template-vue/src/api/demos/index.ts +155 -0
  19. package/jnrs-template-vue/src/api/request.ts +53 -0
  20. package/jnrs-template-vue/src/api/system/index.ts +107 -0
  21. package/jnrs-template-vue/src/api/user/index.ts +12 -0
  22. package/jnrs-template-vue/src/assets/fonts/.keep +0 -0
  23. package/jnrs-template-vue/src/assets/fonts/AlibabaPuHuiTi-Regular.woff2 +0 -0
  24. package/jnrs-template-vue/src/assets/fonts/AlimamaShuHeiTi-Bold.woff2 +0 -0
  25. package/jnrs-template-vue/src/assets/images/common/403.png +0 -0
  26. package/jnrs-template-vue/src/assets/images/common/404.png +0 -0
  27. package/jnrs-template-vue/src/assets/images/common/avatar.png +0 -0
  28. package/jnrs-template-vue/src/assets/images/common/jnrs-white.svg +1 -0
  29. package/jnrs-template-vue/src/assets/styles/animation.scss +0 -0
  30. package/jnrs-template-vue/src/assets/styles/common.scss +39 -0
  31. package/jnrs-template-vue/src/assets/styles/fonts.scss +27 -0
  32. package/jnrs-template-vue/src/assets/styles/index.scss +5 -0
  33. package/jnrs-template-vue/src/assets/styles/init.scss +41 -0
  34. package/jnrs-template-vue/src/assets/styles/root.scss +13 -0
  35. package/jnrs-template-vue/src/components/common/CardTable.vue +90 -0
  36. package/jnrs-template-vue/src/components/common/DictTag.vue +74 -0
  37. package/jnrs-template-vue/src/components/common/ImageView.vue +144 -0
  38. package/jnrs-template-vue/src/components/common/PdfView.vue +115 -0
  39. package/jnrs-template-vue/src/components/select/SelectManager.vue +26 -0
  40. package/jnrs-template-vue/src/directives/permissions.ts +28 -0
  41. package/jnrs-template-vue/src/layout/BlankLayout.vue +15 -0
  42. package/jnrs-template-vue/src/layout/RouterTabs /344/277/256/345/244/215/350/267/257/347/224/261/350/267/263/350/275/254/346/220/272/345/270/246/345/217/202/346/225/260/351/227/256/351/242/230.vue" +150 -0
  43. package/jnrs-template-vue/src/layout/RouterTabs.vue +142 -0
  44. package/jnrs-template-vue/src/layout/SideMenu.vue +208 -0
  45. package/jnrs-template-vue/src/layout/SideMenuItem.vue +38 -0
  46. package/jnrs-template-vue/src/layout/TopHeader.vue +184 -0
  47. package/jnrs-template-vue/src/layout/index.vue +71 -0
  48. package/jnrs-template-vue/src/locales/en.ts +14 -0
  49. package/jnrs-template-vue/src/locales/index.ts +23 -0
  50. package/jnrs-template-vue/src/locales/zhCn.ts +14 -0
  51. package/jnrs-template-vue/src/main.ts +31 -0
  52. package/jnrs-template-vue/src/router/index.ts +77 -0
  53. package/jnrs-template-vue/src/router/routes.ts +48 -0
  54. package/jnrs-template-vue/src/types/env.d.ts +12 -0
  55. package/jnrs-template-vue/src/types/index.ts +81 -0
  56. package/jnrs-template-vue/src/types/webSocket.ts +19 -0
  57. package/jnrs-template-vue/src/utils/file.ts +56 -0
  58. package/jnrs-template-vue/src/utils/packages.ts +116 -0
  59. package/jnrs-template-vue/src/utils/permissions.ts +16 -0
  60. package/jnrs-template-vue/src/views/common/403.vue +52 -0
  61. package/jnrs-template-vue/src/views/common/404.vue +52 -0
  62. package/jnrs-template-vue/src/views/demos/crud/index.vue +355 -0
  63. package/jnrs-template-vue/src/views/demos/simpleTable/index.vue +41 -0
  64. package/jnrs-template-vue/src/views/demos/unitTest/RequestPage.vue +137 -0
  65. package/jnrs-template-vue/src/views/demos/unitTest/index.vue +131 -0
  66. package/jnrs-template-vue/src/views/home/index.vue +9 -0
  67. package/jnrs-template-vue/src/views/lingshuSmart/editorPage.vue +9 -0
  68. package/jnrs-template-vue/src/views/login/index.vue +314 -0
  69. package/jnrs-template-vue/src/views/system/dict/index.vue +161 -0
  70. package/jnrs-template-vue/src/views/system/menu/index.vue +43 -0
  71. package/jnrs-template-vue/src/views/system/mine/baseInfo.vue +108 -0
  72. package/jnrs-template-vue/src/views/system/mine/index.vue +83 -0
  73. package/jnrs-template-vue/src/views/system/mine/securitySettings.vue +105 -0
  74. package/jnrs-template-vue/src/views/system/role/editDialog.vue +94 -0
  75. package/jnrs-template-vue/src/views/system/role/index.vue +41 -0
  76. package/jnrs-template-vue/src/views/visual/index.vue +143 -0
  77. package/jnrs-template-vue/tsconfig.json +25 -0
  78. package/jnrs-template-vue/vite.config.ts +71 -0
  79. package/jnrs-template-vue/viteMockServe/fail.ts +38 -0
  80. package/jnrs-template-vue/viteMockServe/file/mock-pdf.pdf +0 -0
  81. package/jnrs-template-vue/viteMockServe/file/mock-png-0.png +0 -0
  82. package/jnrs-template-vue/viteMockServe/file/mock-png-1.png +0 -0
  83. package/jnrs-template-vue/viteMockServe/file.ts +67 -0
  84. package/jnrs-template-vue/viteMockServe/index.ts +87 -0
  85. package/jnrs-template-vue/viteMockServe/json/detailsRes.json +56 -0
  86. package/jnrs-template-vue/viteMockServe/json/dictItemRes.json +27 -0
  87. package/jnrs-template-vue/viteMockServe/json/dictRes.json +21 -0
  88. package/jnrs-template-vue/viteMockServe/json/loginRes_admin.json +157 -0
  89. package/jnrs-template-vue/viteMockServe/json/loginRes_user.json +713 -0
  90. package/jnrs-template-vue/viteMockServe/json/roleRes.json +37 -0
  91. package/jnrs-template-vue/viteMockServe/json/tableRes.json +390 -0
  92. package/jnrs-template-vue/viteMockServe/success.ts +39 -0
  93. package/package.json +41 -0
@@ -0,0 +1,90 @@
1
+ <!--
2
+ @Author : TanRui
3
+ @WeChat : Tan578853789
4
+ @File : CardTable.vue
5
+ @Date : 2026/01/06
6
+ @Desc. : 卡片数据表格组件
7
+ -->
8
+
9
+ <script setup lang="ts">
10
+ import type { Pagination, PageTableData } from '@/types'
11
+ import { ref, onActivated } from 'vue'
12
+ import { JnPagination, JnTable } from '@jnrs/vue-core/components'
13
+ import { debounce } from '@jnrs/shared/lodash'
14
+
15
+ interface Props {
16
+ /**
17
+ * 获取数据表格 api 函数
18
+ */
19
+ // eslint-disable-next-line
20
+ getTableDataApi?: (data?: Pagination) => Promise<PageTableData<any>>
21
+ }
22
+
23
+ const { getTableDataApi } = defineProps<Props>()
24
+
25
+ const loading = ref(false)
26
+ const tableData = ref()
27
+ const total = ref(0)
28
+ const pagination = ref<Pagination>({ pageNo: 1, pageSize: 20 })
29
+
30
+ const getTable = debounce(async () => {
31
+ if (!getTableDataApi) {
32
+ return false
33
+ }
34
+ loading.value = true
35
+ try {
36
+ const res = await getTableDataApi(pagination.value)
37
+ tableData.value =
38
+ res.list?.map((item) => ({
39
+ ...item,
40
+ newImageFiles: item.imageDocument?.attachments,
41
+ newAttachmentFile: item.attachmentDocument?.attachments
42
+ })) || []
43
+ total.value = res.count
44
+ } catch (error) {
45
+ console.error(error)
46
+ } finally {
47
+ loading.value = false
48
+ }
49
+ }, 300)
50
+
51
+ onActivated(() => {
52
+ getTable()
53
+ })
54
+
55
+ defineExpose({
56
+ getTable
57
+ })
58
+ </script>
59
+
60
+ <template>
61
+ <el-card class="cardTable" v-loading="loading">
62
+ <template #header>
63
+ <div class="cardTable_header">
64
+ <slot name="header"></slot>
65
+ </div>
66
+ </template>
67
+
68
+ <JnTable
69
+ :data="tableData"
70
+ :pagination="pagination"
71
+ :autoHeight="true"
72
+ :showScrollbar="true"
73
+ :showIndexColumn="true"
74
+ >
75
+ <slot name="table"></slot>
76
+ </JnTable>
77
+
78
+ <JnPagination :total="total" v-model="pagination" @change="getTable" />
79
+ </el-card>
80
+ </template>
81
+
82
+ <style lang="scss" scoped>
83
+ .cardTable {
84
+ .cardTable_header {
85
+ display: flex;
86
+ justify-content: space-between;
87
+ align-items: center;
88
+ }
89
+ }
90
+ </style>
@@ -0,0 +1,74 @@
1
+ <!--
2
+ @Author : TanRui
3
+ @WeChat : Tan578853789
4
+ @File : DictTag.vue
5
+ @Date : 2025/12/25
6
+ @Desc. : 带颜色的字典标签
7
+ -->
8
+
9
+ <script setup lang="ts">
10
+ import { computed } from 'vue'
11
+ import { getDictLabel, getDictColor } from '@/utils/packages'
12
+
13
+ interface Props {
14
+ dictName: string
15
+ value: string | number
16
+ /**
17
+ * 是否使用颜色
18
+ */
19
+ showColor?: boolean
20
+ /**
21
+ * 是否使用 popover
22
+ */
23
+ popover?: string
24
+ /**
25
+ * 消息放置位置
26
+ */
27
+ placement?: 'top' | 'top-left' | 'top-right' | 'bottom' | 'bottom-left' | 'bottom-right'
28
+ }
29
+
30
+ const { dictName = '', value = '', showColor = true, popover = '' } = defineProps<Props>()
31
+
32
+ const computedColor = computed(() => {
33
+ return showColor ? getDictColor(dictName, value) : 'unset'
34
+ })
35
+ </script>
36
+
37
+ <template>
38
+ <el-popover :content="popover" placement="top" :disabled="!popover">
39
+ <template #reference>
40
+ <span
41
+ class="dictTag"
42
+ :style="{
43
+ backgroundColor: computedColor
44
+ }"
45
+ >
46
+ <span
47
+ :class="{ dictTag_label_showColor: showColor }"
48
+ :style="{
49
+ color: computedColor
50
+ }"
51
+ >
52
+ {{ getDictLabel(dictName, value) }}
53
+ </span>
54
+ </span>
55
+ </template>
56
+ </el-popover>
57
+ </template>
58
+
59
+ <style lang="scss" scoped>
60
+ .dictTag {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ padding: 1px 8px;
65
+ border-radius: 4px;
66
+ white-space: nowrap;
67
+ transform: scale(0.8);
68
+
69
+ .dictTag_label_showColor {
70
+ font-size: 1.1em;
71
+ filter: invert(0.5) brightness(0.5);
72
+ }
73
+ }
74
+ </style>
@@ -0,0 +1,144 @@
1
+ <script lang="ts" setup>
2
+ import type { FileItem } from '@jnrs/shared'
3
+ import type { Attachment } from '@/types'
4
+ import { ref, watch, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
5
+ import { blobToUrl } from '@jnrs/shared'
6
+ import { debounce } from '@jnrs/shared/lodash'
7
+ import { FileApi } from '@/api/common'
8
+
9
+ import { JnImageView } from '@jnrs/vue-core/components'
10
+
11
+ interface Props {
12
+ /**
13
+ * 要加载的文件列表 | 文件名唯一标识 uniqueFileName
14
+ */
15
+ loadKeys: Attachment[] | string | undefined
16
+
17
+ /**
18
+ * 是否清理文件副作用
19
+ */
20
+ clearSideEffects?: boolean
21
+ }
22
+
23
+ const { loadKeys, clearSideEffects = false } = defineProps<Props>()
24
+
25
+ // 第一张显示的图片
26
+ const posterUrl = ref('')
27
+
28
+ // 存储每个 URL 对应的 URL 对象
29
+ const fileList = ref<FileItem[]>([])
30
+
31
+ // 存储每个 URL 对应的 revoke 函数用于副作用清理
32
+ const revokeFns = ref<(() => void)[]>([])
33
+
34
+ // 清理当前所有 Object URL
35
+ const clearUrls = () => {
36
+ if (clearSideEffects) {
37
+ revokeFns.value.forEach((revoke) => revoke())
38
+ revokeFns.value = []
39
+ fileList.value = []
40
+ posterUrl.value = ''
41
+ }
42
+ }
43
+
44
+ // 加载文件
45
+ const loadFilesRaw = async (keys: Props['loadKeys']) => {
46
+ clearUrls()
47
+
48
+ if (!keys) {
49
+ return
50
+ }
51
+
52
+ // 如果是字符串
53
+ if (typeof keys === 'string') {
54
+ try {
55
+ const blob = await FileApi(keys)
56
+ const { url, revoke } = blobToUrl(blob)
57
+ fileList.value = [{ src: url, fileName: keys }]
58
+ revokeFns.value = [revoke]
59
+ posterUrl.value = url
60
+ } catch (err) {
61
+ fileList.value = []
62
+ revokeFns.value = []
63
+ posterUrl.value = keys
64
+ console.warn('ImageView 组件异常', err)
65
+ }
66
+ return
67
+ }
68
+
69
+ // 如果是对象
70
+ if (keys !== null && typeof keys === 'object' && !Array.isArray(keys)) {
71
+ const { uniqueFileName, fileName } = keys
72
+ if (!uniqueFileName) {
73
+ return
74
+ }
75
+ try {
76
+ const blob = await FileApi(uniqueFileName)
77
+ const { url, revoke } = blobToUrl(blob)
78
+
79
+ fileList.value = [{ src: url, fileName: fileName || uniqueFileName }]
80
+ revokeFns.value = [revoke]
81
+ posterUrl.value = url
82
+ } catch (err) {
83
+ fileList.value = []
84
+ revokeFns.value = []
85
+ posterUrl.value = uniqueFileName
86
+ console.warn('ImageView 组件异常', err)
87
+ }
88
+ return
89
+ }
90
+
91
+ // 如果是数组
92
+ if (Array.isArray(keys) && keys.length > 0) {
93
+ const res = await Promise.all(
94
+ keys.map(async ({ uniqueFileName, fileName }) => {
95
+ if (!uniqueFileName) {
96
+ return { url: '', fileName: '', revoke: () => {} }
97
+ }
98
+ try {
99
+ const blob = await FileApi(uniqueFileName)
100
+ const { url, revoke } = blobToUrl(blob)
101
+ return { url, fileName, revoke }
102
+ } catch (err) {
103
+ console.warn('ImageView 组件异常', err)
104
+ return { url: uniqueFileName, fileName: '', revoke: () => {} }
105
+ }
106
+ })
107
+ )
108
+ fileList.value = res.map(({ url, fileName }) => ({ src: url, fileName }))
109
+ revokeFns.value = res.map((d) => d.revoke)
110
+ // 数组情况下默认显示第一张可用的图片
111
+ posterUrl.value = fileList.value.find((d) => d.fileName)?.src || ''
112
+ }
113
+ }
114
+
115
+ const loadFiles = debounce(loadFilesRaw, 500)
116
+
117
+ watch(
118
+ () => loadKeys,
119
+ (nv) => {
120
+ loadFiles(nv)
121
+ },
122
+ { deep: true, immediate: true }
123
+ )
124
+
125
+ onActivated(() => {
126
+ if (clearSideEffects) {
127
+ loadFiles(loadKeys)
128
+ }
129
+ })
130
+
131
+ // 副作用清理
132
+ onDeactivated(() => {
133
+ clearUrls()
134
+ })
135
+
136
+ // 副作用清理
137
+ onBeforeUnmount(() => {
138
+ clearUrls()
139
+ })
140
+ </script>
141
+
142
+ <template>
143
+ <jn-image-view :src="posterUrl" :previewSrcList="fileList"></jn-image-view>
144
+ </template>
@@ -0,0 +1,115 @@
1
+ <script lang="ts" setup>
2
+ import type { FileItem } from '@jnrs/shared'
3
+ import type { Attachment } from '@/types'
4
+ import { ref, watch, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
5
+ import { blobToUrl } from '@jnrs/shared'
6
+ import { FileApi } from '@/api/common'
7
+
8
+ import { JnPdfView } from '@jnrs/vue-core/components'
9
+
10
+ interface Props {
11
+ /**
12
+ * 要加载的文件列表 | 文件名唯一标识 uniqueFileName
13
+ */
14
+ loadKeys: Attachment[] | string | undefined
15
+
16
+ /**
17
+ * 是否清理文件副作用
18
+ */
19
+ clearSideEffects?: boolean
20
+ }
21
+
22
+ const { loadKeys, clearSideEffects = false } = defineProps<Props>()
23
+
24
+ // 存储每个 URL 对应的 URL 对象
25
+ const fileList = ref<FileItem[]>([])
26
+
27
+ // 存储每个 URL 对应的 revoke 函数用于副作用清理
28
+ const revokeFns = ref<(() => void)[]>([])
29
+
30
+ // 清理当前所有 Object URL
31
+ const clearUrls = () => {
32
+ if (clearSideEffects) {
33
+ revokeFns.value.forEach((revoke) => revoke())
34
+ revokeFns.value = []
35
+ fileList.value = []
36
+ }
37
+ }
38
+
39
+ const loadFiles = async (keys: Props['loadKeys']) => {
40
+ clearUrls()
41
+
42
+ // 如果是字符串
43
+ if (typeof keys === 'string') {
44
+ try {
45
+ const blob = await FileApi(keys)
46
+ const { url, revoke } = blobToUrl(blob)
47
+ fileList.value = [{ src: url, fileName: keys }]
48
+ revokeFns.value = [revoke]
49
+ } catch (err) {
50
+ console.warn(keys, err)
51
+ }
52
+ return
53
+ }
54
+
55
+ // 如果是对象
56
+ if (keys !== null && typeof keys === 'object' && !Array.isArray(keys)) {
57
+ const { uniqueFileName, fileName } = keys
58
+ try {
59
+ const blob = await FileApi(uniqueFileName)
60
+ const { url, revoke } = blobToUrl(blob)
61
+ fileList.value = [{ src: url, fileName: fileName || uniqueFileName }]
62
+ revokeFns.value = [revoke]
63
+ } catch (err) {
64
+ console.warn(uniqueFileName, err)
65
+ }
66
+ return
67
+ }
68
+
69
+ // 如果是数组
70
+ if (Array.isArray(keys) && keys.length > 0) {
71
+ const res = await Promise.all(
72
+ keys.map(async ({ uniqueFileName, fileName }) => {
73
+ try {
74
+ const blob = await FileApi(uniqueFileName)
75
+ const { url, revoke } = blobToUrl(blob)
76
+ return { url, fileName, revoke }
77
+ } catch (err) {
78
+ console.warn(uniqueFileName, err)
79
+ return { url: '', fileName: fileName || uniqueFileName, revoke: () => {} }
80
+ }
81
+ })
82
+ )
83
+ fileList.value = res.map(({ url, fileName }) => ({ src: url, fileName }))
84
+ revokeFns.value = res.map((d) => d.revoke)
85
+ }
86
+ }
87
+
88
+ watch(
89
+ () => loadKeys,
90
+ (nv) => {
91
+ loadFiles(nv)
92
+ },
93
+ { deep: true, immediate: true }
94
+ )
95
+
96
+ onActivated(() => {
97
+ if (clearSideEffects) {
98
+ loadFiles(loadKeys)
99
+ }
100
+ })
101
+
102
+ // 副作用清理
103
+ onDeactivated(() => {
104
+ clearUrls()
105
+ })
106
+
107
+ // 副作用清理
108
+ onBeforeUnmount(() => {
109
+ clearUrls()
110
+ })
111
+ </script>
112
+
113
+ <template>
114
+ <JnPdfView :fileList="fileList"></JnPdfView>
115
+ </template>
@@ -0,0 +1,26 @@
1
+ <!--
2
+ @Author : TanRui
3
+ @WeChat : Tan578853789
4
+ @File : SelectManager.vue
5
+ @Date : 2025/12/31
6
+ @Desc. : 选择项目经理
7
+ -->
8
+
9
+ <script setup lang="ts">
10
+ import { TableApi } from '@/api/demos/index'
11
+ import { JnSelectTemplate } from '@jnrs/vue-core/components'
12
+ </script>
13
+
14
+ <template>
15
+ <JnSelectTemplate
16
+ tableName="项目经理"
17
+ :keyValue="{ name: 'manager', id: 'managerId', code: 'code' }"
18
+ optionSecondaryField="code"
19
+ :listApi="TableApi"
20
+ >
21
+ <template #table>
22
+ <el-table-column prop="manager" label="项目经理" align="center" sortable />
23
+ <el-table-column prop="code" label="编码" align="center" sortable />
24
+ </template>
25
+ </JnSelectTemplate>
26
+ </template>
@@ -0,0 +1,28 @@
1
+ import type { Directive } from 'vue'
2
+ import { hasPermission } from '@/utils/permissions'
3
+
4
+ export type PermissionsDirective = Directive<HTMLElement, string[]>
5
+
6
+ declare module 'vue' {
7
+ export interface ComponentCustomProperties {
8
+ vPermissions: PermissionsDirective
9
+ }
10
+ }
11
+
12
+ export const permissions = {
13
+ mounted(el, binding) {
14
+ const { value, modifiers } = binding
15
+ const hasPerm = hasPermission(value)
16
+
17
+ if (!hasPerm) {
18
+ if (modifiers.display) {
19
+ el.style.display = 'none'
20
+ } else if (modifiers.disabled) {
21
+ el.setAttribute('disabled', 'disabled')
22
+ el.classList.add('disabled')
23
+ } else {
24
+ el.remove()
25
+ }
26
+ }
27
+ }
28
+ } satisfies PermissionsDirective
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <div class="blank_layout">
3
+ <router-view />
4
+ </div>
5
+ </template>
6
+
7
+ <style scoped>
8
+ .blank_layout {
9
+ position: relative;
10
+ width: 100vw;
11
+ height: 100vh;
12
+ background: #000;
13
+ overflow: hidden;
14
+ }
15
+ </style>
@@ -0,0 +1,150 @@
1
+ <template>
2
+ <div class="routerTabs">
3
+ <el-tabs v-model="activeTab" type="card" @tab-remove="removeTab" @tab-click="handleTabClick">
4
+ <el-tab-pane
5
+ v-for="item in tabs"
6
+ :key="item.fullPath"
7
+ :name="item.fullPath"
8
+ :closable="item.path !== HOME_PATH"
9
+ >
10
+ <template #label>
11
+ <span>{{ tabLabel(item) }}</span>
12
+ </template>
13
+ </el-tab-pane>
14
+ </el-tabs>
15
+ </div>
16
+ </template>
17
+
18
+ <script setup>
19
+ import { ref, watch, computed, onMounted } from 'vue'
20
+ import { useRouter, useRoute } from 'vue-router'
21
+
22
+ const HOME_PATH = '/home/index'
23
+ const router = useRouter()
24
+ const route = useRoute()
25
+
26
+ // 使用 fullPath 作为激活 Tab 的值
27
+ const activeTab = ref(route.fullPath)
28
+
29
+ // Tab 列表:每个 tab 包含 fullPath、path、meta
30
+ const tabs = ref([
31
+ {
32
+ fullPath: route.fullPath,
33
+ path: route.path,
34
+ meta: route.meta
35
+ }
36
+ ])
37
+
38
+ // 标签页标题计算
39
+ const tabLabel = computed(() => {
40
+ return function (item) {
41
+ let label = item.meta?.title || '未命名'
42
+ if (item.meta?.fullPathTitle) {
43
+ label = item.meta.fullPathTitle.replace(/,/g, '/')
44
+ }
45
+ return label
46
+ }
47
+ })
48
+
49
+ // 初始化:插入首页(如果当前不是首页)
50
+ onMounted(() => {
51
+ if (route.path !== HOME_PATH) {
52
+ tabs.value.unshift({
53
+ fullPath: HOME_PATH,
54
+ path: HOME_PATH,
55
+ meta: { title: '工作台' }
56
+ })
57
+ }
58
+ })
59
+
60
+ // 添加标签页(基于当前 route)
61
+ const addTab = () => {
62
+ const currentRoute = route
63
+
64
+ // 如果已存在相同 fullPath 的 Tab,直接激活,不重复添加
65
+ const existingTab = tabs.value.find((tab) => tab.fullPath === currentRoute.fullPath)
66
+ if (existingTab) {
67
+ activeTab.value = currentRoute.fullPath
68
+ return
69
+ }
70
+
71
+ // 添加新 Tab
72
+ tabs.value.push({
73
+ fullPath: currentRoute.fullPath,
74
+ path: currentRoute.path,
75
+ meta: currentRoute.meta
76
+ })
77
+ activeTab.value = currentRoute.fullPath
78
+ }
79
+
80
+ // 移除标签页
81
+ const removeTab = (targetFullPath) => {
82
+ if (targetFullPath === HOME_PATH) return
83
+
84
+ const currentIndex = tabs.value.findIndex((tab) => tab.fullPath === targetFullPath)
85
+ if (currentIndex === -1) return
86
+
87
+ // 删除 Tab
88
+ tabs.value.splice(currentIndex, 1)
89
+
90
+ // 如果删除的是当前激活的 Tab
91
+ if (activeTab.value === targetFullPath) {
92
+ const nextTab = tabs.value[currentIndex] || tabs.value[currentIndex - 1]
93
+ if (nextTab) {
94
+ activeTab.value = nextTab.fullPath
95
+ router.push(nextTab.fullPath)
96
+ } else {
97
+ // 回退到首页
98
+ activeTab.value = HOME_PATH
99
+ router.push(HOME_PATH)
100
+ }
101
+ }
102
+ }
103
+
104
+ // 监听路由变化(使用 fullPath)
105
+ watch(
106
+ () => route.fullPath,
107
+ (newFullPath) => {
108
+ // 避免由 Tab 点击触发的导航再次添加 Tab(防止循环)
109
+ if (newFullPath !== activeTab.value) {
110
+ addTab()
111
+ }
112
+ },
113
+ { immediate: true }
114
+ )
115
+
116
+ // 处理标签页点击
117
+ const handleTabClick = (tab) => {
118
+ const fullPath = tab.props.name // el-tab-pane 的 name 即 fullPath
119
+ if (fullPath !== activeTab.value) {
120
+ activeTab.value = fullPath
121
+ router.push(fullPath)
122
+ }
123
+ }
124
+ </script>
125
+
126
+ <style lang="scss" scoped>
127
+ .routerTabs {
128
+ :deep(.el-tabs__header) {
129
+ margin-bottom: 0;
130
+ height: 32px;
131
+ }
132
+ :deep(.el-tabs__item) {
133
+ color: #888;
134
+ font-size: 12px;
135
+ height: 32px;
136
+ }
137
+ :deep(.el-tabs__item.is-active) {
138
+ color: #09a2a5;
139
+ border-bottom-color: #f2f2f2;
140
+ }
141
+ :deep(.el-tabs__nav-prev) {
142
+ color: #09a2a5;
143
+ background: #fff;
144
+ }
145
+ :deep(.el-tabs__nav-next) {
146
+ color: #09a2a5;
147
+ background: #fff;
148
+ }
149
+ }
150
+ </style>