@kine-design/crud 0.0.1-beta.15 → 0.0.1-beta.17

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.
@@ -0,0 +1 @@
1
+ 594292cc-8321-4629-97d9-91ad432e0646
@@ -34,9 +34,31 @@
34
34
  width: 100%;
35
35
  }
36
36
 
37
- /* ===== 空图片占位 ===== */
37
+ /* ===== 列表图片 ===== */
38
+ .k-crud-page .k-image-placeholder {
39
+ min-width: unset;
40
+ min-height: unset;
41
+ }
42
+
38
43
  .k-image-empty {
39
44
  color: var(--kine-color-text-muted);
40
45
  opacity: 0.4;
41
46
  }
42
47
 
48
+ /* ================================================================
49
+ 移动端(<768px)
50
+ ================================================================ */
51
+
52
+ @media (max-width: 767px) {
53
+ .k-crud-page {
54
+ padding: var(--kine-spacing-4);
55
+ gap: var(--kine-spacing-4);
56
+ }
57
+
58
+ /* 筛选项自动换行,撑满宽度 */
59
+ .k-crud-page-filter-item {
60
+ min-width: 0;
61
+ flex: 1 1 100%;
62
+ }
63
+ }
64
+
@@ -2,16 +2,14 @@
2
2
  * @description KEditableTable — 可编辑行表格组件
3
3
  * @author 阿怪
4
4
  * @date 2026/3/22
5
- * @version v0.0.1
5
+ * @version v0.1.0
6
6
  *
7
7
  * 江湖的业务千篇一律,复杂的代码好几百行。
8
8
  *
9
- * 支持行内编辑、添加行、删除行、汇总行。
10
- * 设计参考 AIDesigner EditableDataTable 规范:
11
- * - 正常行:纯展示
12
- * - 编辑行:左侧靛蓝竖线,cell 变为 input
13
- * - 删除确认行:红色高亮 + 内联确认
14
- * - 新增行:底部空行带搜索输入
9
+ * 所有行永远可编辑,不区分查看/编辑态。
10
+ * - 每个 cell 直接渲染为 input
11
+ * - 删除:内联确认
12
+ * - 新增:底部按钮
15
13
  * - 汇总行:底部加粗合计
16
14
  */
17
15
  import { defineComponent, ref, type PropType } from 'vue';
