af-mobile-client-vue3 1.3.13 → 1.3.15
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.
- package/.claude/settings.local.json +1 -3
- package/package.json +1 -1
- package/src/components/data/CardHeader/CardContainer.vue +118 -0
- package/src/components/data/CardHeader/CardHeader.vue +99 -0
- package/src/components/data/UserDetail/index.vue +93 -12
- package/src/hooks/useLoading.ts +16 -0
- package/src/views/component/UserDetailView/index.vue +10 -0
- package/src/views/userRecords/OperateRecords.vue +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch } from 'vue'
|
|
3
|
+
import CardHeader from './CardHeader.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
shadow: {
|
|
7
|
+
type: Boolean,
|
|
8
|
+
default: true,
|
|
9
|
+
},
|
|
10
|
+
className: {
|
|
11
|
+
type: String,
|
|
12
|
+
default: '',
|
|
13
|
+
},
|
|
14
|
+
title: {
|
|
15
|
+
type: String,
|
|
16
|
+
default: '',
|
|
17
|
+
},
|
|
18
|
+
collapsible: {
|
|
19
|
+
type: Boolean,
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
defaultCollapsed: {
|
|
23
|
+
type: Boolean,
|
|
24
|
+
default: false,
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const isCollapsed = ref(props.defaultCollapsed)
|
|
29
|
+
|
|
30
|
+
function handleToggle(collapsed: boolean) {
|
|
31
|
+
isCollapsed.value = collapsed
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 过渡动画钩子函数
|
|
35
|
+
function onEnter(el: HTMLElement) {
|
|
36
|
+
el.style.height = '0'
|
|
37
|
+
el.style.overflow = 'hidden'
|
|
38
|
+
el.style.opacity = '0'
|
|
39
|
+
el.style.transformOrigin = 'top'
|
|
40
|
+
el.style.transform = 'scaleY(0.8)'
|
|
41
|
+
|
|
42
|
+
// 触发回流
|
|
43
|
+
void el.offsetHeight
|
|
44
|
+
|
|
45
|
+
el.style.transition = 'height 0.2s ease, opacity 0.2s ease, transform 0.2s ease'
|
|
46
|
+
el.style.height = `${el.scrollHeight}px`
|
|
47
|
+
el.style.opacity = '1'
|
|
48
|
+
el.style.transform = 'scaleY(1)'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function onAfterEnter(el: HTMLElement) {
|
|
52
|
+
el.style.height = ''
|
|
53
|
+
el.style.overflow = ''
|
|
54
|
+
el.style.transition = ''
|
|
55
|
+
el.style.transformOrigin = ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onBeforeLeave(el: HTMLElement) {
|
|
59
|
+
el.style.height = `${el.scrollHeight}px`
|
|
60
|
+
el.style.overflow = 'hidden'
|
|
61
|
+
el.style.transformOrigin = 'top'
|
|
62
|
+
|
|
63
|
+
// 触发回流
|
|
64
|
+
void el.offsetHeight
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onLeave(el: HTMLElement) {
|
|
68
|
+
el.style.transition = 'height 0.2s ease, opacity 0.2s ease, transform 0.2s ease'
|
|
69
|
+
el.style.height = '0'
|
|
70
|
+
el.style.opacity = '0'
|
|
71
|
+
el.style.transform = 'scaleY(0.8)'
|
|
72
|
+
}
|
|
73
|
+
watch(() => props.defaultCollapsed, (newVal) => {
|
|
74
|
+
isCollapsed.value = newVal
|
|
75
|
+
})
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<div class="card-container" :class="[shadow ? 'with-shadow' : '', className]">
|
|
80
|
+
<CardHeader
|
|
81
|
+
v-if="title"
|
|
82
|
+
:title="title"
|
|
83
|
+
:collapsible="collapsible"
|
|
84
|
+
:default-collapsed="defaultCollapsed"
|
|
85
|
+
@toggle="handleToggle"
|
|
86
|
+
/>
|
|
87
|
+
<transition
|
|
88
|
+
name="collapse-transition"
|
|
89
|
+
@enter="onEnter"
|
|
90
|
+
@after-enter="onAfterEnter"
|
|
91
|
+
@before-leave="onBeforeLeave"
|
|
92
|
+
@leave="onLeave"
|
|
93
|
+
>
|
|
94
|
+
<div v-show="!isCollapsed" class="card-content">
|
|
95
|
+
<slot />
|
|
96
|
+
</div>
|
|
97
|
+
</transition>
|
|
98
|
+
</div>
|
|
99
|
+
</template>
|
|
100
|
+
|
|
101
|
+
<style lang="less" scoped>
|
|
102
|
+
.card-container {
|
|
103
|
+
background-color: #fff;
|
|
104
|
+
border-radius: 8px;
|
|
105
|
+
border: 1px solid #e5e7eb;
|
|
106
|
+
padding: 10px;
|
|
107
|
+
// margin-bottom: 0;
|
|
108
|
+
|
|
109
|
+
&.with-shadow {
|
|
110
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.card-content {
|
|
115
|
+
will-change: height, opacity, transform;
|
|
116
|
+
transform-origin: top;
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
title: {
|
|
6
|
+
type: String,
|
|
7
|
+
required: true,
|
|
8
|
+
},
|
|
9
|
+
icon: {
|
|
10
|
+
type: String,
|
|
11
|
+
default: '',
|
|
12
|
+
},
|
|
13
|
+
collapsible: {
|
|
14
|
+
type: Boolean,
|
|
15
|
+
default: false,
|
|
16
|
+
},
|
|
17
|
+
defaultCollapsed: {
|
|
18
|
+
type: Boolean,
|
|
19
|
+
default: false,
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits(['toggle'])
|
|
24
|
+
|
|
25
|
+
const isCollapsed = ref(props.defaultCollapsed)
|
|
26
|
+
|
|
27
|
+
function toggleCollapse() {
|
|
28
|
+
if (props.collapsible) {
|
|
29
|
+
isCollapsed.value = !isCollapsed.value
|
|
30
|
+
emit('toggle', isCollapsed.value)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div class="card-header" @click="toggleCollapse">
|
|
37
|
+
<div class="card-header__title">
|
|
38
|
+
<slot name="icon">
|
|
39
|
+
<van-icon v-if="icon" class="card-header__icon" :name="icon" />
|
|
40
|
+
</slot>
|
|
41
|
+
<h4 class="card-header__text">
|
|
42
|
+
{{ title }}
|
|
43
|
+
</h4>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="card-header__extra">
|
|
46
|
+
<slot name="extra" />
|
|
47
|
+
<van-icon
|
|
48
|
+
v-if="collapsible"
|
|
49
|
+
class="card-header__collapse-icon"
|
|
50
|
+
:name="isCollapsed ? 'arrow-down' : 'arrow-up'"
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style lang="less" scoped>
|
|
57
|
+
.card-header {
|
|
58
|
+
display: flex;
|
|
59
|
+
justify-content: space-between;
|
|
60
|
+
align-items: center;
|
|
61
|
+
margin-bottom: 8px;
|
|
62
|
+
padding-bottom: 6px;
|
|
63
|
+
border-bottom: 1px solid #f0f0f0;
|
|
64
|
+
|
|
65
|
+
&__title {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
&__icon {
|
|
71
|
+
margin-right: 8px;
|
|
72
|
+
font-size: 14px;
|
|
73
|
+
color: #666;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
&__text {
|
|
77
|
+
font-size: 14px;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
color: #333;
|
|
80
|
+
margin: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
&__extra {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
&__collapse-icon {
|
|
89
|
+
margin-left: 8px;
|
|
90
|
+
font-size: 14px;
|
|
91
|
+
color: #666;
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
|
|
94
|
+
&:hover {
|
|
95
|
+
color: #333;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
</style>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { RecordEntry } from './recordEntries'
|
|
3
3
|
import type { BaseUser, ConfigItem } from './types'
|
|
4
|
-
import
|
|
4
|
+
import useLoading from '@af-mobile-client-vue3/hooks/useLoading'
|
|
5
5
|
import { mobileUtil } from '@af-mobile-client-vue3/utils/mobileUtil'
|
|
6
|
-
import { Button as VanButton } from 'vant'
|
|
6
|
+
import { Button as VanButton, Empty as VanEmpty, Icon as VanIcon, Loading as VanLoading } from 'vant'
|
|
7
7
|
import { computed, ref, watch } from 'vue'
|
|
8
8
|
import { useRouter } from 'vue-router'
|
|
9
9
|
import InfoDisplay from '../InfoDisplay/index.vue'
|
|
@@ -16,6 +16,8 @@ interface Props {
|
|
|
16
16
|
recordEntries?: RecordEntry[] // 记录入口配置(可选)
|
|
17
17
|
businessButtonText?: string // 业务办理按钮文本,默认"业务办理"
|
|
18
18
|
showBottomButtons?: boolean // 是否显示底部按钮区域,默认false
|
|
19
|
+
showHeader?: boolean // 是否显示头部导航栏,默认false
|
|
20
|
+
headerTitle?: string // 头部标题,默认"用户档案详情"
|
|
19
21
|
getUserDetailApi?: (id: string) => Promise<BaseUser> // 自定义获取用户详情接口
|
|
20
22
|
getRecentTimeApi?: (id: string) => Promise<Record<string, string>> // 自定义获取历史时间接口
|
|
21
23
|
}
|
|
@@ -31,6 +33,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
31
33
|
showRecentTime: false,
|
|
32
34
|
businessButtonText: '业务办理',
|
|
33
35
|
showBottomButtons: false,
|
|
36
|
+
showHeader: false,
|
|
37
|
+
headerTitle: '用户档案详情',
|
|
34
38
|
recordEntries: () => defaultRecordEntries,
|
|
35
39
|
})
|
|
36
40
|
|
|
@@ -39,14 +43,15 @@ const router = useRouter()
|
|
|
39
43
|
|
|
40
44
|
// 用户数据
|
|
41
45
|
const user = ref<BaseUser | null>(null)
|
|
42
|
-
const userLoading =
|
|
46
|
+
const { loading: userLoading, startLoading: startUserLoading, endLoading: endUserLoading } = useLoading(false)
|
|
47
|
+
const userError = ref<string | null>(null)
|
|
43
48
|
|
|
44
49
|
// 控制用户信息展开收起
|
|
45
50
|
const showUserInfo = ref(false)
|
|
46
51
|
|
|
47
52
|
// 最近记录
|
|
48
53
|
const recentRecord = ref<Record<string, string>>({})
|
|
49
|
-
const {
|
|
54
|
+
const { loading: recentRecordLoading, startLoading: startRecentRecordLoading, endLoading: endRecentRecordLoading } = useLoading(false)
|
|
50
55
|
|
|
51
56
|
// 状态类名
|
|
52
57
|
const statusClass = computed(() => {
|
|
@@ -122,25 +127,33 @@ async function fetchUserDetail() {
|
|
|
122
127
|
return
|
|
123
128
|
|
|
124
129
|
try {
|
|
125
|
-
|
|
130
|
+
startUserLoading()
|
|
131
|
+
userError.value = null
|
|
126
132
|
const api = props.getUserDetailApi || getCacheUserDetail
|
|
127
133
|
user.value = await api(props.userInfoId)
|
|
128
134
|
}
|
|
129
135
|
catch (error) {
|
|
130
136
|
console.error('获取用户详情失败:', error)
|
|
137
|
+
userError.value = error instanceof Error ? error.message : '获取用户详情失败'
|
|
138
|
+
user.value = null
|
|
131
139
|
}
|
|
132
140
|
finally {
|
|
133
|
-
|
|
141
|
+
endUserLoading()
|
|
134
142
|
}
|
|
135
143
|
}
|
|
136
144
|
|
|
145
|
+
// 重试获取用户详情
|
|
146
|
+
function retryFetchUserDetail() {
|
|
147
|
+
fetchUserDetail()
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
// 获取最近记录
|
|
138
151
|
async function fetchRecentRecord() {
|
|
139
152
|
if (!props.showRecentTime || !props.userInfoId)
|
|
140
153
|
return
|
|
141
154
|
|
|
142
155
|
try {
|
|
143
|
-
|
|
156
|
+
startRecentRecordLoading()
|
|
144
157
|
const api = props.getRecentTimeApi || getRecentBusinessTime
|
|
145
158
|
recentRecord.value = await api(props.userInfoId)
|
|
146
159
|
}
|
|
@@ -149,7 +162,7 @@ async function fetchRecentRecord() {
|
|
|
149
162
|
recentRecord.value = {}
|
|
150
163
|
}
|
|
151
164
|
finally {
|
|
152
|
-
|
|
165
|
+
endRecentRecordLoading()
|
|
153
166
|
}
|
|
154
167
|
}
|
|
155
168
|
|
|
@@ -214,8 +227,40 @@ watch(() => props.userInfoId, async (newId) => {
|
|
|
214
227
|
</script>
|
|
215
228
|
|
|
216
229
|
<template>
|
|
217
|
-
<div
|
|
218
|
-
|
|
230
|
+
<div class="user-detail" :class="{ 'has-header': props.showHeader }">
|
|
231
|
+
<!-- 头部导航栏 -->
|
|
232
|
+
<div v-if="props.showHeader" class="user-detail__header">
|
|
233
|
+
<div class="header-left">
|
|
234
|
+
<button type="button" class="back-button" @click="emit('close')">
|
|
235
|
+
<VanIcon name="arrow-left" />
|
|
236
|
+
</button>
|
|
237
|
+
<h3 class="header-title">
|
|
238
|
+
{{ props.headerTitle }}
|
|
239
|
+
</h3>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<!-- 加载状态 -->
|
|
244
|
+
<div v-if="userLoading" class="loading-container">
|
|
245
|
+
<VanLoading size="24px" vertical>
|
|
246
|
+
加载中...
|
|
247
|
+
</VanLoading>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<!-- 错误状态 -->
|
|
251
|
+
<div v-else-if="userError" class="error-container">
|
|
252
|
+
<VanEmpty
|
|
253
|
+
image="error"
|
|
254
|
+
:description="userError"
|
|
255
|
+
>
|
|
256
|
+
<VanButton type="primary" @click="retryFetchUserDetail">
|
|
257
|
+
重试
|
|
258
|
+
</VanButton>
|
|
259
|
+
</VanEmpty>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<!-- 用户详情内容 -->
|
|
263
|
+
<div v-else-if="user" class="user-detail__content">
|
|
219
264
|
<!-- 用户基本信息卡片 -->
|
|
220
265
|
<div class="user-info-card">
|
|
221
266
|
<div
|
|
@@ -306,7 +351,7 @@ watch(() => props.userInfoId, async (newId) => {
|
|
|
306
351
|
.user-detail {
|
|
307
352
|
position: relative;
|
|
308
353
|
background-color: #f5f7fa;
|
|
309
|
-
|
|
354
|
+
min-height: 100vh;
|
|
310
355
|
padding-bottom: 70px;
|
|
311
356
|
|
|
312
357
|
&__header {
|
|
@@ -326,6 +371,42 @@ watch(() => props.userInfoId, async (newId) => {
|
|
|
326
371
|
&__content {
|
|
327
372
|
padding: 16px;
|
|
328
373
|
}
|
|
374
|
+
|
|
375
|
+
// 当有 header 时,为 content 腾出空间
|
|
376
|
+
&.has-header &__content {
|
|
377
|
+
padding-top: calc(16px + 46px); // 16px原有padding + 56px header高度
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 加载状态
|
|
382
|
+
.loading-container {
|
|
383
|
+
display: flex;
|
|
384
|
+
justify-content: center;
|
|
385
|
+
align-items: center;
|
|
386
|
+
height: 300px;
|
|
387
|
+
background-color: #fff;
|
|
388
|
+
margin: 16px;
|
|
389
|
+
border-radius: 8px;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 错误状态
|
|
393
|
+
.error-container {
|
|
394
|
+
display: flex;
|
|
395
|
+
justify-content: center;
|
|
396
|
+
align-items: center;
|
|
397
|
+
min-height: 300px;
|
|
398
|
+
background-color: #fff;
|
|
399
|
+
margin: 16px;
|
|
400
|
+
border-radius: 8px;
|
|
401
|
+
padding: 32px 16px;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 当有 header 时,调整加载和错误状态的位置
|
|
405
|
+
.has-header {
|
|
406
|
+
.loading-container,
|
|
407
|
+
.error-container {
|
|
408
|
+
margin-top: calc(16px + 56px); // header高度 + 原有margin
|
|
409
|
+
}
|
|
329
410
|
}
|
|
330
411
|
|
|
331
412
|
.header-left {
|
|
@@ -439,7 +520,7 @@ watch(() => props.userInfoId, async (newId) => {
|
|
|
439
520
|
}
|
|
440
521
|
|
|
441
522
|
.user-info-body {
|
|
442
|
-
padding:
|
|
523
|
+
padding: 16px 16px;
|
|
443
524
|
border-top: 1px solid #f0f0f0;
|
|
444
525
|
}
|
|
445
526
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import useBoolean from './useBoolean'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 加载状态管理 Hook
|
|
5
|
+
* @param initValue 初始加载状态,默认为 false
|
|
6
|
+
* @returns {object} 包含 loading 状态和控制方法
|
|
7
|
+
*/
|
|
8
|
+
export default function useLoading(initValue = false) {
|
|
9
|
+
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue)
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
loading,
|
|
13
|
+
startLoading,
|
|
14
|
+
endLoading,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -104,6 +104,16 @@ function switchUser() {
|
|
|
104
104
|
<span class="prop-type">boolean</span>
|
|
105
105
|
<span class="prop-desc">是否显示底部按钮区域</span>
|
|
106
106
|
</div>
|
|
107
|
+
<div class="props-row">
|
|
108
|
+
<span class="prop-name">showHeader</span>
|
|
109
|
+
<span class="prop-type">boolean</span>
|
|
110
|
+
<span class="prop-desc">是否显示头部导航栏</span>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="props-row">
|
|
113
|
+
<span class="prop-name">headerTitle</span>
|
|
114
|
+
<span class="prop-type">string</span>
|
|
115
|
+
<span class="prop-desc">头部标题文本</span>
|
|
116
|
+
</div>
|
|
107
117
|
<div class="props-row">
|
|
108
118
|
<span class="prop-name">#bottom</span>
|
|
109
119
|
<span class="prop-type">slot</span>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import XCellList from '@af-mobile-client-vue3/components/data/XCellList/index.vue'
|
|
3
|
+
import XExpandDetail from '@af-mobile-client-vue3/views/userRecords/operateRecordDetail/index.vue'
|
|
3
4
|
import { computed, ref } from 'vue'
|
|
4
5
|
import { useRoute } from 'vue-router'
|
|
5
|
-
import XExpandDetail from '@/views/userRecords/operateRecordDetail/index.vue'
|
|
6
6
|
// 简易crud表单测试——变更记录
|
|
7
7
|
const configName = 'mobile_operateRecordCRUD'
|
|
8
8
|
const serviceName = 'af-revenue'
|