uni-oaview 1.5.2 → 1.7.0

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.
@@ -1,7 +1,14 @@
1
1
  <template>
2
- <uni-collapse ref="collapseRef" v-model="activeNames">
2
+ <view class="toolbar">
3
+ <view class="toolbar-input-wrap">
4
+ <input v-model="keyword" class="toolbar-input" placeholder="搜索实例ID / 音源 / 操作" confirm-type="search" />
5
+ <view v-if="keyword" class="toolbar-input-clear" @click="handleClearKeyword">×</view>
6
+ </view>
7
+ <view class="toolbar-clear" @click="handleClear">清除</view>
8
+ </view>
9
+ <uni-collapse v-if="filteredAudioLogMetas.length" ref="collapseRef" v-model="activeNames">
3
10
  <uni-collapse-item
4
- v-for="meta in audioLogMetas"
11
+ v-for="meta in filteredAudioLogMetas"
5
12
  :key="meta.id"
6
13
  :name="meta.id"
7
14
  :title="meta.title"
@@ -39,10 +46,13 @@
39
46
  </view>
40
47
  </uni-collapse-item>
41
48
  </uni-collapse>
49
+ <view v-else class="empty-state">
50
+ {{ emptyText }}
51
+ </view>
42
52
  </template>
43
53
 
44
54
  <script lang="ts" setup>
45
- import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
55
+ import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
46
56
  import awesomeDisplayInfo from './awesome-display-info.vue';
47
57
  import { audioSubject, getAudioLogs } from 'uniapp-log-sdk';
48
58
 
@@ -73,6 +83,9 @@
73
83
 
74
84
  const collapseRef = ref<{ resize?: () => void } | null>(null);
75
85
  const activeNames = ref<string[] | string>([]);
86
+ const keyword = ref('');
87
+ const clearTimestamp = ref<number | null>(null);
88
+ const hasCleared = ref(false);
76
89
 
77
90
  const audioLogDetailMap = shallowRef<Map<string, AudioLog>>(new Map());
78
91
  const audioLogMetas = ref<AudioLogMeta[]>([]);
@@ -142,8 +155,68 @@
142
155
  return audioLogDetailMap.value.get(id) ?? null;
143
156
  };
144
157
 
158
+ const safeStringify = (value: unknown): string => {
159
+ if (value === null || value === undefined) return '';
160
+ if (typeof value === 'string') return value;
161
+ try {
162
+ return JSON.stringify(value);
163
+ } catch (error) {
164
+ return String(value);
165
+ }
166
+ };
167
+
168
+ const normalizeKeyword = (value: string): string => value.trim().toLowerCase();
169
+
170
+ const matchesAudioLog = (meta: AudioLogMeta): boolean => {
171
+ const normalizedKeyword = normalizeKeyword(keyword.value);
172
+ if (!normalizedKeyword) return true;
173
+ const detail = getAudioLogDetail(meta.id);
174
+ return [meta.title, meta.instanceId, meta.src, meta.action, safeStringify(detail?.data)]
175
+ .join(' ')
176
+ .toLowerCase()
177
+ .includes(normalizedKeyword);
178
+ };
179
+
180
+ const filteredAudioLogMetas = computed(() => audioLogMetas.value.filter(matchesAudioLog));
181
+
182
+ const emptyText = computed(() => {
183
+ if (keyword.value) return '无匹配结果';
184
+ if (hasCleared.value) return '已清空';
185
+ return '暂无日志';
186
+ });
187
+
188
+ const handleClear = () => {
189
+ clearTimestamp.value = Date.now();
190
+ hasCleared.value = true;
191
+ audioLogMetas.value = [];
192
+ audioLogDetailMap.value = new Map();
193
+ activeNames.value = [];
194
+ };
195
+
196
+ const handleClearKeyword = () => {
197
+ keyword.value = '';
198
+ };
199
+
145
200
  const upsertLogs = (logs: AudioLog[]) => {
146
- const nextLogs = logs.slice(-MAX_AUDIO_LOGS);
201
+ const visibleLogs = clearTimestamp.value ? logs.filter((log) => log.timestamp > clearTimestamp.value!) : logs;
202
+ if (
203
+ !visibleLogs.length &&
204
+ clearTimestamp.value &&
205
+ logs.length &&
206
+ logs[logs.length - 1].timestamp < clearTimestamp.value
207
+ ) {
208
+ clearTimestamp.value = null;
209
+ hasCleared.value = false;
210
+ }
211
+
212
+ if (!visibleLogs.length && logs.length && clearTimestamp.value) {
213
+ audioLogDetailMap.value = new Map();
214
+ audioLogMetas.value = [];
215
+ activeNames.value = [];
216
+ return;
217
+ }
218
+
219
+ const nextLogs = visibleLogs.slice(-MAX_AUDIO_LOGS);
147
220
  const detailMap = audioLogDetailMap.value;
148
221
 
149
222
  const nextMetas: AudioLogMeta[] = [];
@@ -160,14 +233,6 @@
160
233
  for (const key of detailMap.keys()) {
161
234
  if (!keepIds.has(key)) detailMap.delete(key);
162
235
  }
163
-
164
- // 清理 activeNames(如果展开项已被裁剪掉)
165
- const currentActive = activeNames.value;
166
- if (Array.isArray(currentActive)) {
167
- if (currentActive.length) activeNames.value = currentActive.filter((id) => keepIds.has(id));
168
- } else if (currentActive && !keepIds.has(currentActive)) {
169
- activeNames.value = '';
170
- }
171
236
  };
