create-jnrs-template-vue 1.2.3 → 1.2.4

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 (30) hide show
  1. package/jnrs-template-vue/.env.development +1 -1
  2. package/jnrs-template-vue/index.html +1 -1
  3. package/jnrs-template-vue/package.json +3 -3
  4. package/jnrs-template-vue/public/system/menu.json +11 -2
  5. package/jnrs-template-vue/src/api/demos/index.ts +17 -10
  6. package/jnrs-template-vue/src/api/system/index.ts +11 -1
  7. package/jnrs-template-vue/src/assets/styles/index.scss +0 -24
  8. package/jnrs-template-vue/src/assets/styles/init.scss +24 -0
  9. package/jnrs-template-vue/src/assets/styles/root.scss +4 -0
  10. package/jnrs-template-vue/src/components/common/CardTable.vue +89 -0
  11. package/jnrs-template-vue/src/components/common/DictTag.vue +8 -4
  12. package/jnrs-template-vue/src/components/common/ImageView.vue +16 -7
  13. package/jnrs-template-vue/src/components/common/PdfView.vue +14 -5
  14. package/jnrs-template-vue/src/components/select/SelectManager.vue +2 -2
  15. package/jnrs-template-vue/src/layout/SideMenu.vue +0 -1
  16. package/jnrs-template-vue/src/layout/TopHeader.vue +7 -14
  17. package/jnrs-template-vue/src/types/webSocket.ts +19 -0
  18. package/jnrs-template-vue/src/utils/file.ts +36 -1
  19. package/jnrs-template-vue/src/views/demos/crud/index.vue +24 -36
  20. package/jnrs-template-vue/src/views/demos/simpleTable/index.vue +41 -0
  21. package/jnrs-template-vue/src/views/demos/unitTest/RequestPage.vue +2 -2
  22. package/jnrs-template-vue/src/views/login/index.vue +18 -15
  23. package/jnrs-template-vue/src/views/system/dict/index.vue +63 -76
  24. package/jnrs-template-vue/src/views/system/menu/index.vue +42 -54
  25. package/jnrs-template-vue/src/views/system/mine/baseInfo.vue +26 -59
  26. package/jnrs-template-vue/src/views/system/role/index.vue +20 -29
  27. package/jnrs-template-vue/src/views/visual/index.vue +130 -15
  28. package/package.json +1 -1
  29. package/jnrs-template-vue/src/composables/base/useAvatar.ts +0 -36
  30. package/jnrs-template-vue/src/composables/tools/useMouseSelection.ts +0 -150
@@ -5,7 +5,7 @@ VITE_USE_MOCK = true
5
5
 
6
6
  # 后端接口基地址 - 开发环境
7
7
  # VITE_BASE_URL = '192.168.1.120:6001'
8
- VITE_BASE_URL = '192.168.1.120:5010'
8
+ VITE_BASE_URL = '192.168.1.112:5010'
9
9
 
10
10
  # 应用运行主机(置空默认为 localhost)
11
11
  VITE_APP_HOST = ''
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" href="/favicon.ico" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>create-jnrs-template</title>
7
+ <title>jnrs-template-vue</title>
8
8
  </head>
9
9
  <body>
10
10
  <div id="app"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jnrs-template-vue",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "JNRS 信息化管理系统模板",
5
5
  "author": "Talia-Tan",
6
6
  "private": true,
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@element-plus/icons-vue": "^2.3.2",
22
- "@jnrs/shared": "1.1.8",
23
- "@jnrs/vue-core": "1.2.3",
22
+ "@jnrs/shared": "1.1.9",
23
+ "@jnrs/vue-core": "1.2.4",
24
24
  "@vueuse/core": "^14.1.0",
25
25
  "element-plus": "^2.11.9",
26
26
  "pinia": "^3.0.4",
@@ -15,16 +15,25 @@
15
15
  "path": "/unitTest",
16
16
  "name": "UnitTest",
17
17
  "meta": {
18
- "title": "功能测试页面",
18
+ "title": "基础功能测试",
19
19
  "todoCount": 8
20
20
  },
21
21
  "component": "/demos/unitTest/index"
22
22
  },