@@ -31,7 +29,7 @@ export interface EditableColumn {
31
29
  label: string;
32
30
  /** 列宽 */
33
31
  width?: string;
34
- /** 编辑态下的输入类型 */
32
+ /** 输入类型 */
35
33
  editType?: 'text' | 'number' | 'select' | 'search' | 'readonly';
36
34
  /** select 类型的选项 */
37
35
  options?: Array<{ label: string; value: string | number }>;
@@ -78,43 +76,22 @@ export default defineComponent({
78
76
  /** 币种前缀(用于金额汇总展示) */
79
77
  currencyPrefix: { type: String, default: '' },
80
78
  },
81
- emits: ['update:modelValue', 'add', 'edit', 'save', 'cancel', 'remove'],
79
+ emits: ['update:modelValue', 'add', 'remove'],
82
80
  setup(props, { emit, slots }) {
83
- const editingId = ref<string | number | null>(null);
84
81
  const deletingId = ref<string | number | null>(null);
85
- const editingData = ref<Record<string, unknown>>({});
86
82
 
87
- // ── 行操作 ────────────────────────────────────
83
+ // ── 更新某行某字段 ──────────────────────────────
88
84
 
89
- const startEdit = (row: EditableTableRow) => {
90
- editingId.value = row._id;
91
- editingData.value = { ...row };
92
- deletingId.value = null;
93
- emit('edit', row);
94
- };
95
-
96
- const saveEdit = () => {
97
- if (editingId.value === null) return;
85
+ const updateCell = (rowIdx: number, param: string, value: unknown) => {
98
86
  const rows = [...props.modelValue];
99
- const idx = rows.findIndex(r => r._id === editingId.value);
100
- if (idx >= 0) {
101
- rows[idx] = { ...editingData.value } as EditableTableRow;
102
- emit('update:modelValue', rows);
103
- emit('save', rows[idx]);
104
- }
105
- editingId.value = null;
106
- editingData.value = {};
87
+ rows[rowIdx] = { ...rows[rowIdx], [param]: value };
88
+ emit('update:modelValue', rows);
107
89
  };
108
90
 
109
- const cancelEdit = () => {
110
- editingId.value = null;
111
- editingData.value = {};
112
- emit('cancel');
113
- };
91
+ // ── 删除 ────────────────────────────────────────
114
92
 
115
93
  const startDelete = (row: EditableTableRow) => {
116
94
  deletingId.value = row._id;
117
- editingId.value = null;
118
95
  };
119
96
 
120
97
  const confirmDelete = () => {
@@ -145,37 +122,30 @@ export default defineComponent({
145
122
  return sum.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
146
123
  };
147
124
 
148
- // ── 渲染 ──────────────────────────────────────
125
+ // ── 渲染单元格(永远是输入框) ──────────────────
126
+
127
+ const renderCell = (col: EditableColumn, row: EditableTableRow, rowIdx: number) => {
128
+ const editSlot = slots[`edit-${col.param}`];
129
+ if (editSlot) return editSlot({ row, value: row[col.param], update: (v: unknown) => updateCell(rowIdx, col.param, v) });
149
130
 
150
- /** 渲染普通单元格 */
151
- const renderCell = (col: EditableColumn, row: EditableTableRow) => {
152
131
  const customSlot = slots[`column-${col.param}`];
153
132
  if (customSlot) return customSlot({ row, value: row[col.param] });
154
133
 
155
- const val = row[col.param];
156
- return val != null ? String(val) : '';
157
- };
158
-
159
- /** 渲染编辑态单元格 */
160
- const renderEditCell = (col: EditableColumn) => {
161
- const editSlot = slots[`edit-${col.param}`];
162
- if (editSlot) return editSlot({ data: editingData.value });
163
-
164
134
  const type = col.editType ?? 'text';
165
135
 
166
136
  if (type === 'readonly') {
167
- return <span class="k-et-cell-readonly">{editingData.value[col.param] as string ?? ''}</span>;
137
+ return <span class="k-et-cell-readonly">{row[col.param] as string ?? ''}</span>;
168
138
  }
169
139
 
170
140
  if (type === 'select' && col.options) {
171
141
  return (
172
142
  <KSelect
173
143
  class="k-et-select-wrap"
174
- modelValue={editingData.value[col.param]}
144
+ modelValue={row[col.param]}
175
145
  options={col.options}
176
146
  optionParam="label"
177
147
  valueParam="value"
178
- onUpdate:modelValue={(v: unknown) => { editingData.value[col.param] = v; }}
148
+ onUpdate:modelValue={(v: unknown) => updateCell(rowIdx, col.param, v)}
179
149
  />
180
150
  );
181
151
  }
@@ -184,8 +154,8 @@ export default defineComponent({
184
154
  <KInput
185
155
  class="k-et-input-wrap"
186
156
  type={type === 'number' ? 'number' : 'text'}
187
- modelValue={editingData.value[col.param] as string | number}
188
- onUpdate:modelValue={(v: string) => { editingData.value[col.param] = v; }}
157
+ modelValue={row[col.param] as string | number}
158
+ onUpdate:modelValue={(v: string) => updateCell(rowIdx, col.param, v)}
189
159
  />
190
160
  );
191
161
  };
@@ -233,58 +203,34 @@ export default defineComponent({
233
203
 
234
204
  <tbody class="k-et-tbody">
235
205
  {rows.map((row, i) => {
236
- const isEditing = editingId.value === row._id;
237
206
  const isDeleting = deletingId.value === row._id;
238
207
 
239
- // 删除确认行
240
208
  if (isDeleting) {
241
209
  return (
242
210
  <tr key={row._id} class="k-et-tr k-et-tr--deleting">
243
211
  {props.showIndex && <td class="k-et-td k-et-td-index">{String(i + 1).padStart(2, '0')}</td>}
244
212
  <td class="k-et-td" colspan={cols.length + (props.showActions ? 1 : 0)}>
245
213
  <span class="k-et-delete-confirm">
246
- <s class="k-et-deleted-text">{renderCell(cols[0], row)}</s>
247
- <span class="k-et-delete-ask">Are you sure you want to delete this line?</span>
248
- <KButton type="danger" size="small" text="Delete" onClick={confirmDelete} />
249
- <KButton plain size="small" text="Cancel" onClick={cancelDelete} />
214
+ <s class="k-et-deleted-text">{row[cols[0].param] as string ?? ''}</s>
215
+ <span class="k-et-delete-ask">确定删除这一行?</span>
216
+ <KButton type="danger" size="small" text="删除" onClick={confirmDelete} />
217
+ <KButton plain size="small" text="取消" onClick={cancelDelete} />
250
218
  </span>
251
219
  </td>
252
220
  </tr>
253
221
  );
254
222
  }
255
223
 
256
- // 编辑行
257
- if (isEditing) {
258
- return (
259
- <tr key={row._id} class="k-et-tr k-et-tr--editing">
260
- {props.showIndex && <td class="k-et-td k-et-td-index">{String(i + 1).padStart(2, '0')}</td>}
261
- {cols.map(col => (
262
- <td key={col.param} class={['k-et-td', col.align && `k-et-align-${col.align}`]}>
263
- {renderEditCell(col)}
264
- </td>
265
- ))}
266
- {props.showActions && (
267
- <td class="k-et-td k-et-td-actions">
268
- <KButton plain size="small" text="✓" onClick={saveEdit} />
269
- <KButton plain size="small" text="×" onClick={cancelEdit} />
270
- </td>
271
- )}
272
- </tr>
273
- );
274
- }
275
-
276
- // 正常行
277
224
  return (
278
- <tr key={row._id} class="k-et-tr">
225
+ <tr key={row._id} class="k-et-tr k-et-tr--editing">
279
226
  {props.showIndex && <td class="k-et-td k-et-td-index">{String(i + 1).padStart(2, '0')}</td>}
280
227
  {cols.map(col => (
281
228
  <td key={col.param} class={['k-et-td', col.align && `k-et-align-${col.align}`]}>
282
- {renderCell(col, row)}
229
+ {renderCell(col, row, i)}
283
230
  </td>
284
231
  ))}
285
232
  {props.showActions && (
286
233
  <td class="k-et-td k-et-td-actions">
287
- <KButton plain size="small" text="✎" onClick={() => startEdit(row)} />
288
234
  <KButton plain size="small" text="×" onClick={() => startDelete(row)} />
289
235
  </td>
290
236
  )}
@@ -585,3 +585,45 @@
585
585
  color: var(--kine-color-text-primary, #e5e5e5);
586
586
  }
587
587
 
588
+ /* ================================================================
589
+ 移动端(<768px)
590
+ ================================================================ */
591
+
592
+ @media (max-width: 767px) {
593
+ .k-form-page {
594
+ padding: 0 0 var(--kine-spacing-6);
595
+ }
596
+
597
+ /* 多列网格在移动端强制单列 */
598
+ .k-fp-grid--2,
599
+ .k-fp-grid--3 {
600
+ grid-template-columns: 1fr;
601
+ }
602
+
603
+ /* 跨列字段在单列模式下不需要 span */
604
+ .k-fp-field[style*="grid-column"] {
605
+ grid-column: span 1 !important;
606
+ }
607
+
608
+ .k-form-card-body {
609
+ padding: var(--kine-spacing-6);
610
+ }
611
+
612
+ .k-form-card-header {
613
+ padding: var(--kine-spacing-6) var(--kine-spacing-6);
614
+ }
615
+
616
+ .k-fp-header {
617
+ margin-bottom: var(--kine-spacing-6);
618
+ }
619
+
620
+ .k-sticky-action-bar {
621
+ padding: var(--kine-spacing-4) var(--kine-spacing-6);
622
+ margin: 0 calc(-1 * var(--kine-spacing-6)) calc(-1 * var(--kine-spacing-6));
623
+ }
624
+
625
+ .k-approval-dialog {
626
+ width: calc(100vw - 32px);
627
+ }
628
+ }
629
+
@@ -6,13 +6,21 @@
6
6
  *
7
7
  * 江湖的业务千篇一律,复杂的代码好几百行。
8
8
  */
9
- import { defineComponent } from 'vue';
9
+ import { defineComponent, inject } from 'vue';
10
+ import { LAYOUT_TOGGLE_DRAWER_KEY } from './KLayout';
10
11
 
11
12
  export default defineComponent({
12
13
  name: 'KHeader',
13
14
  setup(_, ctx) {
15
+ const toggleDrawer = inject(LAYOUT_TOGGLE_DRAWER_KEY, () => {});
16
+
14
17
  return () => (
15
18
  <header class="k-crud-header">
19
+ {/* 汉堡按钮(移动端可见,桌面端 CSS 隐藏) */}
20
+ <button class="k-crud-header-hamburger" onClick={toggleDrawer}>
21
+ <span class="k-crud-header-hamburger-icon" />
22
+ </button>
23
+
16
24
  {/* 左侧/默认内容区(面包屑等) */}
17
25
  <div class="k-crud-header-main">
18
26
  {ctx.slots.default?.()}
@@ -13,6 +13,8 @@ export const LAYOUT_COLLAPSED_KEY: InjectionKey<Ref<boolean>> = Symbol('kCrudLay
13
13
  export const LAYOUT_TOGGLE_KEY: InjectionKey<() => void> = Symbol('kCrudLayoutToggle');
14
14
  export const LAYOUT_SIDER_WIDTH_KEY: InjectionKey<string> = Symbol('kCrudLayoutSiderWidth');
15
15
  export const LAYOUT_COLLAPSED_WIDTH_KEY: InjectionKey<string> = Symbol('kCrudLayoutCollapsedWidth');
16
+ export const LAYOUT_DRAWER_OPEN_KEY: InjectionKey<Ref<boolean>> = Symbol('kCrudLayoutDrawerOpen');
17
+ export const LAYOUT_TOGGLE_DRAWER_KEY: InjectionKey<() => void> = Symbol('kCrudLayoutToggleDrawer');
16
18
 
17
19
  export default defineComponent({
18
20
  name: 'KLayout',
@@ -54,14 +56,25 @@ export default defineComponent({
54
56
  ctx.emit('update:collapsed', next);
55
57
  };
56
58
 
59
+ // 移动端抽屉开关状态
60
+ const drawerOpen = ref(false);
61
+ const toggleDrawer = () => { drawerOpen.value = !drawerOpen.value; };
62
+
57
63
  // 向子组件提供折叠状态、切换方法和宽度配置
58
64
  provide(LAYOUT_COLLAPSED_KEY, innerCollapsed);
59
65
  provide(LAYOUT_TOGGLE_KEY, toggleCollapsed);
60
66
  provide(LAYOUT_SIDER_WIDTH_KEY, props.siderWidth);
61
67
  provide(LAYOUT_COLLAPSED_WIDTH_KEY, props.collapsedWidth);
68
+ provide(LAYOUT_DRAWER_OPEN_KEY, drawerOpen);
69
+ provide(LAYOUT_TOGGLE_DRAWER_KEY, toggleDrawer);
62
70
 
63
71
  return () => (
64
72
  <div class="k-crud-layout">
73
+ {/* 移动端遮罩层,点击关闭抽屉 */}
74
+ <div
75
+ class={['k-crud-sider-overlay', drawerOpen.value && 'k-crud-sider-overlay--visible']}
76
+ onClick={toggleDrawer}
77
+ />
65
78
  {ctx.slots.default?.()}
66
79
  </div>
67
80
  );
@@ -6,12 +6,13 @@
6
6
  *
7
7
  * 江湖的业务千篇一律,复杂的代码好几百行。
8
8
  */
9
- import { computed, defineComponent, inject } from 'vue';
9
+ import { computed, defineComponent, inject, ref } from 'vue';
10
10
  import {
11
11
  LAYOUT_COLLAPSED_KEY,
12
12
  LAYOUT_TOGGLE_KEY,
13
13
  LAYOUT_SIDER_WIDTH_KEY,
14
14
  LAYOUT_COLLAPSED_WIDTH_KEY,
15
+ LAYOUT_DRAWER_OPEN_KEY,
15
16
  } from './KLayout';
16
17
 
17
18
  export default defineComponent({
@@ -20,6 +21,7 @@ export default defineComponent({
20
21
  // 从 KLayout 注入折叠状态、切换方法和宽度配置
21
22
  const collapsed = inject(LAYOUT_COLLAPSED_KEY);
22
23
  const toggle = inject(LAYOUT_TOGGLE_KEY);
24
+ const drawerOpen = inject(LAYOUT_DRAWER_OPEN_KEY, ref(false));
23
25
  const siderWidth = inject(LAYOUT_SIDER_WIDTH_KEY, '240px');
24
26
  const collapsedWidth = inject(LAYOUT_COLLAPSED_WIDTH_KEY, '64px');
25
27
 
@@ -52,6 +54,7 @@ export default defineComponent({
52
54
  class={[
53
55
  'k-crud-sider',
54
56
  isCollapsed.value && 'k-crud-sider--collapsed',
57
+ drawerOpen.value && 'k-crud-sider--drawer-open',
55
58
  ]}
56
59
  style={{ width: currentWidth.value }}
57
60
  >
@@ -147,3 +147,116 @@
147
147
  padding: var(--kine-spacing-8);
148
148
  box-sizing: border-box;
149
149
  }
150
+
151
+ /* ===== 遮罩层(桌面端隐藏) ===== */
152
+ .k-crud-sider-overlay {
153
+ display: none;
154
+ position: fixed;
155
+ inset: 0;
156
+ z-index: 999;
157
+ background: rgba(0, 0, 0, 0);
158
+ pointer-events: none;
159
+ transition: background 0.3s var(--kine-motion-easing-default);
160
+ }
161
+
162
+ .k-crud-sider-overlay--visible {
163
+ background: rgba(0, 0, 0, 0.45);
164
+ pointer-events: auto;
165
+ }
166
+
167
+ /* ===== 汉堡按钮 ===== */
168
+ .k-crud-header-hamburger {
169
+ display: none; /* 桌面端隐藏 */
170
+ flex-direction: column;
171
+ justify-content: center;
172
+ align-items: center;
173
+ width: 36px;
174
+ height: 36px;
175
+ flex-shrink: 0;
176
+ margin-right: var(--kine-spacing-4);
177
+ background: none;
178
+ border: none;
179
+ cursor: pointer;
180
+ padding: var(--kine-spacing-2);
181
+ border-radius: var(--kine-radius-xs);
182
+ color: var(--kine-color-text-secondary);
183
+ transition: background var(--kine-motion-duration-fast) var(--kine-motion-easing-default);
184
+ }
185
+
186
+ .k-crud-header-hamburger:hover {
187
+ background: var(--kine-color-bg-hover);
188
+ color: var(--kine-color-text-primary);
189
+ }
190
+
191
+ /* 三条横线图标 */
192
+ .k-crud-header-hamburger-icon,
193
+ .k-crud-header-hamburger-icon::before,
194
+ .k-crud-header-hamburger-icon::after {
195
+ display: block;
196
+ width: 18px;
197
+ height: 2px;
198
+ background: currentColor;
199
+ border-radius: 1px;
200
+ }
201
+
202
+ .k-crud-header-hamburger-icon {
203
+ position: relative;
204
+ }
205
+
206
+ .k-crud-header-hamburger-icon::before,
207
+ .k-crud-header-hamburger-icon::after {
208
+ content: '';
209
+ position: absolute;
210
+ left: 0;
211
+ }
212
+
213
+ .k-crud-header-hamburger-icon::before { top: -5px; }
214
+ .k-crud-header-hamburger-icon::after { top: 5px; }
215
+
216
+ /* ================================================================
217
+ 移动端(<768px)
218
+ ================================================================ */
219
+
220
+ @media (max-width: 767px) {
221
+ /* 汉堡按钮显示 */
222
+ .k-crud-header-hamburger {
223
+ display: flex;
224
+ }
225
+
226
+ /* 侧边栏变为 fixed 抽屉,默认隐藏 */
227
+ .k-crud-sider {
228
+ position: fixed;
229
+ top: 0;
230
+ left: 0;
231
+ height: 100%;
232
+ width: 240px !important; /* 覆盖 inline style */
233
+ z-index: 1000;
234
+ transform: translateX(-100%);
235
+ transition:
236
+ transform 0.3s var(--kine-motion-easing-default),
237
+ box-shadow 0.3s var(--kine-motion-easing-default);
238
+ box-shadow: none;
239
+ border-right: none;
240
+ }
241
+
242
+ /* 抽屉打开 */
243
+ .k-crud-sider--drawer-open {
244
+ transform: translateX(0);
245
+ box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
246
+ }
247
+
248
+ /* 遮罩层 */
249
+ .k-crud-sider-overlay {
250
+ display: block;
251
+ }
252
+
253
+ /* 底部折叠 trigger 在移动端隐藏 */
254
+ .k-crud-sider-trigger {
255
+ display: none;
256
+ }
257
+
258
+ /* 内容区 padding 缩减 */
259
+ .k-crud-content {
260
+ padding: var(--kine-spacing-4);
261
+ }
262
+ }
@@ -91,3 +91,31 @@
91
91
  flex-shrink: 0;
92
92
  padding: var(--kine-spacing-4) 0;
93
93
  }
94
+
95
+ /* ================================================================
96
+ 移动端(<768px)
97
+ ================================================================ */
98
+
99
+ @media (max-width: 767px) {
100
+ /* 搜索区从横排改为纵排 */
101
+ .k-search-table-search {
102
+ flex-direction: column;
103
+ align-items: stretch;
104
+ padding: var(--kine-spacing-6);
105
+ gap: var(--kine-spacing-4);
106
+ }
107
+
108
+ .k-search-table-search-form {
109
+ flex-direction: column;
110
+ }
111
+
112
+ /* 搜索按钮区靠右 */
113
+ .k-search-table-search-actions {
114
+ justify-content: flex-end;
115
+ }
116
+
117
+ /* 表格区允许横向滚动 */
118
+ .k-search-table-body {
119
+ overflow-x: auto;
120
+ }
121
+ }
@@ -134,11 +134,24 @@ function buildAuthReturn(
134
134
  clearStorage();
135
135
  }
136
136
 
137
+ let restoring: Promise<void> | null = null;
138
+
137
139
  async function restore(): Promise<void> {
138
140
  if (!adapter) return;
141
+ // 防止并发调用(如 mount + tab 激活同时触发)
142
+ if (restoring) return restoring;
139
143
 
140
- const storedToken = adapter.get<string>(STORAGE_KEY_TOKEN);
141
- const storedUser = adapter.get<AuthState['user']>(STORAGE_KEY_USER);
144
+ restoring = doRestore();
145
+ try {
146
+ await restoring;
147
+ } finally {
148
+ restoring = null;
149
+ }
150
+ }
151
+
152
+ async function doRestore(): Promise<void> {
153
+ const storedToken = adapter!.get<string>(STORAGE_KEY_TOKEN);
154
+ const storedUser = adapter!.get<AuthState['user']>(STORAGE_KEY_USER);
142
155
 
143
156
  // storage 中没有有效数据,跳过
144
157
  if (!storedToken || !storedUser) return;
@@ -213,6 +226,13 @@ export function createAuth(app: App, options: AuthOptions): AuthReturn {
213
226
  }
214
227
  };
215
228
  window.addEventListener('storage', onStorageChange);
229
+
230
+ // app.unmount 时清理监听器,防止泄漏
231
+ const origUnmount = app.unmount.bind(app);
232
+ app.unmount = () => {
233
+ window.removeEventListener('storage', onStorageChange);
234
+ origUnmount();
235
+ };
216
236
  }
217
237
 
218
238
  // 将 auth 实例注入 Vue 组件树,同时挂载 onUnauthorized 回调供 authGuard 使用
@@ -58,6 +58,7 @@ export function createRequest(options: RequestOptions = {}): RequestClient {
58
58
  getToken: options.getToken,
59
59
  onUnauthorized: options.onUnauthorized,
60
60
  responseInterceptor: options.responseInterceptor,
61
+ feedback: options.feedback,
61
62
  registerAbort: (controller: AbortController) => {
62
63
  abortControllers.add(controller);
63
64
  // 完成后自动移除
@@ -11,10 +11,12 @@ import type {
11
11
  CachePolicy,
12
12
  ControlPolicy,
13
13
  Lifecycle,
14
+ NetworkError,
14
15
  RequestConfig,
15
16
  RequestMethod,
16
17
  RetryPolicy,
17
18
  Transport,
19
+ TransportResponse,
18
20
  WrappedResponse,
19
21
  } from './types';
20
22
  import { BusinessError, NetworkRequestError } from './types';
@@ -89,6 +91,20 @@ async function unwrapResponse<T>(
89
91
  ): Promise<T> {
90
92
  // HTTP 错误
91
93
  if (response.status < 200 || response.status >= 300) {
94
+ // 4xx:尝试解析 body,提取业务错误信息
95
+ if (response.status >= 400 && response.status < 500) {
96
+ const ct = response.headers['content-type'] ?? '';
97
+ if (ct.includes('application/json')) {
98
+ try {
99
+ const json = await response.json<WrappedResponse<unknown>>();
100
+ if (isWrappedResponse(json) && !json.success) {
101
+ throw new BusinessError(json.message || `请求失败(${response.status})`);
102
+ }
103
+ } catch (e) {
104
+ if (e instanceof BusinessError) throw e;
105
+ }
106
+ }
107
+ }
92
108
  throw new NetworkRequestError({
93
109
  type: 'httpError',
94
110
  status: response.status,
@@ -180,6 +196,8 @@ export interface RequestBuilderContext {
180
196
  getToken?: () => string | null | undefined;
181
197
  onUnauthorized?: () => void;
182
198
  responseInterceptor?: <T>(data: T) => T;
199
+ /** 用户反馈处理器(写操作成功后自动 showSuccess) */
200
+ feedback?: import('./types').UserFeedbackHandler;
183
201
  /** 注册 abort controller,供生命周期管理 */
184
202
  registerAbort?: (controller: AbortController) => void;
185
203
  }
@@ -344,6 +362,11 @@ export class RequestBuilder {
344
362
  // 响应拦截器
345
363
  const finalResult = responseInterceptor ? responseInterceptor(result) : result;
346
364
 
365
+ // 写操作成功后自动反馈
366
+ if (this.context.feedback && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method)) {
367
+ this.context.feedback.showSuccess('操作成功');
368
+ }
369
+
347
370
  return finalResult as T;
348
371
  } catch (error) {
349
372
  // 401 检测
@@ -365,14 +388,23 @@ export class RequestBuilder {
365
388
  body: BodyInit | null,
366
389
  signal: AbortSignal,
367
390
  ): Promise<T> {
368
- const response = await transport.send({
369
- url,
370
- method: this.method,
371
- headers,
372
- body,
373
- signal,
374
- timeout: this.config.timeout,
375
- });
391
+ let response: TransportResponse;
392
+ try {
393
+ response = await transport.send({
394
+ url,
395
+ method: this.method,
396
+ headers,
397
+ body,
398
+ signal,
399
+ timeout: this.config.timeout,
400
+ });
401
+ } catch (error) {
402
+ // transport 层抛出的是 NetworkError 裸对象,统一包装为 NetworkRequestError
403
+ if (error && typeof error === 'object' && 'type' in error) {
404
+ throw new NetworkRequestError(error as NetworkError);
405
+ }
406
+ throw new NetworkRequestError({ type: 'unknown', error });
407
+ }
376
408
 
377
409
  return unwrapResponse<T>(response);
378
410
  }
@@ -72,7 +72,10 @@ export class XhrTransport implements Transport {
72
72
  headers,
73
73
  body: null,
74
74
  text: () => Promise.resolve(responseText),
75
- json: <T>() => Promise.resolve(JSON.parse(responseText) as T),
75
+ json: <T>() => {
76
+ try { return Promise.resolve(JSON.parse(responseText) as T); }
77
+ catch (e) { return Promise.reject(e); }
78
+ },
76
79
  blob: () => Promise.resolve(new Blob([responseText])),
77
80
  };
78
81