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
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'
181230import { useBleStore } from ' ../../store/bleStore'
182231import { useAppStore } from ' ../../store/appStore'
183232import { useI18n } from ' ../../composables/useI18n'
184233import type { BleCharacteristic } from ' ../../services/bleManager'
185234import { shortUUID } from ' ../../utils/hex'
235+ import {
236+ saveLogsToFile ,
237+ buildDeviceReportFilename ,
238+ exportDeviceReportToText ,
239+ exportDeviceReportToMarkdown ,
240+ exportDeviceReportToCSV ,
241+ type DeviceReportInfo ,
242+ } from ' ../../utils/buffer'
186243import SettingsPanel from ' ../../components/SettingsPanel.vue'
187244import RssiChart from ' ../../components/RssiChart.vue'
188245import { bleManager } from ' ../../services/bleManager'
@@ -197,6 +254,17 @@ const activeService = ref('')
197254const mtuInput = ref (' ' )
198255const 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+
200268interface 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+
280467function goToDebug() { uni .switchTab ({ url: ' /pages/debug/index' }) }
281468
282469function 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