新增 luxsin-controller,更新 wiki

This commit is contained in:
eafonyang
2026-07-10 16:51:05 +08:00
parent b9ba1d5ed4
commit fc88d42be9
28 changed files with 1009 additions and 270 deletions
+3 -2
View File
@@ -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) {
+15 -2
View File
@@ -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 ''
})
+6
View File
@@ -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' }
}
]
}
+3 -2
View File
@@ -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>