172
237
 
173
238
  upsertLogs(getAudioLogs());
@@ -197,12 +262,85 @@
197
262
  { deep: false },
198
263
  );
199
264
 
265
+ watch(keyword, () => {
266
+ const keepIds = new Set(filteredAudioLogMetas.value.map((meta) => meta.id));
267
+ const currentActive = activeNames.value;
268
+ if (Array.isArray(currentActive)) {
269
+ activeNames.value = currentActive.filter((id) => keepIds.has(id));
270
+ } else if (currentActive && !keepIds.has(currentActive)) {
271
+ activeNames.value = '';
272
+ }
273
+
274
+ // #ifdef MP
275
+ if (getActiveNameList().length) nextTick(() => collapseRef.value?.resize?.());
276
+ // #endif
277
+ });
278
+
200
279
  onBeforeUnmount(() => {
201
280
  stop?.unsubscribe();
202
281
  });
203
282
  </script>
204
283
 
205
284
  <style lang="scss" scoped>
285
+ .toolbar {
286
+ position: sticky;
287
+ top: 0;
288
+ z-index: 1;
289
+ display: flex;
290
+ align-items: center;
291
+ gap: 8px;
292
+ margin-bottom: 8px;
293
+ padding-bottom: 8px;
294
+ background-color: #fff;
295
+ }
296
+
297
+ .toolbar-input {
298
+ width: 100%;
299
+ height: 32px;
300
+ padding: 0 28px 0 10px;
301
+ font-size: 12px;
302
+ border: 1px solid #dcdfe6;
303
+ border-radius: 4px;
304
+ background-color: #fff;
305
+ box-sizing: border-box;
306
+ }
307
+
308
+ .toolbar-input-wrap {
309
+ position: relative;
310
+ flex: 1;
311
+ min-width: 0;
312
+ }
313
+
314
+ .toolbar-input-clear {
315
+ position: absolute;
316
+ top: 50%;
317
+ right: 8px;
318
+ transform: translateY(-50%);
319
+ width: 16px;
320
+ height: 16px;
321
+ line-height: 16px;
322
+ font-size: 12px;
323
+ color: #999;
324
+ text-align: center;
325
+ border-radius: 50%;
326
+ }
327
+
328
+ .toolbar-clear {
329
+ flex-shrink: 0;
330
+ padding: 6px 10px;
331
+ font-size: 12px;
332
+ color: #119af5;
333
+ border: 1px solid #119af5;
334
+ border-radius: 4px;
335
+ }
336
+
337
+ .empty-state {
338
+ padding: 16px 0;
339
+ font-size: 12px;
340
+ color: #999;
341
+ text-align: center;
342
+ }
343
+
206
344
  .uni-collapse-content {
207
345
  font-size: 12px;
208
346
  }
@@ -1,18 +1,35 @@
1
1
  <template>
2
- <view
3
- class="log-item"
4
- v-for="log in consoleLogs"
5
- style="font-size: 10px; width: calc(100vw - 16px); word-break: break-all"
6
- >
7
- {{ log }}
2
+ <view class="toolbar">
3
+ <view class="toolbar-input-wrap">
4
+ <input v-model="keyword" class="toolbar-input" placeholder="搜索日志内容" confirm-type="search" />
5
+ <view v-if="keyword" class="toolbar-input-clear" @click="handleClearKeyword">×</view>
6
+ </view>
7
+ <view class="toolbar-clear" @click="handleClear">清除</view>
8
+ </view>
9
+ <template v-if="filteredConsoleLogs.length">
10
+ <view
11
+ v-for="(log, index) in filteredConsoleLogs"
12
+ :key="`${index}-${log}`"
13
+ class="log-item"
14
+ style="font-size: 10px; width: calc(100vw - 16px); word-break: break-all"
15
+ >
16
+ {{ log }}
17
+ </view>
18
+ </template>
19
+ <view v-else class="empty-state">
20
+ {{ emptyText }}
8
21
  </view>
9
22
  </template>
10
23
  <script lang="ts" setup>
11
- import { onBeforeUnmount, ref } from 'vue';
24
+ import { computed, onBeforeUnmount, ref } from 'vue';
12
25
  import { consoleSubject, getConsoleLogs } from 'uniapp-log-sdk';
13
26
 
14
27
  const MAX_CONSOLE_LOGS = 500;
15
28
 
29
+ const keyword = ref('');
30
+ const hasCleared = ref(false);
31
+ const clearedLogCount = ref(0);
32
+
16
33
  const logConvert = (items: any[]): string =>
