dk-frontend-skills 1.0.2 → 1.0.3
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.json +4 -0
- package/.claude/skills/fe-biz-patterns/SKILL.md +5 -2
- package/.claude/skills/fe-biz-patterns/references/infinite-scroll.md +15 -18
- package/.claude/skills/fe-biz-patterns/references/pinia-store.md +174 -0
- package/.claude/skills/fe-biz-patterns/references/service-layer.md +198 -0
- package/.claude/skills/fe-biz-patterns/references/use-loading.md +114 -0
- package/CLAUDE.md +1 -1
- package/package.json +2 -2
package/.claude/settings.json
CHANGED
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
"security-review": {
|
|
48
48
|
"enabled": true,
|
|
49
49
|
"description": "待变更代码安全审查"
|
|
50
|
+
},
|
|
51
|
+
"fe-biz-patterns": {
|
|
52
|
+
"enabled": true,
|
|
53
|
+
"description": "前端业务模式库,loading/滚动加载/导入导出/批量操作/表单联动/大列表渲染/Service层封装/Pinia Store模式/分页数据管理等常见业务场景"
|
|
50
54
|
}
|
|
51
55
|
},
|
|
52
56
|
"always_apply_skills": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fe-biz-patterns
|
|
3
|
-
description: 前端业务模式库,收录 XiaoMa
|
|
3
|
+
description: 前端业务模式库,收录 XiaoMa 在实际业务中沉淀的前端方案和最佳实践。当用户要求实现包含以下特征的功能时使用:loading/加载/加载态、滚动加载/无限滚动/触底加载、数据导入导出、批量操作、表单联动、权限控制、大列表渲染、文件上传、实时搜索、拖拽排序、Service层封装/请求层/axios封装、Pinia Store模式/状态管理/分页数据管理等常见前端业务场景。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 前端业务模式库
|
|
@@ -13,9 +13,12 @@ XiaoMa 实战沉淀的前端业务方案集合,覆盖日常开发中高频出
|
|
|
13
13
|
|
|
14
14
|
| 方案 | 适用场景 | 参考文档 |
|
|
15
15
|
|------|---------|---------|
|
|
16
|
+
| 通用 loading 管理 | 表单提交、数据加载、异步操作的 loading 状态控制 | [use-loading.md](references/use-loading.md) |
|
|
16
17
|
| 滚动触底自动加载 | 列表滚动加载更多、无限滚动、分页拉取 | [infinite-scroll.md](references/infinite-scroll.md) |
|
|
18
|
+
| Service 层封装 | axios 实例封装、API 模块组织、拦截器管理、多 API 源隔离 | [service-layer.md](references/service-layer.md) |
|
|
19
|
+
| Pinia Store 模式 | Setup Store 格局、分页数据管理、storeToRefs 解构规范 | [pinia-store.md](references/pinia-store.md) |
|
|
17
20
|
|
|
18
|
-
>
|
|
21
|
+
> **使用方式**:根据用户需求匹配上方方案,点击对应链接阅读完整实现文档,按文档中的模式生成代码。选择方案后**必须向用户确认**再执行。
|
|
19
22
|
|
|
20
23
|
## 扩展指南
|
|
21
24
|
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
## 自定义 Hook
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
|
28
|
+
import { ref, nextTick, onMounted, onUnmounted, type Ref } from 'vue'
|
|
29
|
+
import { useLoading } from '@/composables/useLoading'
|
|
29
30
|
|
|
30
31
|
interface UseInfiniteScrollOptions<T> {
|
|
31
32
|
/** 分页请求函数,返回当前页的数据数组。返回空数组时视为加载完毕 */
|
|
@@ -44,7 +45,7 @@ export function useInfiniteScroll<T>(options: UseInfiniteScrollOptions<T>) {
|
|
|
44
45
|
const { fetcher, root, rootMargin = '100px', threshold = 0, immediate = true } = options
|
|
45
46
|
|
|
46
47
|
const data = ref<T[]>([]) as Ref<T[]>
|
|
47
|
-
const loading =
|
|
48
|
+
const { loading, withLoading } = useLoading() // 详见 [use-loading.md](use-loading.md)
|
|
48
49
|
const error = ref<Error | null>(null)
|
|
49
50
|
const isFinished = ref(false)
|
|
50
51
|
const page = ref(0)
|
|
@@ -57,28 +58,25 @@ export function useInfiniteScroll<T>(options: UseInfiniteScrollOptions<T>) {
|
|
|
57
58
|
async function loadMore() {
|
|
58
59
|
if (loading.value || isFinished.value) return
|
|
59
60
|
|
|
60
|
-
loading.value = true
|
|
61
61
|
error.value = null
|
|
62
62
|
const currentPage = page.value + 1
|
|
63
63
|
|
|
64
64
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
65
|
+
await withLoading(async () => {
|
|
66
|
+
const res = await fetcher(currentPage)
|
|
67
|
+
// 卸载后丢弃结果
|
|
68
|
+
if (stopLoading) return
|
|
69
|
+
|
|
70
|
+
if (res.length === 0) {
|
|
71
|
+
isFinished.value = true
|
|
72
|
+
} else {
|
|
73
|
+
data.value.push(...res)
|
|
74
|
+
page.value = currentPage
|
|
75
|
+
}
|
|
76
|
+
})
|
|
75
77
|
} catch (e) {
|
|
76
78
|
if (stopLoading) return
|
|
77
79
|
error.value = e as Error
|
|
78
|
-
} finally {
|
|
79
|
-
if (!stopLoading) {
|
|
80
|
-
loading.value = false
|
|
81
|
-
}
|
|
82
80
|
}
|
|
83
81
|
}
|
|
84
82
|
|
|
@@ -93,7 +91,6 @@ export function useInfiniteScroll<T>(options: UseInfiniteScrollOptions<T>) {
|
|
|
93
91
|
page.value = 0
|
|
94
92
|
isFinished.value = false
|
|
95
93
|
error.value = null
|
|
96
|
-
loading.value = false
|
|
97
94
|
|
|
98
95
|
if (immediate) {
|
|
99
96
|
await loadMore()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Pinia Store 封装方案
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
基于 Pinia Setup Store(Composition API 风格)的状态管理方案。
|
|
6
|
+
|
|
7
|
+
核心模式:
|
|
8
|
+
1. **Setup Store 格局** — `defineStore` + Composition API,天然支持响应式
|
|
9
|
+
2. **异步 action + loading 分离** — store 只管理数据,loading 状态交给 view 层的 `useLoading`
|
|
10
|
+
3. **分页数据管理** — 首屏替换 / 翻页追加的通用模式
|
|
11
|
+
|
|
12
|
+
## 目录结构
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
src/stores/
|
|
16
|
+
home.ts -- 首页 store
|
|
17
|
+
city.ts -- 城市 store
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
每个文件对应一个业务域,与 service/modules 一一对应。
|
|
21
|
+
|
|
22
|
+
## Setup Store 格局
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
export const useHomeStore = defineStore('home', () => {
|
|
26
|
+
// --- 状态 ---
|
|
27
|
+
const xxx = ref<T>(...)
|
|
28
|
+
|
|
29
|
+
// --- 计算属性(可选) ---
|
|
30
|
+
const xxxName = computed(() => ...)
|
|
31
|
+
|
|
32
|
+
// --- 异步操作 ---
|
|
33
|
+
const fetchXxx = async () => {
|
|
34
|
+
const res = await getXxx()
|
|
35
|
+
xxx.value = res.data || []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- 同步操作 ---
|
|
39
|
+
const setXxx = (val: T) => { xxx.value = val }
|
|
40
|
+
|
|
41
|
+
return { xxx, xxxName, fetchXxx, setXxx }
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 命名规范
|
|
46
|
+
|
|
47
|
+
| 项目 | 规范 |
|
|
48
|
+
|------|------|
|
|
49
|
+
| Store 变量 | `use[Name]Store` |
|
|
50
|
+
| Store ID | 小写英文,与文件名一致 |
|
|
51
|
+
| 导出方式 | `export const`(统一使用命名导出) |
|
|
52
|
+
| 状态 | `ref()` 定义原始数据 |
|
|
53
|
+
| 派生 | `computed()` 定义计算属性 |
|
|
54
|
+
| 异步操作 | `fetch` / `load` 前缀 |
|
|
55
|
+
| 同步操作 | `set` / `select` 前缀,动词主导 |
|
|
56
|
+
|
|
57
|
+
## 异步 Action 模式
|
|
58
|
+
|
|
59
|
+
### 原则:Store 只管数据,不管 UI 状态
|
|
60
|
+
|
|
61
|
+
store 中的异步函数只负责获取数据、更新状态,不管理 loading/error:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const fetchHotSuggests = async () => {
|
|
65
|
+
const res = await getHotSuggests()
|
|
66
|
+
hotSuggests.value = res.data || []
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
View 层通过 `useLoading` 组合多个 action:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// Home.vue
|
|
74
|
+
const { loading: initLoading, withLoading } = useLoading()
|
|
75
|
+
|
|
76
|
+
onMounted(() => {
|
|
77
|
+
withLoading(async () => {
|
|
78
|
+
await fetchHotSuggests()
|
|
79
|
+
await fetchCategories()
|
|
80
|
+
await fetchHouseList()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
这样 store 保持纯数据逻辑,view 控制 UI 状态粒度(可以多个 action 共享一个 loading,也可以各自独立)。
|
|
86
|
+
|
|
87
|
+
## 分页数据管理
|
|
88
|
+
|
|
89
|
+
### 核心状态
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const list = ref<T[]>([]) // 列表数据
|
|
93
|
+
const currentPage = ref(1) // 当前页码
|
|
94
|
+
const hasMore = ref(true) // 是否还有更多
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 加载函数
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const fetchList = async (page: number = 1) => {
|
|
101
|
+
const res = await getList(page)
|
|
102
|
+
// 假设后端返回 { errcode: 0, data: [...] }
|
|
103
|
+
if (res.errcode === 0) {
|
|
104
|
+
if (page === 1) {
|
|
105
|
+
list.value = res.data || [] // 首页:替换
|
|
106
|
+
} else {
|
|
107
|
+
list.value.push(...(res.data || [])) // 翻页:追加
|
|
108
|
+
}
|
|
109
|
+
currentPage.value = page
|
|
110
|
+
hasMore.value = (res.data || []).length > 0
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### View 层使用
|
|
116
|
+
|
|
117
|
+
```vue
|
|
118
|
+
<script setup>
|
|
119
|
+
const { list, currentPage, hasMore } = storeToRefs(store)
|
|
120
|
+
const { fetchList } = store
|
|
121
|
+
|
|
122
|
+
// 首次加载
|
|
123
|
+
onMounted(() => fetchList())
|
|
124
|
+
|
|
125
|
+
// 加载更多
|
|
126
|
+
function onLoadMore() {
|
|
127
|
+
if (!hasMore.value) return
|
|
128
|
+
fetchList(currentPage.value + 1)
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<template>
|
|
133
|
+
<div v-for="item in list" :key="item.id">{{ item.name }}</div>
|
|
134
|
+
<div v-if="hasMore" @click="onLoadMore">加载更多</div>
|
|
135
|
+
</template>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### 边界处理
|
|
139
|
+
|
|
140
|
+
| 场景 | 处理 |
|
|
141
|
+
|------|------|
|
|
142
|
+
| 空数据 | `res.data || []` 保底,避免 `.push(undefined)` |
|
|
143
|
+
| 已加载完毕 | `hasMore` 为 false 时拦截加载请求 |
|
|
144
|
+
| 搜索条件变化 | 重置 page = 1,重新 fetchList |
|
|
145
|
+
| 后端页码不从 1 开始 | 调整 `page === 1` 的判断逻辑 |
|
|
146
|
+
|
|
147
|
+
## View 层消费 Store 的方式
|
|
148
|
+
|
|
149
|
+
### 推荐:解构响应式状态 + 保留 action
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { useHomeStore } from '@/stores/home'
|
|
153
|
+
import { storeToRefs } from 'pinia'
|
|
154
|
+
|
|
155
|
+
const store = useHomeStore()
|
|
156
|
+
// 响应式状态:必须用 storeToRefs 包裹,否则会丢失响应性
|
|
157
|
+
const { hotSuggests, houseList, hasMore } = storeToRefs(store)
|
|
158
|
+
// 非响应式方法/action:直接从 store 实例解构
|
|
159
|
+
const { fetchHotSuggests, fetchHouseList } = store
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### storeToRefs 规则
|
|
163
|
+
|
|
164
|
+
| 类型 | 解构方式 | 原因 |
|
|
165
|
+
|------|---------|------|
|
|
166
|
+
| `ref` / `computed` | `storeToRefs(store)` | 保持响应性 |
|
|
167
|
+
| 函数(action) | `store.xxx` 或解构 | 本身就是普通函数,无响应式问题 |
|
|
168
|
+
| 普通值 | `store.xxx` | 非响应式属性 |
|
|
169
|
+
|
|
170
|
+
## 注意事项
|
|
171
|
+
|
|
172
|
+
1. **不要在 store 外直接修改 `storeToRefs` 的值**:`storeToRefs` 返回的是 ref,改 `xxx.value = ...` 会直接修改 store 状态。如果只是想取值不改值,用 `toRef(store, 'xxx')` 只读
|
|
173
|
+
2. **避免 store 循环依赖**:Store A 调用 Store B 的 action 时,在 action 内部 `useXxxStore()`,不要在模块顶层调用
|
|
174
|
+
3. **SSR 兼容**:`useStore()` 调用要在组件的 `setup` 或 `pinia` 实例已挂载后执行
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Service 层封装方案
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
基于 axios 的请求层封装,核心模式:
|
|
6
|
+
|
|
7
|
+
1. **Request 类 + 单例** — 封装 axios 实例,统一配置和拦截器
|
|
8
|
+
2. **按业务域拆分模块** — `modules/[domain].ts`,类型与函数共置
|
|
9
|
+
3. **config 集中管理** — baseURL、超时、状态码映射统一维护
|
|
10
|
+
|
|
11
|
+
## 目录结构
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
src/service/
|
|
15
|
+
index.ts -- 统一导出
|
|
16
|
+
request/
|
|
17
|
+
config.ts -- 配置集中管理
|
|
18
|
+
index.ts -- Request 类(axios 封装)
|
|
19
|
+
modules/
|
|
20
|
+
home.ts -- 首页 API
|
|
21
|
+
city.ts -- 城市 API
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Request 类封装
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
class Request {
|
|
28
|
+
private instance: AxiosInstance
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
this.instance = axios.create({
|
|
32
|
+
baseURL: config.baseURL,
|
|
33
|
+
timeout: config.timeout,
|
|
34
|
+
headers: config.headers,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public get<T>(url, params?, options?): Promise<T>
|
|
39
|
+
public post<T>(url, data?, options?): Promise<T>
|
|
40
|
+
public put<T>(url, data?, options?): Promise<T>
|
|
41
|
+
public delete<T>(url, params?, options?): Promise<T>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const request = new Request()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
关键设计点:
|
|
48
|
+
|
|
49
|
+
- **单例模式**:整个应用共享一个 Request 实例,避免重复创建
|
|
50
|
+
- **泛型方法**:`get<T>` 返回 `Promise<T>`,调用方通过类型参数控制返回值类型
|
|
51
|
+
- **RequestOptions**:扩展 `AxiosRequestConfig`,预留 `showLoading`、`showError` 等业务字段
|
|
52
|
+
|
|
53
|
+
## 拦截器(可选启用)
|
|
54
|
+
|
|
55
|
+
拦截器默认不激活,需要时显式调用 `request.enableInterceptors()`:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
class Request {
|
|
59
|
+
/** 启用拦截器:token 注入 + 统一错误处理 */
|
|
60
|
+
enableInterceptors() {
|
|
61
|
+
// 请求拦截器:自动注入 token
|
|
62
|
+
this.instance.interceptors.request.use((config) => {
|
|
63
|
+
const token = localStorage.getItem('token')
|
|
64
|
+
if (token) {
|
|
65
|
+
config.headers.Authorization = `Bearer ${token}`
|
|
66
|
+
}
|
|
67
|
+
return config
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// 响应拦截器:解包数据 + 统一错误提示
|
|
71
|
+
this.instance.interceptors.response.use(
|
|
72
|
+
(response) => {
|
|
73
|
+
const { code, message, data } = response.data
|
|
74
|
+
if (code === 0) return data // 成功:直接返回业务数据
|
|
75
|
+
console.error('请求失败:', message)
|
|
76
|
+
return Promise.reject(new Error(message))
|
|
77
|
+
},
|
|
78
|
+
(error) => {
|
|
79
|
+
if (error.response) {
|
|
80
|
+
console.error(statusCodeMap[error.response.status] || '网络请求失败')
|
|
81
|
+
} else {
|
|
82
|
+
console.error('网络连接失败')
|
|
83
|
+
}
|
|
84
|
+
return Promise.reject(error)
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
启用后,API 函数的写法会简化——不再需要手动解 `res.data`,因为响应拦截器已经解了一层。
|
|
92
|
+
|
|
93
|
+
## Config 集中管理
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// src/service/request/config.ts
|
|
97
|
+
export interface RequestConfig {
|
|
98
|
+
baseURL: string
|
|
99
|
+
timeout: number
|
|
100
|
+
headers?: Record<string, string>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const config: RequestConfig = {
|
|
104
|
+
baseURL: 'http://xxx/api',
|
|
105
|
+
timeout: 10000,
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const statusCodeMap: Record<number, string> = {
|
|
110
|
+
400: '请求参数错误',
|
|
111
|
+
401: '未授权,请登录',
|
|
112
|
+
403: '拒绝访问',
|
|
113
|
+
404: '请求地址不存在',
|
|
114
|
+
500: '服务器内部错误',
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
多 API 源时,在 config 中添加对应配置对象,Request 类中用单独 instance 隔离:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
export const mapConfig = {
|
|
122
|
+
baseURL: '/api/map',
|
|
123
|
+
key: 'xxx',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// request/index.ts
|
|
127
|
+
class Request {
|
|
128
|
+
private mapInstance: AxiosInstance | null = null
|
|
129
|
+
|
|
130
|
+
public mapGet<T>(url, params?): Promise<T> {
|
|
131
|
+
if (!this.mapInstance) {
|
|
132
|
+
this.mapInstance = axios.create({ baseURL: mapConfig.baseURL, timeout: config.timeout })
|
|
133
|
+
}
|
|
134
|
+
return this.mapInstance.get(url, { params })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## API 模块组织
|
|
140
|
+
|
|
141
|
+
### 按业务域拆分
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// src/service/modules/home.ts
|
|
145
|
+
import { request } from '../request'
|
|
146
|
+
|
|
147
|
+
// 类型定义与 API 函数共置
|
|
148
|
+
export interface HouseListItem { /* ... */ }
|
|
149
|
+
|
|
150
|
+
export const getHotSuggests = async () => {
|
|
151
|
+
const res = await request.get('/home/hotSuggests')
|
|
152
|
+
return res.data
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const getHouseList = async (page = 1) => {
|
|
156
|
+
const res = await request.get('/home/houselist', { page })
|
|
157
|
+
return res.data
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 规则
|
|
162
|
+
|
|
163
|
+
| 原则 | 说明 |
|
|
164
|
+
|------|------|
|
|
165
|
+
| 一域一文件 | 每个业务模块一个文件,命名与后端资源对应 |
|
|
166
|
+
| 类型共置 | 接口响应类型定义在 API 函数同一文件,就近维护 |
|
|
167
|
+
| 函数即接口 | 每个 API 导出一个 async 函数,参数即业务参数 |
|
|
168
|
+
| 统一导出 | `service/index.ts` 只导出 request 实例和核心类型,modules 由消费方按需 import |
|
|
169
|
+
|
|
170
|
+
## View 层消费方式
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { getHotSuggests } from '@/service/modules/home'
|
|
174
|
+
|
|
175
|
+
onMounted(async () => {
|
|
176
|
+
const res = await getHotSuggests()
|
|
177
|
+
hotSuggests.value = res.data || []
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
或通过 store 间接调用:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// store 中调用 service
|
|
185
|
+
const fetchHotSuggests = async () => {
|
|
186
|
+
const res = await getHotSuggests()
|
|
187
|
+
hotSuggests.value = res.data || []
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// view 中调用 store action
|
|
191
|
+
onMounted(() => store.fetchHotSuggests())
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## 注意事项
|
|
195
|
+
|
|
196
|
+
1. **拦截器启用时机**:`enableInterceptors()` 应在应用初始化时(如 `main.ts`)调用一次,避免重复注册
|
|
197
|
+
2. **响应拦截器副作用**:启用后 API 函数返回的是解包后的业务数据,而非完整 AxiosResponse,与之配套的 store 逻辑需要同步调整
|
|
198
|
+
3. **泛型参数**:`request.get<T>` 的 `T` 只是类型标注,不会在运行时做校验,后端返回格式异常仍需前端容错
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# 通用 Loading 管理方案
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
异步操作的 loading 状态管理是前端最基础的需求之一。`useLoading` 是一个极简的通用 loading 包装器,用最小的代码量解决 loading 状态控制问题。
|
|
6
|
+
|
|
7
|
+
## 源码
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/composables/useLoading.ts
|
|
11
|
+
import { ref } from 'vue'
|
|
12
|
+
|
|
13
|
+
export function useLoading(initial = false) {
|
|
14
|
+
const loading = ref(initial)
|
|
15
|
+
|
|
16
|
+
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
|
|
17
|
+
loading.value = true
|
|
18
|
+
try {
|
|
19
|
+
return await fn()
|
|
20
|
+
} finally {
|
|
21
|
+
loading.value = false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { loading, withLoading }
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
核心逻辑只有一行:执行前设 `true`,finally 中设回 `false`,保证无论成功还是失败 loading 都能复位。
|
|
30
|
+
|
|
31
|
+
## 使用示例
|
|
32
|
+
|
|
33
|
+
### 表单提交
|
|
34
|
+
|
|
35
|
+
```vue
|
|
36
|
+
<script setup lang="ts">
|
|
37
|
+
const { loading, withLoading } = useLoading()
|
|
38
|
+
|
|
39
|
+
const onSubmit = async () => {
|
|
40
|
+
await withLoading(() => updateUser(form.value))
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<van-button :loading="loading" @click="onSubmit">提交</van-button>
|
|
46
|
+
</template>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 初始数据加载
|
|
50
|
+
|
|
51
|
+
```vue
|
|
52
|
+
<script setup lang="ts">
|
|
53
|
+
const { loading, withLoading } = useLoading(true) // 初始就为 true
|
|
54
|
+
|
|
55
|
+
onMounted(() => {
|
|
56
|
+
withLoading(async () => {
|
|
57
|
+
await fetchUserInfo()
|
|
58
|
+
await fetchPermissions()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<BaseLoading :loading="loading" type="spinner" text="正在加载..." vertical />
|
|
65
|
+
<template v-if="!loading">
|
|
66
|
+
<!-- 主体内容 -->
|
|
67
|
+
</template>
|
|
68
|
+
</template>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 多个独立 loading(初始加载 + 加载更多)
|
|
72
|
+
|
|
73
|
+
```vue
|
|
74
|
+
<script setup lang="ts">
|
|
75
|
+
// 首次加载
|
|
76
|
+
const { loading: initLoading, withLoading: withInitLoading } = useLoading()
|
|
77
|
+
// 加载更多
|
|
78
|
+
const { loading: moreLoading, withLoading: withMoreLoading } = useLoading()
|
|
79
|
+
|
|
80
|
+
const onLoadMore = () => {
|
|
81
|
+
withMoreLoading(() => fetchHouseList(page + 1))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onMounted(() => {
|
|
85
|
+
withInitLoading(async () => {
|
|
86
|
+
await fetchHotSuggests()
|
|
87
|
+
await fetchCategories()
|
|
88
|
+
await fetchHouseList()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<template>
|
|
94
|
+
<!-- 首次加载用全屏 loading -->
|
|
95
|
+
<BaseLoading :loading="initLoading" type="spinner" vertical />
|
|
96
|
+
<!-- 加载更多只影响底部 -->
|
|
97
|
+
<div v-if="moreLoading">加载更多...</div>
|
|
98
|
+
</template>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 常见问题
|
|
102
|
+
|
|
103
|
+
| 场景 | 处理方式 |
|
|
104
|
+
|------|---------|
|
|
105
|
+
| 重复点击提交 | `loading` 为 true 时按钮置灰 / `:loading` 属性,无需额外逻辑 |
|
|
106
|
+
| 多个请求并行 | 需要分别管理 loading 时创建多个 `useLoading` 实例 |
|
|
107
|
+
| 组件卸载 | 不影响,`withLoading` 的 finally 中赋值不会有问题(Vue 会处理) |
|
|
108
|
+
| 需要 loading 之外的状态 | 搭配 `error`、`data` 等额外 ref 使用,`useLoading` 只负责 loading |
|
|
109
|
+
|
|
110
|
+
## 注意事项
|
|
111
|
+
|
|
112
|
+
1. **不要滥用**:简单的 Boolean 控制直接写模板里就行,不需要每个异步操作都抽成一个 composable
|
|
113
|
+
2. **区分 loading 粒度**:页面级 loading 用一个实例,列表加载更多用另一个实例,互不干扰
|
|
114
|
+
3. **`withLoading` 的返回值**:它返回的是 `fn()` 的 Promise 结果,可以用它链式处理后续逻辑
|
package/CLAUDE.md
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dk-frontend-skills",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "dk-engineer -
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "dk-engineer - 幽默沉稳靠谱的前端开发助手 Claude Skills 配置包,一键注入 .claude/ 技能目录和 CLAUDE.md 人设配置",
|
|
5
5
|
"author": "XiaoMa",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|