Skip to content

Commit 423b324

Browse files
committed
feat: add export functionality for device information with multiple formats and UI modal
1 parent 83e1f10 commit 423b324

4 files changed

Lines changed: 439 additions & 3 deletions

File tree

locales/en.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ export const en: I18nKeys = {
113113
mtuSuccess: 'Negotiated to',
114114
mtuFailed: 'Negotiation failed',
115115
rssiChart: 'RSSI Signal History',
116+
export: {
117+
btn: 'Export',
118+
title: 'Export Device Info',
119+
notesLabel: 'Debug Notes (optional)',
120+
notesPlaceholder: 'Record debug notes, issues, test conclusions...',
121+
fmtTxt: 'Text TXT',
122+
fmtMd: 'Markdown',
123+
fmtCsv: 'Spreadsheet CSV',
124+
confirm: 'Export',
125+
loadingChars: 'Loading all characteristics...',
126+
success: 'Export Successful',
127+
failed: 'Export Failed',
128+
savePath: 'Saved to:\n',
129+
shareTitle: 'Share Report',
130+
},
116131
},
117132

118133
debug: {

locales/zh.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,21 @@ export const zh = {
111111
mtuSuccess: '已协商为',
112112
mtuFailed: '协商失败',
113113
rssiChart: 'RSSI 信号历史',
114+
export: {
115+
btn: '导出',
116+
title: '导出设备信息',
117+
notesLabel: '调试备注(可选)',
118+
notesPlaceholder: '记录调试要点、问题描述、测试结论...',
119+
fmtTxt: '文本 TXT',
120+
fmtMd: 'Markdown',
121+
fmtCsv: '表格 CSV',
122+
confirm: '导出',
123+
loadingChars: '正在加载所有特征值...',
124+
success: '导出成功',
125+
failed: '导出失败',
126+
savePath: '保存路径:\n',
127+
shareTitle: '分享报告',
128+
},
114129
},
115130

116131
debug: {

pages/device/index.vue

Lines changed: 245 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,14 @@
8787
<view v-else class="services-section">
8888
<view class="section-hd">
8989
<text class="section-title">{{ t('device.servicesTitle') }}</text>
90-
<view class="refresh-btn" @click="reloadServices">
91-
<text class="refresh-icon">↻</text>
90+
<view class="section-hd-actions">
91+
<view class="export-btn" @click="showExportModal = true">
92+
<text class="export-btn-icon">⬆</text>
93+
<text class="export-btn-label">{{ t('device.export.btn') }}</text>
94+
</view>
95+
<view class="refresh-btn" @click="reloadServices">
96+
<text class="refresh-icon">↻</text>
97+
</view>
9298
</view>
9399
</view>
94100

@@ -173,16 +179,67 @@
173179
<!-- 设置面板 -->
174180
<SettingsPanel :visible="showSettings" @close="showSettings = false" />
175181

182+
<!-- 导出设备信息弹窗 -->
183+
<view v-if="showExportModal" class="dev-modal-overlay" @click="showExportModal = false">
184+
<view class="dev-modal-card" @click.stop>
185+
<text class="dev-modal-title">{{ t('device.export.title') }}</text>
186+
187+
<!-- 格式选择 -->
188+
<view class="fmt-tabs">
189+
<view
190+
v-for="fmt in exportFormats"
191+
:key="fmt.value"
192+
class="fmt-tab"
193+
:class="{ 'fmt-tab--active': exportFormat === fmt.value }"
194+
@click="exportFormat = fmt.value"
195+
>
196+
<text class="fmt-tab-text">{{ fmt.label }}</text>
197+
</view>
198+
</view>
199+
200+
<!-- 备注输入 -->
201+
<view class="notes-section">
202+
<text class="notes-label">{{ t('device.export.notesLabel') }}</text>
203+
<textarea
204+
class="notes-input"
205+
v-model="exportNotes"
206+
:placeholder="t('device.export.notesPlaceholder')"
207+
placeholder-class="notes-ph"
208+
maxlength="500"
209+
:auto-height="true"
210+
/>
211+
</view>
212+
213+
<view class="dev-modal-actions">
214+
<view class="dev-modal-btn dev-modal-btn--cancel" @click="showExportModal = false">
215+
<text>{{ t('common.cancel') }}</text>
216+
</view>
217+
<view class="dev-modal-btn dev-modal-btn--confirm" :class="{ 'dev-modal-btn--loading': isExporting }" @click="confirmExport">
218+
<view v-if="isExporting" class="mini-spin" />
219+
<text v-else>{{ t('device.export.confirm') }}</text>
220+
</view>
221+
</view>
222+
</view>
223+
</view>
224+
176225
</view>
177226
</template>
178227

179228
<script setup lang="ts">
180-
import { ref, onMounted, watch } from 'vue'
229+
import { ref, computed, onMounted, watch } from 'vue'
181230
import { useBleStore } from '../../store/bleStore'
182231
import { useAppStore } from '../../store/appStore'
183232
import { useI18n } from '../../composables/useI18n'
184233
import type { BleCharacteristic } from '../../services/bleManager'
185234
import { shortUUID } from '../../utils/hex'
235+
import {
236+
saveLogsToFile,
237+
buildDeviceReportFilename,
238+
exportDeviceReportToText,
239+
exportDeviceReportToMarkdown,
240+
exportDeviceReportToCSV,
241+
type DeviceReportInfo,
242+
} from '../../utils/buffer'
186243
import SettingsPanel from '../../components/SettingsPanel.vue'
187244
import RssiChart from '../../components/RssiChart.vue'
188245
import { bleManager } from '../../services/bleManager'
@@ -197,6 +254,17 @@ const activeService = ref('')
197254
const mtuInput = ref('')
198255
const isMtuNegotiating = ref(false)
199256
257+
// ── 导出 ──
258+
const showExportModal = ref(false)
259+
const exportNotes = ref('')
260+
const exportFormat = ref<'txt' | 'md' | 'csv'>('txt')
261+
const isExporting = ref(false)
262+
const exportFormats = computed(() => [
263+
{ value: 'txt', label: t('device.export.fmtTxt') },
264+
{ value: 'md', label: t('device.export.fmtMd') },
265+
{ value: 'csv', label: t('device.export.fmtCsv') },
266+
])
267+
200268
interface ServiceNode {
201269
uuid: string; isPrimary: boolean
202270
expanded: boolean; loading: boolean
@@ -277,6 +345,125 @@ async function handleMtuNegotiate() {
277345
}
278346
}
279347
348+
async function confirmExport() {
349+
if (isExporting.value) return
350+
isExporting.value = true
351+
try {
352+
// 确保所有服务的特征值都已加载
353+
for (const node of serviceTree.value) {
354+
if (!node.characteristics.length && !node.loading && bleStore.connectedDevice) {
355+
node.loading = true
356+
try {
357+
await bleStore.loadCharacteristics(node.uuid)
358+
node.characteristics = bleStore.characteristics.get(node.uuid) ?? []
359+
} catch { /* 单个服务加载失败不中断整体导出 */ } finally {
360+
node.loading = false
361+
}
362+
}
363+
}
364+
365+
const reportInfo: DeviceReportInfo = {
366+
name: bleStore.connectedDevice?.name ?? 'Unknown',
367+
deviceId: bleStore.connectedDevice?.deviceId ?? '',
368+
rssi: bleStore.connectedDevice?.RSSI,
369+
mtu: bleStore.currentMtu,
370+
notes: exportNotes.value,
371+
services: serviceTree.value.map((node) => ({
372+
uuid: node.uuid,
373+
isPrimary: node.isPrimary,
374+
charsLoaded: node.characteristics.length > 0 || node.expanded,
375+
characteristics: node.characteristics.map((ch: BleCharacteristic) => ({
376+
uuid: ch.uuid,
377+
properties: {
378+
read: !!ch.properties.read,
379+
write: !!ch.properties.write,
380+
writeNoResponse: !!ch.properties.writeNoResponse,
381+
notify: !!ch.properties.notify,
382+
indicate: !!ch.properties.indicate,
383+
},
384+
})),
385+
})),
386+
}
387+
388+
const fmt = exportFormat.value
389+
const mimeType = fmt === 'csv' ? 'text/csv' : 'text/plain'
390+
const content =
391+
fmt === 'md' ? exportDeviceReportToMarkdown(reportInfo) :
392+
fmt === 'csv' ? exportDeviceReportToCSV(reportInfo) :
393+
exportDeviceReportToText(reportInfo)
394+
const filename = buildDeviceReportFilename(reportInfo.name, fmt)
395+
396+
console.log('[DeviceExport] exporting', filename, '| content length:', content.length)
397+
showExportModal.value = false
398+
399+
const path = await saveLogsToFile(content, filename, mimeType)
400+
console.log('[DeviceExport] saved —', path)
401+
402+
// #ifdef APP-PLUS
403+
if (plus.os.name === 'Android') {
404+
try {
405+
const Intent = plus.android.importClass('android.content.Intent')
406+
const File = plus.android.importClass('java.io.File')
407+
const BuildVersion = plus.android.importClass('android.os.Build$VERSION')
408+
const activity = plus.android.runtimeMainActivity()
409+
const intent = new Intent(Intent.ACTION_SEND)
410+
intent.setType(mimeType)
411+
const file = new File(path)
412+
if (BuildVersion.SDK_INT >= 24) {
413+
const FileProvider = plus.android.importClass('androidx.core.content.FileProvider')
414+
const authority = activity.getPackageName() + '.dc.fileprovider'
415+
console.log('[DeviceExport] FileProvider authority:', authority)
416+
const uri = FileProvider.getUriForFile(activity, authority, file)
417+
console.log('[DeviceExport] content:// URI:', uri.toString())
418+
intent.putExtra(Intent.EXTRA_STREAM, uri)
419+
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
420+
} else {
421+
const Uri = plus.android.importClass('android.net.Uri')
422+
intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file))
423+
}
424+
activity.startActivity(Intent.createChooser(intent, t('device.export.shareTitle')))
425+
console.log('[DeviceExport] share chooser started')
426+
} catch (shareErr: any) {
427+
console.error('[DeviceExport] share error —', shareErr?.message)
428+
uni.showModal({
429+
title: t('device.export.success'),
430+
content: `${filename}\n\n${t('device.export.savePath')}${path}`,
431+
showCancel: false,
432+
confirmText: t('common.ok'),
433+
})
434+
}
435+
} else {
436+
plus.share.sendWithSystem(
437+
{ type: 'file', href: path },
438+
() => { console.log('[DeviceExport] iOS share done') },
439+
(e: any) => {
440+
console.warn('[DeviceExport] iOS share failed —', JSON.stringify(e))
441+
uni.showModal({
442+
title: t('device.export.success'),
443+
content: `${filename}\n\n${t('device.export.savePath')}${path}`,
444+
showCancel: false,
445+
confirmText: t('common.ok'),
446+
})
447+
},
448+
)
449+
}
450+
// #endif
451+
// #ifndef APP-PLUS
452+
uni.showModal({
453+
title: t('device.export.success'),
454+
content: `${filename}\n\n${t('device.export.savePath')}${path}`,
455+
showCancel: false,
456+
confirmText: t('common.ok'),
457+
})
458+
// #endif
459+
} catch (e: any) {
460+
console.error('[DeviceExport] failed —', e?.message, JSON.stringify(e))
461+
uni.showToast({ title: t('device.export.failed'), icon: 'none' })
462+
} finally {
463+
isExporting.value = false
464+
}
465+
}
466+
280467
function goToDebug() { uni.switchTab({ url: '/pages/debug/index' }) }
281468
282469
function handleDisconnect() {
@@ -431,6 +618,14 @@ function handleDisconnect() {
431618
.services-section { flex: 1; display: flex; flex-direction: column; gap: 8px; }
432619
.section-hd { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; margin-bottom: 4px; }
433620
.section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
621+
.section-hd-actions { display: flex; align-items: center; gap: 6px; }
622+
.export-btn {
623+
display: flex; align-items: center; gap: 4px; height: 32px; padding: 0 10px;
624+
background: rgba(var(--color-accent-rgb), 0.08); border: 1px solid rgba(var(--color-accent-rgb), 0.25);
625+
border-radius: 8px; &:active { opacity: 0.7; }
626+
}
627+
.export-btn-icon { font-size: 13px; color: var(--color-accent); }
628+
.export-btn-label { font-size: 12px; color: var(--color-accent); font-weight: 600; }
434629
.refresh-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(var(--color-primary-rgb), 0.06); border: 1px solid rgba(var(--color-primary-rgb), 0.15); border-radius: 8px; &:active { opacity: 0.7; } }
435630
.refresh-icon { font-size: 16px; color: var(--color-primary); }
436631
@@ -506,4 +701,51 @@ function handleDisconnect() {
506701
}
507702
.go-debug-text { font-size: 14px; font-weight: 700; color: var(--bg-base); }
508703
.go-debug-arrow { font-size: 14px; color: var(--bg-base); }
704+
705+
/* ── 导出弹窗 ── */
706+
.dev-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 300; }
707+
.dev-modal-card {
708+
background: var(--bg-card); border-radius: 16px; border: 1px solid var(--border-default);
709+
padding: 24px; margin: 24px; width: 100%; max-width: 380px;
710+
display: flex; flex-direction: column; gap: 16px; box-shadow: var(--shadow-card);
711+
}
712+
.dev-modal-title { font-size: 16px; font-weight: 700; color: var(--text-primary); }
713+
714+
/* 格式选择 tabs */
715+
.fmt-tabs { display: flex; gap: 6px; }
716+
.fmt-tab {
717+
flex: 1; height: 36px; display: flex; align-items: center; justify-content: center;
718+
background: var(--bg-input); border: 1px solid var(--border-subtle); border-radius: 8px;
719+
&:active { opacity: 0.75; }
720+
&--active {
721+
background: rgba(var(--color-primary-rgb), 0.1);
722+
border-color: rgba(var(--color-primary-rgb), 0.4);
723+
.fmt-tab-text { color: var(--color-primary); }
724+
}
725+
}
726+
.fmt-tab-text { font-size: 12px; font-weight: 600; color: var(--text-muted); }
727+
728+
/* 备注输入 */
729+
.notes-section { display: flex; flex-direction: column; gap: 6px; }
730+
.notes-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.4px; }
731+
.notes-input {
732+
background: var(--bg-input); border: 1px solid var(--border-default); border-radius: 8px;
733+
padding: 10px 12px; font-size: 13px; color: var(--text-primary); width: 100%;
734+
min-height: 72px; max-height: 160px;
735+
}
736+
.notes-ph { color: var(--text-dimmed); }
737+
738+
/* 按钮行 */
739+
.dev-modal-actions { display: flex; gap: 10px; }
740+
.dev-modal-btn {
741+
flex: 1; height: 44px; display: flex; align-items: center; justify-content: center;
742+
border-radius: 10px; font-size: 14px; font-weight: 600;
743+
&--cancel { background: var(--bg-elevated); border: 1px solid var(--border-subtle); color: var(--text-secondary); }
744+
&--confirm {
745+
background: linear-gradient(135deg, var(--color-primary), rgba(var(--color-primary-rgb), 0.7));
746+
color: var(--bg-base); box-shadow: 0 0 12px rgba(var(--color-primary-rgb), 0.3);
747+
&:active { opacity: 0.85; }
748+
}
749+
&--loading { opacity: 0.6; }
750+
}
509751
</style>

0 commit comments

Comments
 (0)