17
34
  items.reduce((prev, current, index) => {
18
35
  try {
@@ -26,11 +43,44 @@
26
43
  return prev;
27
44
  }, '');
28
45
 
29
- const consoleLogs = ref<string[]>(getConsoleLogs().slice(-MAX_CONSOLE_LOGS).map(logConvert));
46
+ const normalizeKeyword = (value: string): string => value.trim().toLowerCase();
47
+
48
+ const buildVisibleLogs = (logs: any[]): string[] => {
49
+ const nextLogs = clearedLogCount.value > 0 ? logs.slice(clearedLogCount.value) : logs;
50
+ return nextLogs.slice(-MAX_CONSOLE_LOGS).map(logConvert);
51
+ };
52
+
53
+ const initialLogs = getConsoleLogs();
54
+ const consoleLogs = ref<string[]>(buildVisibleLogs(initialLogs));
55
+
56
+ const filteredConsoleLogs = computed(() => {
57
+ const normalizedKeyword = normalizeKeyword(keyword.value);
58
+ if (!normalizedKeyword) return consoleLogs.value;
59
+ return consoleLogs.value.filter((log) => log.toLowerCase().includes(normalizedKeyword));
60
+ });
61
+
62
+ const emptyText = computed(() => {
63
+ if (keyword.value) return '无匹配结果';
64
+ if (hasCleared.value) return '已清空';
65
+ return '暂无日志';
66
+ });
67
+
68
+ const handleClear = () => {
69
+ hasCleared.value = true;
70
+ clearedLogCount.value = getConsoleLogs().length;
71
+ consoleLogs.value = [];
72
+ };
73
+
74
+ const handleClearKeyword = () => {
75
+ keyword.value = '';
76
+ };
30
77
 
31
78
  const stop = consoleSubject.subscribe((data: any) => {
32
- const converted = data.slice(-MAX_CONSOLE_LOGS).map(logConvert);
33
- consoleLogs.value = converted;
79
+ if (data.length < clearedLogCount.value) {
80
+ clearedLogCount.value = 0;
81
+ hasCleared.value = false;
82
+ }
83
+ consoleLogs.value = buildVisibleLogs(data);
34
84
  });
35
85
 
36
86
  onBeforeUnmount(() => {
@@ -39,7 +89,66 @@
39
89
  </script>
40
90
 
41
91
  <style lang="scss" scoped>
92
+ .toolbar {
93
+ position: sticky;
94
+ top: 0;
95
+ z-index: 1;
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 8px;
99
+ margin-bottom: 8px;
100
+ padding-bottom: 8px;
101
+ background-color: #fff;
102
+ }
103
+
104
+ .toolbar-input {
105
+ width: 100%;
106
+ height: 32px;
107
+ padding: 0 28px 0 10px;
108
+ font-size: 12px;
109
+ border: 1px solid #dcdfe6;
110
+ border-radius: 4px;
111
+ background-color: #fff;
112
+ box-sizing: border-box;
113
+ }
114
+
115
+ .toolbar-input-wrap {
116
+ position: relative;
117
+ flex: 1;
118
+ min-width: 0;
119
+ }
120
+
121
+ .toolbar-input-clear {
122
+ position: absolute;
123
+ top: 50%;
124
+ right: 8px;
125
+ transform: translateY(-50%);
126
+ width: 16px;
127
+ height: 16px;
128
+ line-height: 16px;
129
+ font-size: 12px;
130
+ color: #999;
131
+ text-align: center;
132
+ border-radius: 50%;
133
+ }
134
+
135
+ .toolbar-clear {
136
+ flex-shrink: 0;
137
+ padding: 6px 10px;
138
+ font-size: 12px;
139
+ color: #119af5;
140
+ border: 1px solid #119af5;
141
+ border-radius: 4px;
142
+ }
143
+
42
144
  .log-item:nth-of-type(odd) {
43
145
  background-color: #eee;
44
146
  }
147
+
148
+ .empty-state {
149
+ padding: 16px 0;
150
+ font-size: 12px;
151
+ color: #999;
152
+ text-align: center;
153
+ }
45
154
  </style>
@@ -1,7 +1,14 @@
1
1
  <template>
2
- <uni-collapse ref="collapseRef" v-model="activeNames">
2
+ <view class="toolbar">
3
+ <view class="toolbar-input-wrap">
4
+ <input v-model="keyword" class="toolbar-input" placeholder="搜索 URL / 方法 / 状态码" confirm-type="search" />
5
+ <view v-if="keyword" class="toolbar-input-clear" @click="handleClearKeyword">×</view>
6
+ </view>
7
+ <view class="toolbar-clear" @click="handleClear">清除</view>
8
+ </view>
9
+ <uni-collapse v-if="filteredNetworkLogMetas.length" ref="collapseRef" v-model="activeNames">
3
10
  <uni-collapse-item
4
- v-for="meta in networkLogMetas"
11
+ v-for="meta in filteredNetworkLogMetas"
5
12
  :key="meta.id"
6
13
  :name="meta.id"
7
14
  :title="meta.title"
@@ -32,10 +39,13 @@
32
39
  </view>
33
40
  </uni-collapse-item>
34
41
  </uni-collapse>
42
+ <view v-else class="empty-state">
43
+ {{ emptyText }}
44
+ </view>
35
45
  </template>
36
46
 
37
47
  <script lang="ts" setup>
38
- import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
48
+ import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
39
49
  import awesomeDisplayInfo from './awesome-display-info.vue';
40
50
  import { networkSubject, getNetworkLogs } from 'uniapp-log-sdk';
41
51
 
@@ -58,6 +68,7 @@
58
68
  }
59
69
 
60
70
  interface NetworkLog {
71
+ type: 'request' | 'uploadFile' | 'downloadFile';
61
72
  request: NetworkLogRequest;
62
73
  response?: NetworkLogResponse;
63
74
  startTime: number;
@@ -78,6 +89,9 @@
78
89
  const collapseRef = ref<{ resize?: () => void } | null>(null);
79
90
  // uni-collapse 的 v-model 在不同模式下可能是 string 或 string[]
80
91
  const activeNames = ref<string[] | string>([]);
92
+ const keyword = ref('');
93
+ const clearTimestamp = ref<number | null>(null);
94
+ const hasCleared = ref(false);
81
95
 
82
96
  const networkLogDetailMap = shallowRef<Map<string, NetworkLog>>(new Map());
83
97
  const networkLogMetas = ref<NetworkLogMeta[]>([]);
@@ -132,8 +146,85 @@
132
146
  return networkLogDetailMap.value.get(id) ?? null;
133
147
  };
134
148
 
135
- const upsertLogs = (logs: NetworkLog[]) => {
136
- const nextLogs = logs.slice(-MAX_NETWORK_LOGS);
149
+ const normalizeKeyword = (value: string): string => value.trim().toLowerCase();
150
+
151
+ const safeStringify = (value: unknown): string => {
152
+ if (value === null || value === undefined) return '';
153
+ if (typeof value === 'string') return value;
154
+ try {
155
+ return JSON.stringify(value);
156
+ } catch (error) {
157
+ return String(value);
158
+ }
159
+ };
160
+
161
+ const matchesNetworkLog = (meta: NetworkLogMeta): boolean => {
162
+ const normalizedKeyword = normalizeKeyword(keyword.value);
163
+ if (!normalizedKeyword) return true;
164
+ const detail = getNetworkLogDetail(meta.id);
165
+ return [
166
+ meta.title,
167
+ meta.method,
168
+ meta.statusCode,
169
+ detail?.request?.url,
170
+ safeStringify(detail?.request?.data),
171
+ safeStringify(detail?.response),
172
+ ]
173
+ .join(' ')
174
+ .toLowerCase()
175
+ .includes(normalizedKeyword);
176
+ };
177
+
178
+ const filteredNetworkLogMetas = computed(() => networkLogMetas.value.filter(matchesNetworkLog));
179
+
180
+ const emptyText = computed(() => {
181
+ if (keyword.value) return '无匹配结果';
182
+ if (hasCleared.value) return '已清空';
183
+ return '暂无日志';
184
+ });
185
+
186
+ // 使用类型守卫过滤出 HTTP 日志
187
+ const isHttpLog = (log: any): log is NetworkLog => {
188
+ return log && log.type !== 'websocket';
189
+ };
190
+
191
+ const handleClear = () => {
192
+ clearTimestamp.value = Date.now();
193
+ hasCleared.value = true;
194
+ networkLogMetas.value = [];
195
+ networkLogDetailMap.value = new Map();
196
+ activeNames.value = [];
197
+ };
198
+
199
+ const handleClearKeyword = () => {
200
+ keyword.value = '';
201
+ };
202
+
203
+ const upsertLogs = (logs: any[]): void => {
204
+ // 过滤出非 websocket 的日志
205
+ const httpLogs = logs.filter(isHttpLog);
206
+ const visibleLogs = clearTimestamp.value
207
+ ? httpLogs.filter((log) => log.startTime > clearTimestamp.value!)
208
+ : httpLogs;
209
+
210
+ if (
211
+ !visibleLogs.length &&
212
+ clearTimestamp.value &&
213
+ httpLogs.length &&
214
+ httpLogs[httpLogs.length - 1].startTime < clearTimestamp.value
215
+ ) {
216
+ clearTimestamp.value = null;
217
+ hasCleared.value = false;
218
+ }
219
+
220
+ if (!visibleLogs.length && httpLogs.length && clearTimestamp.value) {
221
+ networkLogDetailMap.value = new Map();
222
+ networkLogMetas.value = [];
223
+ activeNames.value = [];
224
+ return;
225
+ }
226
+
227
+ const nextLogs = visibleLogs.slice(-MAX_NETWORK_LOGS);
137
228
  const detailMap = networkLogDetailMap.value;
138
229
 
139
230
  const nextMetas: NetworkLogMeta[] = [];
@@ -150,19 +241,11 @@
150
241
  for (const key of detailMap.keys()) {
151
242
  if (!keepIds.has(key)) detailMap.delete(key);
152
243
  }
153
-
154
- // 清理 activeNames(如果展开项已被裁剪掉)
155
- const currentActive = activeNames.value;
156
- if (Array.isArray(currentActive)) {
157
- if (currentActive.length) activeNames.value = currentActive.filter((id) => keepIds.has(id));
158
- } else if (currentActive && !keepIds.has(currentActive)) {
159
- activeNames.value = '';
160
- }
161
244
  };
162
245
 
163
246
  upsertLogs(getNetworkLogs());
164
247
 
165
- const stop = networkSubject.subscribe((data: NetworkLog[]) => {
248
+ const stop = networkSubject.subscribe((data: any[]): void => {
166
249
  upsertLogs(data);
167
250
  });
168
251
 
@@ -187,12 +270,85 @@
187
270
  { deep: false },
188
271
  );
189
272
 
273
+ watch(keyword, () => {
274
+ const keepIds = new Set(filteredNetworkLogMetas.value.map((meta) => meta.id));
275
+ const currentActive = activeNames.value;
276
+ if (Array.isArray(currentActive)) {
277
+ activeNames.value = currentActive.filter((id) => keepIds.has(id));
278
+ } else if (currentActive && !keepIds.has(currentActive)) {
279
+ activeNames.value = '';
280
+ }
281
+
282
+ // #ifdef MP
283
+ if (getActiveNameList().length) nextTick(() => collapseRef.value?.resize?.());
284
+ // #endif
285
+ });
286
+
190
287
  onBeforeUnmount(() => {
191
288
  stop?.unsubscribe();
192
289
  });
193
290
  </script>
194
291
 
195
292
  <style lang="scss" scoped>
293
+ .toolbar {
294
+ position: sticky;
295
+ top: 0;
296
+ z-index: 1;
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 8px;
300
+ margin-bottom: 8px;
301
+ padding-bottom: 8px;
302
+ background-color: #fff;
303
+ }
304
+
305
+ .toolbar-input {
306
+ width: 100%;
307
+ height: 32px;
308
+ padding: 0 28px 0 10px;
309
+ font-size: 12px;
310
+ border: 1px solid #dcdfe6;
311
+ border-radius: 4px;
312
+ background-color: #fff;
313
+ box-sizing: border-box;
314
+ }
315
+
316
+ .toolbar-input-wrap {
317
+ position: relative;
318
+ flex: 1;
319
+ min-width: 0;
320
+ }
321
+
322
+ .toolbar-input-clear {
323
+ position: absolute;
324
+ top: 50%;
325
+ right: 8px;
326
+ transform: translateY(-50%);
327
+ width: 16px;
328
+ height: 16px;
329
+ line-height: 16px;
330
+ font-size: 12px;
331
+ color: #999;
332
+ text-align: center;
333
+ border-radius: 50%;
334
+ }
335
+
336
+ .toolbar-clear {
337
+ flex-shrink: 0;
338
+ padding: 6px 10px;
339
+ font-size: 12px;
340
+ color: #119af5;
341
+ border: 1px solid #119af5;
342
+ border-radius: 4px;
343
+ }
344
+
345
+ .empty-state {
346
+ padding: 16px 0;
347
+ font-size: 12px;
348
+ color: #999;
349
+ text-align: center;
350
+ }
351
+
196
352
  .uni-collapse-content {
197
353
  font-size: 12px;
198
354
  }
@@ -37,6 +37,9 @@
37
37
  <Audio />
38
38
  </template>
39
39
  <template v-if="current === 7">
40
+ <Websocket />
41
+ </template>
42
+ <template v-if="current === 8">
40
43
  <DevTools />
41
44
  </template>
42
45
  </view>
@@ -65,10 +68,11 @@
65
68
  import Console from './console.vue';
66
69
  import System from './system.vue';
67
70
  import Audio from './audio.vue';
71
+ import Websocket from './websocket.vue';
68
72
  import DevTools from './dev-tools.vue';
69
73
 
70
74
  const popupRef = ref<any>();
71
- const list = ref(['console', 'network', 'event', 'error', 'storage', 'system', 'audio', 'dev-tools']);
75
+ const list = ref(['console', 'network', 'event', 'error', 'storage', 'system', 'audio', 'websocket', 'dev-tools']);
72
76
  const current = ref(0);
73
77
 
74
78
  const handleTabClick = (index: number) => {
@@ -0,0 +1,424 @@
1
+ <template>
2
+ <view class="toolbar">
3
+ <view class="toolbar-input-wrap">
4
+ <input v-model="keyword" class="toolbar-input" placeholder="搜索 URL / 状态 / 协议" confirm-type="search" />
5
+ <view v-if="keyword" class="toolbar-input-clear" @click="handleClearKeyword">×</view>
6
+ </view>
7
+ <view class="toolbar-clear" @click="handleClear">清除</view>
8
+ </view>
9
+ <uni-collapse v-if="filteredWebsocketLogMetas.length" ref="collapseRef" v-model="activeNames">
10
+ <uni-collapse-item
11
+ v-for="meta in filteredWebsocketLogMetas"
12
+ :key="meta.id"
13
+ :name="meta.id"
14
+ :title="meta.title"
15
+ :class="{
16
+ 'uni-collapse-item-error': meta.isError,
17
+ 'uni-collapse-item-connecting': meta.isConnecting,
18
+ }"
19
+ >
20
+ <view style="font-size: 10px; padding: 10px 15px">
21
+ <view style="word-break: break-all; padding: 8px 0; margin-bottom: 10px">
22
+ <text :selectable="true" style="font-weight: bolder">URL:</text>
23
+ {{ meta.url }}
24
+ </view>
25
+ <view style="word-break: break-all; padding: 8px 0; margin-bottom: 10px">
26
+ <text :selectable="true" style="font-weight: bolder">状态:</text>
27
+ {{ meta.status }}
28
+ </view>
29
+ <view style="word-break: break-all; padding: 8px 0; margin-bottom: 10px">
30
+ <text :selectable="true" style="font-weight: bolder">消息统计:</text>
31
+ 发送 {{ meta.sendCount }},接收 {{ meta.receiveCount }},共 {{ meta.messageTotal }} 条
32
+ </view>
33
+ <template v-if="isExpanded(meta.id)">
34
+ <view v-if="meta.protocols" style="word-break: break-all; padding: 8px 0; margin-bottom: 10px">
35
+ <text :selectable="true" style="font-weight: bolder">协议:</text>
36
+ {{ meta.protocols }}
37
+ </view>
38
+ <view
39
+ v-if="getWebsocketLogDetail(meta.id)?.request?.header"
40
+ style="word-break: break-all; padding: 8px 0; margin-bottom: 10px"
41
+ >
42
+ <text :selectable="true" style="font-weight: bolder">请求头:</text>
43
+ <awesome-display-info :log="getWebsocketLogDetail(meta.id)?.request?.header" />
44
+ </view>
45
+ <view
46
+ v-if="meta.isError && getWebsocketLogDetail(meta.id)?.errorInfo"
47
+ style="word-break: break-all; padding: 8px 0; margin-bottom: 10px"
48
+ >
49
+ <text :selectable="true" style="font-weight: bolder; color: red">错误信息:</text>
50
+ <awesome-display-info :log="getWebsocketLogDetail(meta.id)?.errorInfo" />
51
+ </view>
52
+ <view
53
+ v-if="getMessageList(meta.id).length > 0"
54
+ style="word-break: break-all; padding: 8px 0; margin-bottom: 10px"
55
+ >
56
+ <text :selectable="true" style="font-weight: bolder">
57
+ 消息列表(共 {{ meta.messageTotal }} 条,显示最近 100 条):
58
+ </text>
59
+ <view
60
+ v-for="(msg, index) in getMessageList(meta.id)"
61
+ :key="index"
62
+ style="padding: 4px 0; font-size: 9px; color: #666"
63
+ >
64
+ <text :selectable="true">
65
+ [{{ formatTime(msg.time) }}] [{{ msg.type === 'send' ? '发送' : '接收' }}] {{ msg.size }}B
66
+ </text>
67
+ <view
68
+ v-if="msg.dataPreview"
69
+ style="margin-top: 2px; padding: 4px; background-color: #f5f5f5; border-radius: 2px"
70
+ >
71
+ {{ msg.dataPreview }}
72
+ </view>
73
+ </view>
74
+ </view>
75
+ </template>
76
+ </view>
77
+ </uni-collapse-item>
78
+ </uni-collapse>
79
+ <view v-else class="empty-state">
80
+ {{ emptyText }}
81
+ </view>
82
+ </template>
83
+
84
+ <script lang="ts" setup>
85
+ import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
86
+ import awesomeDisplayInfo from './awesome-display-info.vue';
87
+ import { websocketSubject, getWebsocketLogs } from 'uniapp-log-sdk';
88
+
89
+ interface MessageRecord {
90
+ type: 'send' | 'receive';
91
+ data: string | ArrayBuffer;
92
+ time: number;
93
+ size: number;
94
+ }
95
+
96
+ interface WebsocketLog {
97
+ type: 'websocket';
98
+ connectionId: string;
99
+ request: {
100
+ url: string;
101
+ header?: unknown;
102
+ protocols?: string[];
103
+ };
104
+ response?: unknown;
105
+ messages: MessageRecord[];
106
+ stats: {
107
+ messageTotal: number;
108
+ messageSendCount: number;
109
+ messageReceiveCount: number;
110
+ droppedMessageCount: number;
111
+ };
112
+ startTime: number;
113
+ endTime: number;
114
+ openTime: number;
115
+ status: 'connecting' | 'open' | 'closed' | 'error';
116
+ errorInfo?: unknown;
117
+ }
118
+
119
+ interface WebsocketLogMeta {
120
+ id: string;
121
+ title: string;
122
+ url: string;
123
+ status: string;
124
+ messageTotal: number;
125
+ sendCount: number;
126
+ receiveCount: number;
127
+ protocols?: string;
128
+ duration: string;
129
+ isError: boolean;
130
+ isConnecting: boolean;
131
+ }
132
+
133
+ const MAX_WEBSOCKET_LOGS = 50;
134
+ const MESSAGE_PREVIEW_LENGTH = 100;
135
+
136
+ const collapseRef = ref<{ resize?: () => void } | null>(null);
137
+ const activeNames = ref<string[] | string>([]);
138
+ const keyword = ref('');
139
+ const clearTimestamp = ref<number | null>(null);
140
+ const hasCleared = ref(false);
141
+
142
+ const websocketLogDetailMap = shallowRef<Map<string, WebsocketLog>>(new Map());
143
+ const websocketLogMetas = ref<WebsocketLogMeta[]>([]);
144
+
145
+ const formatTime = (timestamp: number): string => {
146
+ const date = new Date(timestamp);
147
+ const hours = String(date.getHours()).padStart(2, '0');
148
+ const minutes = String(date.getMinutes()).padStart(2, '0');
149
+ const seconds = String(date.getSeconds()).padStart(2, '0');
150
+ const milliseconds = String(date.getMilliseconds()).padStart(3, '0');
151
+ return `${hours}:${minutes}:${seconds}.${milliseconds}`;
152
+ };
153
+
154
+ const buildWebsocketLogId = (log: WebsocketLog): string => {
155
+ // 使用 connectionId 作为唯一标识,避免同 URL 多连接的混淆
156
+ return log.connectionId;
157
+ };
158
+
159
+ const getMessagePreview = (data: string | ArrayBuffer): string => {
160
+ if (typeof data === 'string') {
161
+ return data.length > MESSAGE_PREVIEW_LENGTH ? data.substring(0, MESSAGE_PREVIEW_LENGTH) + '...' : data;
162
+ }
163
+ return `[ArrayBuffer(${data.byteLength})]`;
164
+ };
165
+
166
+ const buildMetaFromLog = (log: WebsocketLog): WebsocketLogMeta => {
167
+ const { startTime, endTime, request, status, stats, errorInfo } = log;
168
+ const timeStr = formatTime(startTime);
169
+ const duration = endTime && startTime ? `${((endTime - startTime) / 1000).toFixed(2)}s` : 'pending';
170
+ const isError = status === 'error' || (errorInfo !== null && errorInfo !== undefined);
171
+ const isConnecting = status === 'connecting';
172
+ const protocolStr = request?.protocols?.join(',') || '';
173
+
174
+ return {
175
+ id: buildWebsocketLogId(log),
176
+ title: `[${timeStr}] ${request?.url};${duration};${status}`,
177
+ url: request?.url ?? '',
178
+ status,
179
+ messageTotal: stats.messageTotal,
180
+ sendCount: stats.messageSendCount,
181
+ receiveCount: stats.messageReceiveCount,
182
+ protocols: protocolStr,
183
+ duration,
184
+ isError,
185
+ isConnecting,
186
+ };
187
+ };
188
+
189
+ const getActiveNameList = (): string[] => {
190
+ const value = activeNames.value;
191
+ return Array.isArray(value) ? value : value ? [value] : [];
192
+ };
193
+
194
+ const isExpanded = (id: string): boolean => getActiveNameList().includes(id);
195
+
196
+ const getWebsocketLogDetail = (id: string): WebsocketLog | null => {
197
+ return websocketLogDetailMap.value.get(id) ?? null;
198
+ };
199
+
200
+ const safeStringify = (value: unknown): string => {
201
+ if (value === null || value === undefined) return '';
202
+ if (typeof value === 'string') return value;
203
+ try {
204
+ return JSON.stringify(value);
205
+ } catch (error) {
206
+ return String(value);
207
+ }
208
+ };
209
+
210
+ const normalizeKeyword = (value: string): string => value.trim().toLowerCase();
211
+
212
+ const matchesWebsocketLog = (meta: WebsocketLogMeta): boolean => {
213
+ const normalizedKeyword = normalizeKeyword(keyword.value);
214
+ if (!normalizedKeyword) return true;
215
+ const detail = getWebsocketLogDetail(meta.id);
216
+ return [meta.title, meta.url, meta.status, meta.protocols, safeStringify(detail?.errorInfo)]
217
+ .join(' ')
218
+ .toLowerCase()
219
+ .includes(normalizedKeyword);
220
+ };
221
+
222
+ const filteredWebsocketLogMetas = computed(() => websocketLogMetas.value.filter(matchesWebsocketLog));
223
+
224
+ const emptyText = computed(() => {
225
+ if (keyword.value) return '无匹配结果';
226
+ if (hasCleared.value) return '已清空';
227
+ return '暂无日志';
228
+ });
229
+
230
+ const handleClear = () => {
231
+ clearTimestamp.value = Date.now();
232
+ hasCleared.value = true;
233
+ websocketLogMetas.value = [];
234
+ websocketLogDetailMap.value = new Map();
235
+ activeNames.value = [];
236
+ };
237
+
238
+ const handleClearKeyword = () => {
239
+ keyword.value = '';
240
+ };
241
+
242
+ const buildVisibleWebsocketLog = (log: WebsocketLog): WebsocketLog | null => {
243
+ if (!clearTimestamp.value) return log;
244
+
245
+ const hasConnectionLifecycleAfterClear =
246
+ log.startTime > clearTimestamp.value || log.openTime > clearTimestamp.value;
247
+ const hasNewError = Boolean(log.errorInfo) && (!log.endTime || log.endTime > clearTimestamp.value);
248
+
249
+ if (!hasConnectionLifecycleAfterClear && !hasNewError) {
250
+ return null;
251
+ }
252
+
253
+ return log;
254
+ };
255
+
256
+ const getMessageList = (id: string): Array<MessageRecord & { dataPreview: string }> => {
257
+ const log = getWebsocketLogDetail(id);
258
+ if (!log) return [];
259
+ return log.messages.map((msg) => ({
260
+ ...msg,
261
+ dataPreview: getMessagePreview(msg.data),
262
+ }));
263
+ };
264
+
265
+ const upsertLogs = (logs: WebsocketLog[]) => {
266
+ const visibleLogs = logs.map(buildVisibleWebsocketLog).filter((log): log is WebsocketLog => Boolean(log));
267
+
268
+ if (!visibleLogs.length && clearTimestamp.value && logs.length) {
269
+ websocketLogDetailMap.value = new Map();
270
+ websocketLogMetas.value = [];
271
+ activeNames.value = [];
272
+ return;
273
+ }
274
+
275
+ const nextLogs = visibleLogs.slice(-MAX_WEBSOCKET_LOGS);
276
+ const detailMap = websocketLogDetailMap.value;
277
+
278
+ const nextMetas: WebsocketLogMeta[] = [];
279
+ for (const log of nextLogs) {
280
+ const id = buildWebsocketLogId(log);
281
+ detailMap.set(id, log);
282
+ nextMetas.push(buildMetaFromLog(log));
283
+ }
284
+
285
+ websocketLogMetas.value = nextMetas;
286
+
287
+ // 清理 detailMap,避免无限增长
288
+ const keepIds = new Set(nextMetas.map((m) => m.id));
289
+ for (const key of detailMap.keys()) {
290
+ if (!keepIds.has(key)) detailMap.delete(key);
291
+ }
292
+ };
293
+
294
+ upsertLogs(getWebsocketLogs());
295
+
296
+ const stop = websocketSubject.subscribe((data: WebsocketLog[]) => {
297
+ upsertLogs(data);
298
+ });
299
+
300
+ // 展开项变化时刷新 collapse 高度缓存(小程序端必须手动调用,否则内容高度可能为 0)
301
+ watch(
302
+ () => getActiveNameList().join('|'),
303
+ () => {
304
+ // #ifdef MP
305
+ nextTick(() => collapseRef.value?.resize?.());
306
+ // #endif
307
+ },
308
+ );
309
+
310
+ // 列表更新时,如果有已展开项(内容高度可能变化),同步刷新高度缓存
311
+ watch(
312
+ () => websocketLogMetas.value,
313
+ () => {
314
+ // #ifdef MP
315
+ if (getActiveNameList().length) nextTick(() => collapseRef.value?.resize?.());
316
+ // #endif
317
+ },
318
+ { deep: false },
319
+ );
320
+
321
+ watch(keyword, () => {
322
+ const keepIds = new Set(filteredWebsocketLogMetas.value.map((meta) => meta.id));
323
+ const currentActive = activeNames.value;
324
+ if (Array.isArray(currentActive)) {
325
+ activeNames.value = currentActive.filter((id) => keepIds.has(id));
326
+ } else if (currentActive && !keepIds.has(currentActive)) {
327
+ activeNames.value = '';
328
+ }
329
+
330
+ // #ifdef MP
331
+ if (getActiveNameList().length) nextTick(() => collapseRef.value?.resize?.());
332
+ // #endif
333
+ });
334
+
335
+ onBeforeUnmount(() => {
336
+ stop?.unsubscribe();
337
+ });
338
+ </script>
339
+
340
+ <style lang="scss" scoped>
341
+ .toolbar {
342
+ position: sticky;
343
+ top: 0;
344
+ z-index: 1;
345
+ display: flex;
346
+ align-items: center;
347
+ gap: 8px;
348
+ margin-bottom: 8px;
349
+ padding-bottom: 8px;
350
+ background-color: #fff;
351
+ }
352
+
353
+ .toolbar-input {
354
+ width: 100%;
355
+ height: 32px;
356
+ padding: 0 28px 0 10px;
357
+ font-size: 12px;
358
+ border: 1px solid #dcdfe6;
359
+ border-radius: 4px;
360
+ background-color: #fff;
361
+ box-sizing: border-box;
362
+ }
363
+
364
+ .toolbar-input-wrap {
365
+ position: relative;
366
+ flex: 1;
367
+ min-width: 0;
368
+ }
369
+
370
+ .toolbar-input-clear {
371
+ position: absolute;
372
+ top: 50%;
373
+ right: 8px;
374
+ transform: translateY(-50%);
375
+ width: 16px;
376
+ height: 16px;
377
+ line-height: 16px;
378
+ font-size: 12px;
379
+ color: #999;
380
+ text-align: center;
381
+ border-radius: 50%;
382
+ }
383
+
384
+ .toolbar-clear {
385
+ flex-shrink: 0;
386
+ padding: 6px 10px;
387
+ font-size: 12px;
388
+ color: #119af5;
389
+ border: 1px solid #119af5;
390
+ border-radius: 4px;
391
+ }
392
+
393
+ .empty-state {
394
+ padding: 16px 0;
395
+ font-size: 12px;
396
+ color: #999;
397
+ text-align: center;
398
+ }
399
+
400
+ .uni-collapse-content {
401
+ font-size: 12px;
402
+ }
403
+ ::v-deep {
404
+ .uni-collapse-item__title-text {
405
+ white-space: normal !important;
406
+ overflow: visible !important;
407
+ text-overflow: clip !important;
408
+ line-height: normal !important;
409
+ word-break: break-all !important;
410
+ font-size: 10px;
411
+ font-weight: normal;
412
+ }
413
+ .uni-collapse-item-error {
414
+ .uni-collapse-item__title-text {
415
+ color: red !important;
416
+ }
417
+ }
418
+ .uni-collapse-item-connecting {
419
+ .uni-collapse-item__title-text {
420
+ color: #f90 !important;
421
+ }
422
+ }
423
+ }
424
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uni-oaview",
3
- "version": "1.5.02",
3
+ "version": "1.7.00",
4
4
  "description": "uniapp小程序组件库",
5
5
  "main": "dist/index.esm.js",
6
6
  "typings": "dist/index.d.ts",
@@ -51,13 +51,13 @@
51
51
  "rollup-plugin-typescript2": "^0.27.2",
52
52
  "rollup-plugin-vue": "^6.0.0",
53
53
  "typescript": "^4.0.2",
54
- "uniapp-log-sdk": "^1.7.2"
54
+ "uniapp-log-sdk": "^1.8.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@dcloudio/uni-ui": "^1.5.11",
58
58
  "@vueuse/core": "^9.12.0",
59
59
  "dayjs": "1.11.7",
60
- "uniapp-log-sdk": "^1.7.2"
60
+ "uniapp-log-sdk": "^1.8.0"
61
61
  },
62
62
  "dependencies": {
63
63
  "element-china-area-data": "5.0.2"