@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.
- package/.vlaude/last-session-id +1 -0
- package/components/crudPage/crudPage.css +23 -1
- package/components/editableTable/KEditableTable.tsx +28 -82
- package/components/formPage/formPage.css +42 -0
- package/components/layout/KHeader.tsx +9 -1
- package/components/layout/KLayout.tsx +13 -0
- package/components/layout/KSider.tsx +4 -1
- package/components/layout/layout.css +113 -0
- package/components/searchTable/searchTable.css +28 -0
- package/composables/auth/useAuth.ts +22 -2
- package/composables/request/createRequest.ts +1 -0
- package/composables/request/requestBuilder.ts +40 -8
- package/composables/request/transport/xhrTransport.ts +4 -1
- package/dist/components/editableTable/KEditableTable.d.ts +2 -5
- package/dist/components/layout/KLayout.d.ts +2 -0
- package/dist/composables/request/requestBuilder.d.ts +2 -0
- package/dist/crud.css +313 -1
- package/dist/crud.js +259 -139
- package/package.json +3 -3
|
@@ -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
|
|
5
|
+
* @version v0.1.0
|
|
6
6
|
*
|
|
7
7
|
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
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', '
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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">{
|
|
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={
|
|
144
|
+
modelValue={row[col.param]}
|
|
175
145
|
options={col.options}
|
|
176
146
|
optionParam="label"
|
|
177
147
|
valueParam="value"
|
|
178
|
-
onUpdate:modelValue={(v: unknown) =>
|
|
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={
|
|
188
|
-
onUpdate:modelValue={(v: string) =>
|
|
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">{
|
|
247
|
-
<span class="k-et-delete-ask"
|
|
248
|
-
<KButton type="danger" size="small" text="
|
|
249
|
-
<KButton plain size="small" text="
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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>() =>
|
|
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
|
|