新增 luxsin-controller,更新 wiki
This commit is contained in:
@@ -55,7 +55,7 @@
|
||||
<script setup>
|
||||
import { computed, markRaw, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Document, List, Upload, Collection, House, User } from '@element-plus/icons-vue'
|
||||
import { Document, List, Upload, Collection, House, User, Tools } from '@element-plus/icons-vue'
|
||||
import { TABS_STORAGE_KEY, HOME_PATH } from '@/utils/tabs'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -67,7 +67,8 @@ const iconMap = markRaw({
|
||||
upload: Upload,
|
||||
collection: Collection,
|
||||
home: House,
|
||||
user: User
|
||||
user: User,
|
||||
tools: Tools
|
||||
})
|
||||
|
||||
function resolveTabIcon(iconKey) {
|
||||
|
||||
@@ -88,6 +88,16 @@
|
||||
<span>分享日志</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="toolbox">
|
||||
<template #title>
|
||||
<el-icon><Tools /></el-icon>
|
||||
<span>工具箱</span>
|
||||
</template>
|
||||
<el-menu-item index="/toolbox/luxsin-controller">
|
||||
<el-icon><Operation /></el-icon>
|
||||
<span>luxsin-controller</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +163,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Headset, Collection, List, Upload, Document, Share, User, Warning, Aim } from '@element-plus/icons-vue'
|
||||
import { Headset, Collection, List, Upload, Document, Share, User, Warning, Aim, Tools, Operation } from '@element-plus/icons-vue'
|
||||
import { clearAuth, getUser, isSuperAdmin } from '@/utils/auth'
|
||||
import TabsView from '@/components/TabsView.vue'
|
||||
import SidebarLogo from '@/components/SidebarLogo.vue'
|
||||
@@ -175,6 +185,7 @@ function resolveOpenedMenus(path) {
|
||||
if (path.startsWith('/brand') || path.startsWith('/model')) return ['1']
|
||||
if (path.startsWith('/ota') || path.startsWith('/blacklist') || path.startsWith('/ota-target-device')) return ['upgrade']
|
||||
if (path.startsWith('/share-code')) return ['share-code']
|
||||
if (path.startsWith('/toolbox')) return ['toolbox']
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -189,7 +200,8 @@ watch(
|
||||
const railNavItems = [
|
||||
{ title: '耳机管理', icon: Headset },
|
||||
{ title: '升级管理', icon: Upload },
|
||||
{ title: '分享码', icon: Share }
|
||||
{ title: '分享码', icon: Share },
|
||||
{ title: '工具箱', icon: Tools }
|
||||
]
|
||||
|
||||
function handleRailClick() {
|
||||
@@ -207,6 +219,7 @@ const breadcrumbSection = computed(() => {
|
||||
if (path.startsWith('/brand') || path.startsWith('/model')) return '耳机管理'
|
||||
if (path.startsWith('/ota') || path.startsWith('/blacklist') || path.startsWith('/ota-target-device')) return '升级管理'
|
||||
if (path.startsWith('/share-code')) return '分享码'
|
||||
if (path.startsWith('/toolbox')) return '工具箱'
|
||||
if (path.startsWith('/system')) return '系统管理'
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -63,6 +63,12 @@ const routes = [
|
||||
name: 'SystemUsers',
|
||||
component: () => import('@/views/system/users/index.vue'),
|
||||
meta: { title: '账号管理', icon: 'user', requiresSuperAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/toolbox/luxsin-controller',
|
||||
name: 'LuxsinController',
|
||||
component: () => import('@/views/toolbox/luxsin-controller/index.vue'),
|
||||
meta: { title: 'luxsin-controller', icon: 'tools' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { House, Collection, List, Document, Share, Upload, Loading } from '@element-plus/icons-vue'
|
||||
import { House, Collection, List, Document, Share, Upload, Loading, Tools } from '@element-plus/icons-vue'
|
||||
import { getUser } from '@/utils/auth'
|
||||
import { getTodayStats } from '@/api/dashboard'
|
||||
|
||||
@@ -114,7 +114,8 @@ const quickLinks = [
|
||||
{ path: '/brand', title: '耳机品牌管理', desc: '维护耳机品牌信息', icon: Collection, colorClass: 'icon-cyan' },
|
||||
{ path: '/model', title: '耳机型号管理', desc: '维护耳机型号与配置', icon: List, colorClass: 'icon-coral' },
|
||||
{ path: '/ota', title: 'OTA 管理', desc: '固件升级包管理', icon: Document, colorClass: 'icon-cyan' },
|
||||
{ path: '/share-code/log', title: '分享日志', desc: '查看分享码使用记录', icon: Share, colorClass: 'icon-coral' }
|
||||
{ path: '/share-code/log', title: '分享日志', desc: '查看分享码使用记录', icon: Share, colorClass: 'icon-coral' },
|
||||
{ path: '/toolbox/luxsin-controller', title: 'Luxsin 控制器', desc: '局域网设备数据同步', icon: Tools, colorClass: 'icon-cyan' }
|
||||
]
|
||||
|
||||
function formatTime(isoStr) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="luxsin-ctrl-page">
|
||||
<!-- 操作栏 -->
|
||||
<el-card class="search-card" shadow="never">
|
||||
<el-form :inline="true" @submit.prevent="handleSyncData">
|
||||
<el-form-item label="设备 IP">
|
||||
<el-input
|
||||
v-model="deviceIp"
|
||||
placeholder="例如 192.168.9.125"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSyncData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="syncDataLoading"
|
||||
:disabled="!deviceIp.trim()"
|
||||
@click="handleSyncData"
|
||||
>
|
||||
同步数据
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="syncPeqLoading"
|
||||
:disabled="!deviceIp.trim()"
|
||||
@click="handleSyncPeq"
|
||||
>
|
||||
同步 PEQ
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 原始响应(默认折叠) -->
|
||||
<el-card v-if="rawResponse !== null" shadow="never" class="result-card">
|
||||
<template #header>
|
||||
<div class="card-header card-header--collapsible" @click="rawExpanded = !rawExpanded">
|
||||
<span class="title" style="font-size: 15px">
|
||||
<el-icon class="collapse-arrow" :class="{ 'is-open': rawExpanded }"><ArrowRight /></el-icon>
|
||||
原始响应(Base64)
|
||||
</span>
|
||||
<el-tag size="small" type="info">{{ rawResponse.length }} chars</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<pre v-show="rawExpanded" class="raw-text">{{ rawResponse }}</pre>
|
||||
</el-card>
|
||||
|
||||
<!-- 解码结果 -->
|
||||
<el-card v-if="decodedData !== null" shadow="never" class="result-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title" style="font-size: 15px">解码结果(JSON)</span>
|
||||
<el-tag size="small" type="success">decoded</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="json-output">{{ decodedData }}</pre>
|
||||
</el-card>
|
||||
|
||||
<!-- 解码失败 -->
|
||||
<el-card v-if="decodeError" shadow="never" class="result-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title" style="font-size: 15px; color: #fb7185">解码失败</span>
|
||||
<el-tag size="small" type="danger">error</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="error-text">{{ decodeError }}</pre>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({ name: 'LuxsinController' })
|
||||
|
||||
const deviceIp = ref('')
|
||||
const syncDataLoading = ref(false)
|
||||
const syncPeqLoading = ref(false)
|
||||
const rawResponse = ref(null)
|
||||
const rawExpanded = ref(false)
|
||||
const decodedData = ref(null)
|
||||
const decodeError = ref('')
|
||||
|
||||
/**
|
||||
* 自定义 Base64 解码
|
||||
* 编码表: KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/
|
||||
* 将自定义字符映射回标准 Base64 字符后用 atob() 解码
|
||||
*/
|
||||
const CUSTOM_B64 = 'KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/'
|
||||
const STD_B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
|
||||
function decodeCustomBase64(input) {
|
||||
// 构建映射表
|
||||
const map = {}
|
||||
for (let i = 0; i < 64; i++) {
|
||||
map[CUSTOM_B64[i]] = STD_B64[i]
|
||||
}
|
||||
|
||||
// 替换字符为标准 Base64
|
||||
let std = ''
|
||||
for (const ch of input) {
|
||||
if (ch === '=' || ch === '\n' || ch === '\r' || ch === ' ') {
|
||||
std += ch
|
||||
} else if (map[ch] !== undefined) {
|
||||
std += map[ch]
|
||||
} else {
|
||||
throw new Error(`无效字符: "${ch}" (U+${ch.charCodeAt(0).toString(16).toUpperCase()})`)
|
||||
}
|
||||
}
|
||||
|
||||
// 用 atob 解码为标准二进制字符串
|
||||
const binary = atob(std)
|
||||
|
||||
// 转 UTF-8 字符串
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(bytes)
|
||||
}
|
||||
|
||||
async function handleSyncData() {
|
||||
await doFetch('syncData', syncDataLoading)
|
||||
}
|
||||
|
||||
async function handleSyncPeq() {
|
||||
await doFetch('syncPeq', syncPeqLoading)
|
||||
}
|
||||
|
||||
async function doFetch(action, loadingRef) {
|
||||
const ip = deviceIp.value.trim()
|
||||
if (!ip) return
|
||||
|
||||
loadingRef.value = true
|
||||
rawResponse.value = null
|
||||
decodedData.value = null
|
||||
decodeError.value = ''
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://${ip}/dev/info.cgi?action=${action}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`设备返回 HTTP ${res.status}`)
|
||||
}
|
||||
const raw = await res.text()
|
||||
rawResponse.value = raw
|
||||
|
||||
if (!raw) {
|
||||
decodeError.value = '设备返回了空数据'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStr = decodeCustomBase64(raw.trim())
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
decodedData.value = JSON.stringify(parsed, null, 2)
|
||||
} catch (e) {
|
||||
decodeError.value = e.message
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '请求失败')
|
||||
} finally {
|
||||
loadingRef.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.luxsin-ctrl-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.result-card :deep(.el-card__body) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.raw-text,
|
||||
.json-output,
|
||||
.error-text {
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.raw-text {
|
||||
color: #94a3b8;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.json-output {
|
||||
color: #cbd5e1;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #fb7185;
|
||||
background: rgba(251, 113, 133, 0.06);
|
||||
}
|
||||
|
||||
.card-header--collapsible {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-header--collapsible:hover .title {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.collapse-arrow.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user