23
+ {
24
+ "path": "/simpleTable",
25
+ "name": "SimpleTable",
26
+ "meta": {
27
+ "title": "简单数据表格模板",
28
+ "todoCount": 0
29
+ },
30
+ "component": "/demos/simpleTable/index"
31
+ },
23
32
  {
24
33
  "path": "/crud",
25
34
  "name": "Crud",
26
35
  "meta": {
27
- "title": "增删改查模板页面",
36
+ "title": "完整增删改查模板",
28
37
  "todoCount": 0
29
38
  },
30
39
  "component": "/demos/crud/index"
@@ -1,5 +1,7 @@
1
1
  import type { Pagination, PageTableData, FileContainer } from '@/types'
2
2
  import { axiosRequest } from '@jnrs/vue-core/request'
3
+ import { extractFieldId } from '@/utils/file'
4
+ import { objectToFormData } from '@/utils/packages'
3
5
 
4
6
  /**
5
7
  * 项目
@@ -26,6 +28,7 @@ export interface ProjectItem extends FileContainer {
26
28
  type AddProjectOmitKeys =
27
29
  | 'id'
28
30
  | 'programCode'
31
+ | 'program'
29
32
  | 'code'
30
33
  | 'manager'
31
34
  | 'plannedFinishDate'
@@ -36,7 +39,7 @@ type AddProjectOmitKeys =
36
39
 
37
40
  export type AddProjectItem = Omit<ProjectItem, AddProjectOmitKeys> & {
38
41
  id?: string
39
- managerId?: Record<string, unknown>
42
+ managerId?: Record<string, unknown> | unknown
40
43
  newAttachmentFile: []
41
44
  newImageFiles: []
42
45
  }
@@ -64,7 +67,7 @@ export const NotFoundApi = () => {
64
67
  * 测试 无权限
65
68
  * @param showErrorMsg 是否显示错误信息
66
69
  */
67
- export const NoAuth = (showErrorMsg: boolean) => {
70
+ export const NoAuthApi = (showErrorMsg: boolean) => {
68
71
  return axiosRequest({
69
72
  url: '/mock/auth/no',
70
73
  method: 'post',
@@ -83,31 +86,35 @@ export const DetailsApi = (): Promise<FileContainer> => {
83
86
  }
84
87
 
85
88
  /**
86
- * 表单新增
89
+ * 表单新增(包含文件上传类需使用 formData 类型)
87
90
  */
88
- export const DemosFormApi = (data: FormData) => {
91
+ export const EditApi = (data: AddProjectItem) => {
92
+ const formData = objectToFormData({
93
+ ...data,
94
+ managerId: extractFieldId(data.managerId, 'managerId')
95
+ })
89
96
  return axiosRequest({
90
97
  url: '/mock/demos/save',
91
98
  method: 'post',
92
- data
99
+ data: formData
93
100
  })
94
101
  }
95
102
 
96
103
  /**
97
104
  * 数据详情
98
105
  */
99
- export const DemosTableApi = (query: ProjectQuery): Promise<PageTableData<ProjectItem>> => {
106
+ export const TableApi = (data?: ProjectQuery): Promise<PageTableData<ProjectItem>> => {
100
107
  return axiosRequest({
101
108
  url: '/mock/demos/table',
102
109
  method: 'get',
103
- data: query
110
+ data
104
111
  })
105
112
  }
106
113
 
107
114
  /**
108
115
  * 下载数据导入模板
109
116
  */
110
- export const DemosImportTemplateApi = (): Promise<Blob> => {
117
+ export const ImportTemplateApi = (): Promise<Blob> => {
111
118
  return axiosRequest({
112
119
  url: '/mock/project/template',
113
120
  method: 'post'
@@ -117,7 +124,7 @@ export const DemosImportTemplateApi = (): Promise<Blob> => {
117
124
  /**
118
125
  * 项目数据导入
119
126
  */
120
- export const DemosImportDataApi = (data: FormData) => {
127
+ export const ImportDataApi = (data: { file: File }) => {
121
128
  return axiosRequest({
122
129
  url: '/mock/project/import',
123
130
  method: 'post',
@@ -128,7 +135,7 @@ export const DemosImportDataApi = (data: FormData) => {
128
135
  /**
129
136
  * 项目数据导出
130
137
  */
131
- export const DemosExportApi = (data: Record<string, unknown>): Promise<Blob> => {
138
+ export const ExportApi = (data: Record<string, unknown>): Promise<Blob> => {
132
139
  return axiosRequest({
133
140
  url: '/mock/project/export',
134
141
  method: 'post',
@@ -1,6 +1,7 @@
1
- import { axiosRequest } from '@jnrs/vue-core/request'
2
1
  import type { Dict, DictItem, User, Role } from '@jnrs/shared'
3
2
  import type { MenuItem } from '@jnrs/vue-core'
3
+ import { axiosRequest } from '@jnrs/vue-core/request'
4
+ import { objectToFormData } from '@/utils/packages'
4
5
 
5
6
  // 菜单
6
7
  export const MenuApi = (): Promise<MenuItem[]> => {
@@ -47,6 +48,15 @@ export const UserInfoApi = (): Promise<User> => {
47
48
  })
48
49
  }
49
50
 
51
+ // 修改头像
52
+ export const AvatarChangeApi = (data: File) => {
53
+ return axiosRequest({
54
+ url: '/api/user/avatar',
55
+ method: 'post',
56
+ data: objectToFormData({ file: data })
57
+ })
58
+ }
59
+
50
60
  // 修改密码
51
61
  interface PasswordChange {
52
62
  userId: number
@@ -3,27 +3,3 @@
3
3
  @use './root.scss';
4
4
  @use './common.scss';
5
5
  @use './animation.scss';
6
-
7
- body {
8
- width: 100vw;
9
- height: 100vh;
10
- color: var(--jnrs-font-primary);
11
- background: var(--jnrs-background-primary);
12
- }
13
-
14
- #app {
15
- min-width: 1280px;
16
- height: 100%;
17
- }
18
-
19
- /*
20
- * 禁止用户选中页面元素
21
- */
22
- .no-select {
23
- -khtml-user-drag: none;
24
- -webkit-user-drag: none;
25
- -webkit-user-select: none;
26
- -moz-user-select: none;
27
- -ms-user-select: none;
28
- user-select: none;
29
- }
@@ -27,3 +27,27 @@ img {
27
27
  object-fit: cover;
28
28
  object-position: center center;
29
29
  }
30
+
31
+ body {
32
+ width: 100vw;
33
+ height: 100vh;
34
+ color: var(--jnrs-font-primary);
35
+ background: var(--jnrs-background-primary);
36
+ }
37
+
38
+ #app {
39
+ min-width: 1280px;
40
+ height: 100%;
41
+ }
42
+
43
+ /*
44
+ * 禁止用户选中页面元素
45
+ */
46
+ .no-select {
47
+ -khtml-user-drag: none;
48
+ -webkit-user-drag: none;
49
+ -webkit-user-select: none;
50
+ -moz-user-select: none;
51
+ -ms-user-select: none;
52
+ user-select: none;
53
+ }
@@ -1,3 +1,7 @@
1
+ /*
2
+ * root 样式
3
+ */
4
+
1
5
  :root {
2
6
  // Layout 头部高度
3
7
  --jnrs-head-height: 50px;
@@ -0,0 +1,89 @@
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 { PageTableData, Pagination } 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
+ getTableDataApi?: (...args: any[]) => Promise<PageTableData<any>>
20
+ }
21
+
22
+ const { getTableDataApi } = defineProps<Props>()
23
+
24
+ const loading = ref(false)
25
+ const tableData = ref()
26
+ const total = ref(0)
27
+ const pagination = ref<Pagination>({ pageNo: 1, pageSize: 10 })
28
+
29
+ const getTable = debounce(async () => {
30
+ if (!getTableDataApi) {
31
+ return false
32
+ }
33
+ loading.value = true
34
+ try {
35
+ const res = await getTableDataApi({
36
+ ...pagination.value
37
+ })
38
+ tableData.value = res.list.map((item) => ({
39
+ ...item,
40
+ newImageFiles: item.imageDocument?.attachments ?? undefined,
41
+ newAttachmentFile: item.attachmentDocument?.attachments ?? undefined
42
+ }))
43
+ console.log(456)
44
+ total.value = res.count
45
+ } catch (error) {
46
+ console.error(error)
47
+ } finally {
48
+ loading.value = false
49
+ }
50
+ }, 300)
51
+
52
+ onActivated(() => {
53
+ console.log(123)
54
+
55
+ getTable()
56
+ })
57
+ </script>
58
+
59
+ <template>
60
+ <el-card class="cardTable" v-loading="loading">
61
+ <template #header>
62
+ <div class="cardTable_header">
63
+ <slot name="header"></slot>
64
+ </div>
65
+ </template>
66
+
67
+ <JnTable
68
+ :data="tableData"
69
+ :pagination="pagination"
70
+ :autoHeight="true"
71
+ :showScrollbar="true"
72
+ :showIndexColumn="true"
73
+ >
74
+ <slot name="table"></slot>
75
+ </JnTable>
76
+
77
+ <JnPagination :total="total" v-model="pagination" @change="getTable" />
78
+ </el-card>
79
+ </template>
80
+
81
+ <style lang="scss" scoped>
82
+ .cardTable {
83
+ .cardTable_header {
84
+ display: flex;
85
+ justify-content: space-between;
86
+ align-items: center;
87
+ }
88
+ }
89
+ </style>
@@ -58,13 +58,17 @@ const computedColor = computed(() => {
58
58
 
59
59
  <style lang="scss" scoped>
60
60
  .dictTag {
61
- padding: 2px 8px;
62
- border-radius: 8px;
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ padding: 1px 8px;
65
+ border-radius: 4px;
63
66
  white-space: nowrap;
67
+ transform: scale(0.8);
64
68
 
65
69
  .dictTag_label_showColor {
66
- font-size: 0.8em;
67
- filter: invert(0.6) brightness(0.6);
70
+ font-size: 1.1em;
71
+ filter: invert(0.5) brightness(0.5);
68
72
  }
69
73
  }
70
74
  </style>
@@ -13,9 +13,14 @@ interface Props {
13
13
  * 要加载的文件列表 | 文件名唯一标识 uniqueFileName
14
14
  */
15
15
  loadKeys: Attachment[] | string | undefined
16
+
17
+ /**
18
+ * 是否清理文件副作用
19
+ */
20
+ clearSideEffects: boolean
16
21
  }
17
22
 
18
- const { loadKeys } = defineProps<Props>()
23
+ const { loadKeys, clearSideEffects = false } = defineProps<Props>()
19
24
 
20
25
  // 第一张显示的图片
21
26
  const posterUrl = ref('')
@@ -28,10 +33,12 @@ const revokeFns = ref<(() => void)[]>([])
28
33
 
29
34
  // 清理当前所有 Object URL
30
35
  const clearUrls = () => {
31
- revokeFns.value.forEach((revoke) => revoke())
32
- revokeFns.value = []
33
- fileList.value = []
34
- posterUrl.value = ''
36
+ if (clearSideEffects) {
37
+ revokeFns.value.forEach((revoke) => revoke())
38
+ revokeFns.value = []
39
+ fileList.value = []
40
+ posterUrl.value = ''
41
+ }
35
42
  }
36
43
 
37
44
  // 加载文件
@@ -105,7 +112,7 @@ const loadFilesRaw = async (keys: Props['loadKeys']) => {
105
112
  }
106
113
  }
107
114
 
108
- const loadFiles = debounce(loadFilesRaw, 300)
115
+ const loadFiles = debounce(loadFilesRaw, 500)
109
116
 
110
117
  watch(
111
118
  () => loadKeys,
@@ -116,7 +123,9 @@ watch(
116
123
  )
117
124
 
118
125
  onActivated(() => {
119
- loadFiles(loadKeys)
126
+ if (clearSideEffects) {
127
+ loadFiles(loadKeys)
128
+ }
120
129
  })
121
130
 
122
131
  // 副作用清理
@@ -12,9 +12,14 @@ interface Props {
12
12
  * 要加载的文件列表 | 文件名唯一标识 uniqueFileName
13
13
  */
14
14
  loadKeys: Attachment[] | string | undefined
15
+
16
+ /**
17
+ * 是否清理文件副作用
18
+ */
19
+ clearSideEffects: boolean
15
20
  }
16
21
 
17
- const { loadKeys } = defineProps<Props>()
22
+ const { loadKeys, clearSideEffects = false } = defineProps<Props>()
18
23
 
19
24
  // 存储每个 URL 对应的 URL 对象
20
25
  const fileList = ref<FileItem[]>([])
@@ -24,9 +29,11 @@ const revokeFns = ref<(() => void)[]>([])
24
29
 
25
30
  // 清理当前所有 Object URL
26
31
  const clearUrls = () => {
27
- revokeFns.value.forEach((revoke) => revoke())
28
- revokeFns.value = []
29
- fileList.value = []
32
+ if (clearSideEffects) {
33
+ revokeFns.value.forEach((revoke) => revoke())
34
+ revokeFns.value = []
35
+ fileList.value = []
36
+ }
30
37
  }
31
38
 
32
39
  const loadFiles = async (keys: Props['loadKeys']) => {
@@ -87,7 +94,9 @@ watch(
87
94
  )
88
95
 
89
96
  onActivated(() => {
90
- loadFiles(loadKeys)
97
+ if (clearSideEffects) {
98
+ loadFiles(loadKeys)
99
+ }
91
100
  })
92
101
 
93
102
  // 副作用清理
@@ -7,12 +7,12 @@
7
7
  -->
8
8
 
9
9
  <script setup lang="ts">
10
- import { DemosTableApi } from '@/api/demos/index'
10
+ import { TableApi } from '@/api/demos/index'
11
11
  import { JnSelectTemplate } from '@jnrs/vue-core/components'
12
12
  </script>
13
13
 
14
14
  <template>
15
- <JnSelectTemplate tableName="项目经理" :keyValue="{ label: 'manager', value: 'managerId' }" :listApi="DemosTableApi">
15
+ <JnSelectTemplate tableName="项目经理" :keyValue="{ label: 'manager', value: 'managerId' }" :listApi="TableApi">
16
16
  <template #table>
17
17
  <el-table-column prop="manager" label="项目经理" align="center" sortable />
18
18
  </template>
@@ -68,7 +68,6 @@ $mainFontColor: rgba(255, 255, 255, 0.8);
68
68
  font-weight: normal;
69
69
  font-family: AlimamaShuHeiTi-Bold;
70
70
  white-space: nowrap;
71
- letter-spacing: 2px;
72
71
  transform: translate(-50%, -50%);
73
72
  transition: all 0.3s ease;
74
73
  filter: opacity(1);
@@ -1,21 +1,17 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed } from 'vue'
2
+ import { ref } from 'vue'
3
3
  import { storeToRefs } from 'pinia'
4
4
  import { ElMessageBox } from 'element-plus'
5
5
  import { handleRouter } from '@jnrs/vue-core/router'
6
6
  import { GlobalSetting } from '@jnrs/vue-core/components'
7
7
  import { useSystemStore, useAuthStore, useMenuStore } from '@jnrs/vue-core/pinia'
8
8
  import { LogoutApi } from '@/api/system'
9
- import { getDictLabel, getDictColor } from '@/utils/packages'
10
-
11
9
  import ImageView from '@/components/common/ImageView.vue'
10
+ import DictTag from '@/components/common/DictTag.vue'
12
11
 
13
12
  const { userInfo, clearAuth } = useAuthStore()
14
13
  const { clearMenu } = useMenuStore()
15
14
 
16
- const roleLabel = computed(() => getDictLabel('role', userInfo?.role || ''))
17
- const roleColor = computed(() => getDictColor('role', userInfo?.role || ''))
18
-
19
15
  const systemStore = useSystemStore()
20
16
  const { documentFullscreen } = storeToRefs(systemStore)
21
17
  const { toggleFullScreen } = systemStore
@@ -29,8 +25,8 @@ const handleLogout = async () => {
29
25
  type: 'warning'
30
26
  })
31
27
  await LogoutApi()
32
- await clearAuth()
33
- await clearMenu()
28
+ clearAuth()
29
+ clearMenu()
34
30
  handleRouter({ name: 'Login' }, 'replace')
35
31
  } catch {}
36
32
  }
@@ -68,16 +64,12 @@ const showGlobalSetting = () => {
68
64
  <span class="userMenu_reference">
69
65
  <ImageView class="userMenu_avatar" :loadKeys="userInfo?.avatarFileName" />
70
66
  <span>{{ userInfo?.name }}</span>
71
- <span class="userMenu_roleName" :style="{ color: roleColor }" v-if="userInfo?.role">[{{ roleLabel }}]</span>
67
+ <DictTag dictName="role" :value="userInfo.role" v-if="userInfo?.role" />
72
68
  <el-icon class="userMenu_icon"><arrow-down /></el-icon>
73
69
  </span>
74
70
  </template>
75
71
  <div class="userMenu_dropdown">
76
72
  <ImageView class="userMenu_dropdown_avatar" :loadKeys="userInfo?.avatarFileName" />
77
- <b>
78
- <span>{{ userInfo?.name }}</span>
79
- <span class="userMenu_roleName" :style="{ color: roleColor }" v-if="userInfo?.role">[{{ roleLabel }}]</span>
80
- </b>
81
73
  <div class="loginDateTime" v-if="userInfo?.loginDateTime">
82
74
  <span>登录时间</span>
83
75
  <p>{{ userInfo?.loginDateTime }}</p>
@@ -132,13 +124,14 @@ $topHoverSize: 35px;
132
124
  display: flex;
133
125
  align-items: center;
134
126
  margin-left: 16px;
135
- transition: all 0.25s ease;
136
127
  cursor: pointer;
137
128
  &:hover {
138
129
  span {
130
+ transition: all 0.25s ease;
139
131
  color: var(--jnrs-color-primary) !important;
140
132
  }
141
133
  .userMenu_icon {
134
+ transition: all 0.25s ease;
142
135
  color: var(--jnrs-color-primary);
143
136
  }
144
137
  }
@@ -0,0 +1,19 @@
1
+ export interface MsgIdMessage {
2
+ msgId: string
3
+ d: unknown
4
+ }
5
+
6
+ export interface TypeDataMessage {
7
+ type: string | number
8
+ data: unknown
9
+ }
10
+
11
+ // 类型守卫函数 MsgId 结构
12
+ export function isMsgIdMessage(msg: unknown): msg is MsgIdMessage {
13
+ return typeof msg === 'object' && msg !== null && 'msgId' in msg && 'd' in msg
14
+ }
15
+
16
+ // 类型守卫函数 Type 结构
17
+ export function isTypeDataMessage(msg: unknown): msg is TypeDataMessage {
18
+ return typeof msg === 'object' && msg !== null && 'type' in msg && 'data' in msg
19
+ }
@@ -6,7 +6,6 @@
6
6
  * @Desc. : 文件处理相关函数
7
7
  */
8
8
 
9
- // import { blobToUrl } from '@jnrs/shared'
10
9
  import { useObjectUrl } from '@vueuse/core'
11
10
  import { FileApi } from '@/api/common'
12
11
 
@@ -19,3 +18,39 @@ export const getFileUrl = async (uniqueFileName: string) => {
19
18
  const blob = await FileApi(uniqueFileName)
20
19
  return useObjectUrl(blob)
21
20
  }
21
+
22
+ /**
23
+ * 从可能为原始值或对象的输入中提取指定字段的值
24
+ * @param value - 输入值(可能是 string/number/对象)
25
+ * @param fieldName - 要提取的对象字段名(默认 'id')
26
+ * @returns 提取出的 string | number 值,或 undefined
27
+ */
28
+ export function extractFieldId(value: unknown, fieldName: string = 'id'): string | number | undefined {
29
+ // 1. 排除 null 和 undefined
30
+ if (value == null) return undefined
31
+
32
+ // 2. 如果是原始 ID 类型,直接返回
33
+ if (typeof value === 'string' || typeof value === 'number') {
34
+ return value
35
+ }
36
+
37
+ // 3. 必须是普通对象(非数组、非 Date 等)
38
+ if (typeof value !== 'object' || Array.isArray(value)) {
39
+ return undefined
40
+ }
41
+
42
+ // 4. 检查对象是否包含目标字段
43
+ if (!(fieldName in value)) {
44
+ return undefined
45
+ }
46
+
47
+ // 5. 安全获取字段值(使用 keyof 断言确保类型安全)
48
+ const fieldValue = (value as Record<string, unknown>)[fieldName]
49
+
50
+ // 6. 只接受 string 或 number 类型
51
+ if (typeof fieldValue === 'string' || typeof fieldValue === 'number') {
52
+ return fieldValue
53
+ }
54
+
55
+ return undefined
56
+ }