Add OTA package upload functionality and UI enhancements
- Introduced a new endpoint for uploading OTA packages in backend/routes/ota.py. - Implemented file handling and MD5 checksum calculation for OTA packages. - Updated frontend API to support OTA package uploads with a new uploadOtaPackage function. - Enhanced the OTA management UI to include file upload options and improved layout. - Updated requirements.txt to include boto3 for S3 interactions. - Made various UI adjustments for better user experience and consistency.
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
"""OTA 升级包存储:Luxsin-X8 -> S3,Luxsin-X9 -> 本地目录"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OTA_MODEL_X8 = "Luxsin-X8"
|
||||||
|
OTA_MODEL_X9 = "Luxsin-X9"
|
||||||
|
OTA_UPLOAD_MODELS = {OTA_MODEL_X8, OTA_MODEL_X9}
|
||||||
|
|
||||||
|
# 本地根目录;实际文件路径为 {根}/{md5前5位}/LUXSIN.PKG
|
||||||
|
OTA_UPLOAD_DIR_DEV = os.getenv(
|
||||||
|
"OTA_UPLOAD_DIR_DEV", "H:/soft/projects/luxsin/dashboard/ota"
|
||||||
|
)
|
||||||
|
OTA_UPLOAD_DIR_PROD = os.getenv(
|
||||||
|
"OTA_UPLOAD_DIR_PROD", "/data/project/dashboard/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
AWS_REGION = os.getenv("AWS_REGION", "eu-central-1")
|
||||||
|
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "")
|
||||||
|
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "")
|
||||||
|
AWS_S3_OTA_BUCKET = os.getenv("AWS_S3_OTA_BUCKET", "luxsin-app-bucket")
|
||||||
|
|
||||||
|
OTA_X8_PUBLIC_BASE = (os.getenv("OTA_X8_PUBLIC_BASE") or "http://am.luxsinaudio.com").rstrip(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
|
OTA_X9_URL_BASE = (os.getenv("OTA_X9_URL_BASE") or "http://source.luxsin.net").rstrip("/")
|
||||||
|
|
||||||
|
OTA_FILENAME_X8 = "LUXSIN_X8.PKG"
|
||||||
|
OTA_FILENAME_X9 = "LUXSIN.PKG"
|
||||||
|
|
||||||
|
|
||||||
|
def get_ota_upload_dir() -> Path:
|
||||||
|
is_dev = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
|
||||||
|
raw = OTA_UPLOAD_DIR_DEV if is_dev else OTA_UPLOAD_DIR_PROD
|
||||||
|
return Path(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def md5_prefix5(md5_hex: str) -> str:
|
||||||
|
return (md5_hex or "")[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def build_x8_public_url(s3_key: str) -> str:
|
||||||
|
"""X8:http://am.luxsinaudio.com/ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG"""
|
||||||
|
return f"{OTA_X8_PUBLIC_BASE}/{s3_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_x9_public_url(ym: str, prefix: str) -> str:
|
||||||
|
"""X9:http://source.luxsin.net/ota/{yyyyMM}/x9/{md5前5位}/LUXSIN.PKG"""
|
||||||
|
return f"{OTA_X9_URL_BASE}/ota/{ym}/x9/{prefix}/{OTA_FILENAME_X9}"
|
||||||
|
|
||||||
|
|
||||||
|
async def read_upload_content_and_md5(package_file: UploadFile) -> tuple[bytes, str]:
|
||||||
|
md5_hash = hashlib.md5()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
chunk_size = 1024 * 1024
|
||||||
|
while True:
|
||||||
|
chunk = await package_file.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
md5_hash.update(chunk)
|
||||||
|
buf.write(chunk)
|
||||||
|
return buf.getvalue(), md5_hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def save_x9_package_local(content: bytes, md5_hex: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
保存到 {OTA_UPLOAD_DIR}/{md5前5位}/LUXSIN.PKG
|
||||||
|
对外 URL:http://source.luxsin.net/ota/{yyyyMM}/x9/{md5前5位}/LUXSIN.PKG
|
||||||
|
"""
|
||||||
|
root = get_ota_upload_dir()
|
||||||
|
prefix = md5_prefix5(md5_hex)
|
||||||
|
ym = datetime.now().strftime("%Y%m")
|
||||||
|
dest_dir = root / prefix
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
final_path = dest_dir / OTA_FILENAME_X9
|
||||||
|
if final_path.exists():
|
||||||
|
final_path.unlink()
|
||||||
|
final_path.write_bytes(content)
|
||||||
|
download_url = build_x9_public_url(ym, prefix)
|
||||||
|
logger.info("OTA X9 package saved locally: %s", final_path)
|
||||||
|
return OTA_FILENAME_X9, download_url
|
||||||
|
|
||||||
|
|
||||||
|
def upload_x8_package_to_s3(content: bytes, md5_hex: str) -> tuple[str, str, str]:
|
||||||
|
"""
|
||||||
|
S3 Key:ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG
|
||||||
|
对外 URL:http://am.luxsinaudio.com/ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG
|
||||||
|
"""
|
||||||
|
if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY:
|
||||||
|
raise ValueError("未配置 AWS 访问密钥(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)")
|
||||||
|
|
||||||
|
ym = datetime.now().strftime("%Y%m")
|
||||||
|
prefix = md5_prefix5(md5_hex)
|
||||||
|
s3_key = f"ota/{ym}/x8/{prefix}/{OTA_FILENAME_X8}"
|
||||||
|
|
||||||
|
client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
region_name=AWS_REGION,
|
||||||
|
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
client.put_object(
|
||||||
|
Bucket=AWS_S3_OTA_BUCKET,
|
||||||
|
Key=s3_key,
|
||||||
|
Body=content,
|
||||||
|
ContentType="application/octet-stream",
|
||||||
|
)
|
||||||
|
except ClientError as e:
|
||||||
|
logger.error("S3 upload failed: %s", e, exc_info=True)
|
||||||
|
raise ValueError(f"S3 上传失败:{e}") from e
|
||||||
|
|
||||||
|
download_url = build_x8_public_url(s3_key)
|
||||||
|
logger.info("OTA X8 package uploaded to s3://%s/%s", AWS_S3_OTA_BUCKET, s3_key)
|
||||||
|
return OTA_FILENAME_X8, download_url, s3_key
|
||||||
@@ -8,3 +8,4 @@ python-dotenv==1.0.0
|
|||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
PyJWT==2.8.0
|
PyJWT==2.8.0
|
||||||
|
boto3==1.34.162
|
||||||
|
|||||||
+72
-1
@@ -1,11 +1,22 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
from fastapi import APIRouter, Depends, HTTPException, Query, Body, UploadFile, File, Form
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import logging
|
import logging
|
||||||
|
from dotenv import load_dotenv
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.ota import Ota
|
from models.ota import Ota
|
||||||
from schemas import OtaCreate, OtaUpdate, OtaResponse
|
from schemas import OtaCreate, OtaUpdate, OtaResponse
|
||||||
from response import ApiResponse, PageData
|
from response import ApiResponse, PageData
|
||||||
|
from ota_storage import (
|
||||||
|
OTA_MODEL_X8,
|
||||||
|
OTA_MODEL_X9,
|
||||||
|
OTA_UPLOAD_MODELS,
|
||||||
|
read_upload_content_and_md5,
|
||||||
|
save_x9_package_local,
|
||||||
|
upload_x8_package_to_s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -13,6 +24,66 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/ota", tags=["ota"])
|
router = APIRouter(prefix="/api/ota", tags=["ota"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-package", response_model=ApiResponse)
|
||||||
|
async def upload_ota_package(
|
||||||
|
model: str = Form(..., description="设备型号"),
|
||||||
|
package_file: UploadFile = File(..., description="OTA 升级包"),
|
||||||
|
):
|
||||||
|
"""上传 OTA 升级包:计算 MD5;X8 上传 S3 为 LUXSIN_X8.PKG,X9 本地保存为 LUXSIN.PKG"""
|
||||||
|
try:
|
||||||
|
model = (model or "").strip()
|
||||||
|
if model not in OTA_UPLOAD_MODELS:
|
||||||
|
return ApiResponse(
|
||||||
|
code=0,
|
||||||
|
msg=f"当前仅支持为 {OTA_MODEL_X8}、{OTA_MODEL_X9} 上传升级包",
|
||||||
|
data=None,
|
||||||
|
)
|
||||||
|
if not package_file or not package_file.filename:
|
||||||
|
return ApiResponse(code=0, msg="请选择升级包文件", data=None)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Uploading OTA package: model=%s, filename=%s",
|
||||||
|
model,
|
||||||
|
package_file.filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
content, md5_hex = await read_upload_content_and_md5(package_file)
|
||||||
|
|
||||||
|
if model == OTA_MODEL_X9:
|
||||||
|
saved_name, download_url = save_x9_package_local(content, md5_hex)
|
||||||
|
return ApiResponse(
|
||||||
|
code=1,
|
||||||
|
msg="success",
|
||||||
|
data={
|
||||||
|
"md5": md5_hex,
|
||||||
|
"filename": saved_name,
|
||||||
|
"url": download_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if model == OTA_MODEL_X8:
|
||||||
|
saved_name, download_url, s3_key = upload_x8_package_to_s3(
|
||||||
|
content, md5_hex
|
||||||
|
)
|
||||||
|
return ApiResponse(
|
||||||
|
code=1,
|
||||||
|
msg="success",
|
||||||
|
data={
|
||||||
|
"md5": md5_hex,
|
||||||
|
"filename": saved_name,
|
||||||
|
"url": download_url,
|
||||||
|
"s3_key": s3_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return ApiResponse(code=0, msg="不支持的设备型号", data=None)
|
||||||
|
except ValueError as e:
|
||||||
|
return ApiResponse(code=0, msg=str(e), data=None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error uploading OTA package: %s", e, exc_info=True)
|
||||||
|
return ApiResponse(code=0, msg=f"上传失败:{e}", data=None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=ApiResponse)
|
@router.get("/", response_model=ApiResponse)
|
||||||
def get_ota_list(
|
def get_ota_list(
|
||||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||||
|
|||||||
@@ -47,3 +47,21 @@ export function deleteOta(id) {
|
|||||||
method: 'delete'
|
method: 'delete'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传 OTA 升级包(Luxsin-X9)
|
||||||
|
* @param {File} file
|
||||||
|
* @param {string} model 设备型号
|
||||||
|
*/
|
||||||
|
export function uploadOtaPackage(file, model) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('package_file', file)
|
||||||
|
formData.append('model', model)
|
||||||
|
return request({
|
||||||
|
url: '/ota/upload-package',
|
||||||
|
method: 'post',
|
||||||
|
data: formData,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 300000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<el-container class="lux-shell-body">
|
<el-container class="lux-shell-body">
|
||||||
<el-aside width="220px" class="sidebar-glass">
|
<el-aside width="220px" class="sidebar-glass">
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<h2>耳机管理平台</h2>
|
<h2>Luxsin CMS</h2>
|
||||||
</div>
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
class="lux-menu"
|
class="lux-menu"
|
||||||
@@ -48,16 +48,23 @@
|
|||||||
|
|
||||||
<el-container class="lux-right-col">
|
<el-container class="lux-right-col">
|
||||||
<el-header class="lux-header">
|
<el-header class="lux-header">
|
||||||
<div class="lux-header-title">耳机品牌与型号管理系统</div>
|
<el-button
|
||||||
<div class="header-right">
|
circle
|
||||||
<el-button type="primary" size="small" class="logout-btn" @click="handleLogout">
|
type="primary"
|
||||||
退出登录
|
class="lux-header-power-btn"
|
||||||
|
title="退出登录"
|
||||||
|
aria-label="退出登录"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<el-icon :size="18"><SwitchButton /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
|
||||||
</el-header>
|
</el-header>
|
||||||
<el-main class="lux-main">
|
<el-main class="lux-main">
|
||||||
<router-view />
|
<router-view />
|
||||||
</el-main>
|
</el-main>
|
||||||
|
<el-footer class="lux-footer" height="auto">
|
||||||
|
<span class="lux-footer-copy">©Powered By EafonYang</span>
|
||||||
|
</el-footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +74,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Headset, Collection, List, Upload, Document } from '@element-plus/icons-vue'
|
import { Headset, Collection, List, Upload, Document, SwitchButton } from '@element-plus/icons-vue'
|
||||||
import { clearToken } from '@/utils/auth'
|
import { clearToken } from '@/utils/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -85,14 +92,8 @@ const handleLogout = () => {
|
|||||||
.lux-right-col {
|
.lux-right-col {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
.header-right {
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn {
|
|
||||||
color: #ffffff !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ body {
|
|||||||
.lux-shell .sidebar-glass {
|
.lux-shell .sidebar-glass {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 220px !important;
|
width: 220px !important;
|
||||||
overflow-x: hidden;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--lux-glass-sidebar) !important;
|
background: var(--lux-glass-sidebar) !important;
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
-webkit-backdrop-filter: blur(18px);
|
-webkit-backdrop-filter: blur(18px);
|
||||||
@@ -119,6 +122,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lux-shell .lux-menu.el-menu {
|
.lux-shell .lux-menu.el-menu {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
border-right: none !important;
|
border-right: none !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
padding: 8px 6px 16px;
|
padding: 8px 6px 16px;
|
||||||
@@ -159,36 +165,71 @@ body {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— 顶栏 —— */
|
/* —— 顶栏(与内容卡片一致的浅色底) —— */
|
||||||
.lux-shell .lux-header {
|
.lux-shell .lux-header {
|
||||||
height: 56px !important;
|
height: 44px !important;
|
||||||
|
min-height: 44px !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
padding: 0 22px !important;
|
padding: 0 14px !important;
|
||||||
margin: 12px 16px 0 8px;
|
margin: 8px 16px 0 8px;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
background: var(--lux-glass-header);
|
background: var(--lux-card-grad) !important;
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
-webkit-backdrop-filter: blur(14px);
|
-webkit-backdrop-filter: blur(14px);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.22) !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 6px -1px rgba(15, 23, 42, 0.06),
|
0 2px 4px -1px rgba(15, 23, 42, 0.05),
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
|
0 0 0 1px rgba(255, 255, 255, 0.65) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lux-shell .lux-header-title {
|
.lux-shell .lux-header-power-btn {
|
||||||
font-size: 17px;
|
width: 34px !important;
|
||||||
font-weight: 700;
|
height: 34px !important;
|
||||||
letter-spacing: -0.01em;
|
padding: 0 !important;
|
||||||
color: #0f172a;
|
box-shadow: 0 4px 12px rgba(56, 189, 248, 0.22) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lux-shell .lux-header-power-btn .el-icon {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lux-shell .lux-header-power-btn:hover,
|
||||||
|
.lux-shell .lux-header-power-btn:focus {
|
||||||
|
box-shadow: 0 6px 16px rgba(56, 189, 248, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— 主内容区 —— */
|
/* —— 主内容区 —— */
|
||||||
.lux-shell .lux-main {
|
.lux-shell .lux-main {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
padding: 16px 16px 24px 12px !important;
|
padding: 12px 16px 24px 12px !important;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lux-shell .lux-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: auto !important;
|
||||||
|
padding: 10px 16px 14px !important;
|
||||||
|
margin: 0 16px 12px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(15, 23, 42, 0.35) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lux-shell .lux-footer-copy {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(203, 213, 225, 0.85);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lux-shell .card-header .title {
|
.lux-shell .card-header .title {
|
||||||
@@ -219,6 +260,20 @@ body {
|
|||||||
color: var(--lux-text-strong);
|
color: var(--lux-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 列表页顶部筛选栏:压低整体高度 */
|
||||||
|
.lux-shell .el-card.search-card {
|
||||||
|
margin-bottom: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lux-shell .el-card.search-card .el-card__body {
|
||||||
|
padding: 8px 14px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lux-shell .search-form.el-form--inline .el-form-item {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.lux-shell .el-table {
|
.lux-shell .el-table {
|
||||||
--el-table-border-color: rgba(148, 163, 184, 0.22);
|
--el-table-border-color: rgba(148, 163, 184, 0.22);
|
||||||
--el-table-header-bg-color: #eef2f7;
|
--el-table-header-bg-color: #eef2f7;
|
||||||
|
|||||||
@@ -279,10 +279,6 @@ onMounted(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-card {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -628,10 +628,6 @@ onMounted(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-card {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -88,11 +88,10 @@
|
|||||||
<el-tag v-else type="info" size="small">停用</el-tag>
|
<el-tag v-else type="info" size="small">停用</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="startTime" label="开始时间" width="160" show-overflow-tooltip />
|
<el-table-column label="操作" width="230" fixed="right">
|
||||||
<el-table-column prop="endTime" label="结束时间" width="160" show-overflow-tooltip />
|
|
||||||
<el-table-column label="操作" width="160" fixed="right">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
<el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||||
|
<el-button type="success" size="small" class="ota-copy-btn" @click="handleCopy(scope.row)">复制</el-button>
|
||||||
<el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
|
<el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -126,7 +125,6 @@
|
|||||||
label-width="120px"
|
label-width="120px"
|
||||||
class="ota-form"
|
class="ota-form"
|
||||||
>
|
>
|
||||||
<el-divider content-position="left">主升级包</el-divider>
|
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="版本号" prop="verCode">
|
<el-form-item label="版本号" prop="verCode">
|
||||||
@@ -146,83 +144,92 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24">
|
|
||||||
<el-form-item label="下载地址" prop="url">
|
|
||||||
<el-input v-model="formData.url" maxlength="255" show-word-limit />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="24">
|
|
||||||
<el-form-item label="MD5(32位)" prop="md5">
|
|
||||||
<el-input v-model="formData.md5" maxlength="32" show-word-limit placeholder="32 位 MD5" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="强升" prop="force">
|
|
||||||
<el-select v-model="formData.force" style="width: 100%">
|
|
||||||
<el-option label="否" :value="0" />
|
|
||||||
<el-option label="是" :value="1" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="状态" prop="status">
|
|
||||||
<el-select v-model="formData.status" style="width: 100%">
|
|
||||||
<el-option label="可用" :value="1" />
|
|
||||||
<el-option label="停用" :value="0" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="硬件版本" prop="hw">
|
<el-form-item label="硬件版本" prop="hw">
|
||||||
<el-input-number v-model="formData.hw" :min="0" :step="1" style="width: 100%" />
|
<el-input-number v-model="formData.hw" :min="0" :step="1" style="width: 100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="24" v-if="isPackageUploadModel">
|
||||||
<el-form-item label="定向" prop="target">
|
<el-form-item label="升级包" prop="url">
|
||||||
<el-select v-model="formData.target" style="width: 100%">
|
<el-upload
|
||||||
<el-option label="否(面向所有用户)" :value="0" />
|
ref="packageUploadRef"
|
||||||
<el-option label="是" :value="1" />
|
:auto-upload="false"
|
||||||
</el-select>
|
:limit="1"
|
||||||
|
accept=".pkg,.bin,.zip"
|
||||||
|
:file-list="packageFileList"
|
||||||
|
:on-change="handlePackageChange"
|
||||||
|
:on-remove="handlePackageRemove"
|
||||||
|
:disabled="packageUploading"
|
||||||
|
>
|
||||||
|
<el-button type="primary" :loading="packageUploading">选择文件</el-button>
|
||||||
|
</el-upload>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="24" v-if="isPackageUploadModel && formData.url">
|
||||||
<el-form-item label="灰度" prop="beta">
|
<el-form-item label="下载地址">
|
||||||
<el-select v-model="formData.beta" style="width: 100%">
|
<el-input v-model="formData.url" readonly style="width: 100%" />
|
||||||
<el-option label="否" :value="0" />
|
|
||||||
<el-option label="是" :value="1" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-form-item label="描述" prop="desc">
|
|
||||||
<el-input v-model="formData.desc" type="textarea" :rows="2" maxlength="255" show-word-limit />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<el-divider content-position="left">时间窗口</el-divider>
|
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="开始时间" prop="startTime">
|
<el-form-item label="MD5(32位)" prop="md5">
|
||||||
<el-date-picker
|
<el-input
|
||||||
v-model="formData.startTime"
|
v-model="formData.md5"
|
||||||
type="datetime"
|
maxlength="32"
|
||||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
show-word-limit
|
||||||
placeholder="可选"
|
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
clearable
|
:readonly="isPackageUploadModel"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
</el-row>
|
||||||
<el-form-item label="结束时间" prop="endTime">
|
</el-col>
|
||||||
<el-date-picker
|
<el-col :span="6">
|
||||||
v-model="formData.endTime"
|
<el-form-item label="强升" prop="force">
|
||||||
type="datetime"
|
<el-switch
|
||||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
v-model="formData.force"
|
||||||
placeholder="可选"
|
:active-value="1"
|
||||||
style="width: 100%"
|
:inactive-value="0"
|
||||||
clearable
|
inline-prompt
|
||||||
|
active-text="是"
|
||||||
|
inactive-text="否"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-switch
|
||||||
|
v-model="formData.status"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
inline-prompt
|
||||||
|
active-text="可用"
|
||||||
|
inactive-text="停用"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="定向" prop="target">
|
||||||
|
<el-switch
|
||||||
|
v-model="formData.target"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
inline-prompt
|
||||||
|
active-text="定向"
|
||||||
|
inactive-text="全量"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-form-item label="灰度" prop="beta">
|
||||||
|
<el-switch
|
||||||
|
v-model="formData.beta"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
inline-prompt
|
||||||
|
active-text="是"
|
||||||
|
inactive-text="否"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -238,18 +245,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
||||||
import { getOtaList, createOta, updateOta, deleteOta } from '@/api/ota'
|
import { getOtaList, createOta, updateOta, deleteOta, uploadOtaPackage } from '@/api/ota'
|
||||||
|
|
||||||
const PAW_MD5_PLACEHOLDER = '0'.repeat(32)
|
const PAW_MD5_PLACEHOLDER = '0'.repeat(32)
|
||||||
|
|
||||||
|
const PACKAGE_UPLOAD_MODELS = ['Luxsin-X8', 'Luxsin-X9']
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const dialogTitle = ref('新增 OTA')
|
const dialogTitle = ref('新增 OTA')
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
|
const packageUploadRef = ref(null)
|
||||||
|
const packageFileList = ref([])
|
||||||
|
const packageUploading = ref(false)
|
||||||
|
/** 从列表行填充表单时跳过 model watch,避免误清 url/md5(默认型号与行型号不一致时) */
|
||||||
|
const skipPackageClearOnModelWatch = ref(false)
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
verName: '',
|
verName: '',
|
||||||
@@ -274,23 +288,24 @@ function emptyForm() {
|
|||||||
url: '',
|
url: '',
|
||||||
md5: '',
|
md5: '',
|
||||||
force: 0,
|
force: 0,
|
||||||
desc: '',
|
|
||||||
model: 'Luxsin-X8',
|
model: 'Luxsin-X8',
|
||||||
hw: 0,
|
hw: 6,
|
||||||
target: 0,
|
target: 0,
|
||||||
beta: 0,
|
beta: 0,
|
||||||
pawVerCode: 0,
|
pawVerCode: 0,
|
||||||
pawVerName: '',
|
pawVerName: '',
|
||||||
pawUrl: '',
|
pawUrl: '',
|
||||||
pawMd5: '',
|
pawMd5: '',
|
||||||
startTime: '',
|
|
||||||
endTime: '',
|
|
||||||
status: 1
|
status: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = reactive(emptyForm())
|
const formData = reactive(emptyForm())
|
||||||
|
|
||||||
|
const isPackageUploadModel = computed(() =>
|
||||||
|
PACKAGE_UPLOAD_MODELS.includes(formData.model)
|
||||||
|
)
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
verCode: [{ required: true, message: '请输入版本号', trigger: 'change' }],
|
verCode: [{ required: true, message: '请输入版本号', trigger: 'change' }],
|
||||||
verName: [
|
verName: [
|
||||||
@@ -298,9 +313,25 @@ const formRules = {
|
|||||||
{ min: 1, max: 20, message: '1~20 字符', trigger: 'blur' }
|
{ min: 1, max: 20, message: '1~20 字符', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
model: [{ required: true, message: '请选择设备型号', trigger: 'change' }],
|
model: [{ required: true, message: '请选择设备型号', trigger: 'change' }],
|
||||||
url: [{ required: true, message: '请输入下载地址', trigger: 'blur' }],
|
url: [
|
||||||
|
{
|
||||||
|
validator: (_rule, value, callback) => {
|
||||||
|
if (isPackageUploadModel.value) {
|
||||||
|
if (!value || !String(value).trim()) {
|
||||||
|
callback(new Error('请上传升级包'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (!value || !String(value).trim()) {
|
||||||
|
callback(new Error('请输入下载地址'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
md5: [
|
md5: [
|
||||||
{ required: true, message: '请输入 MD5', trigger: 'blur' },
|
{ required: true, message: '请填写 MD5', trigger: 'blur' },
|
||||||
{ len: 32, message: '须为 32 位', trigger: 'blur' },
|
{ len: 32, message: '须为 32 位', trigger: 'blur' },
|
||||||
{
|
{
|
||||||
pattern: /^[0-9a-fA-F]{32}$/,
|
pattern: /^[0-9a-fA-F]{32}$/,
|
||||||
@@ -315,7 +346,6 @@ function buildPayload() {
|
|||||||
if (pawMd5.length !== 32) {
|
if (pawMd5.length !== 32) {
|
||||||
pawMd5 = PAW_MD5_PLACEHOLDER
|
pawMd5 = PAW_MD5_PLACEHOLDER
|
||||||
}
|
}
|
||||||
const desc = (formData.desc || '').trim()
|
|
||||||
const model = (formData.model == null || formData.model === '') ? '' : String(formData.model).trim()
|
const model = (formData.model == null || formData.model === '') ? '' : String(formData.model).trim()
|
||||||
return {
|
return {
|
||||||
verCode: Number(formData.verCode),
|
verCode: Number(formData.verCode),
|
||||||
@@ -323,7 +353,7 @@ function buildPayload() {
|
|||||||
url: (formData.url || '').trim(),
|
url: (formData.url || '').trim(),
|
||||||
md5: (formData.md5 || '').trim(),
|
md5: (formData.md5 || '').trim(),
|
||||||
force: Number(formData.force ?? 0),
|
force: Number(formData.force ?? 0),
|
||||||
desc: desc === '' ? null : desc,
|
desc: null,
|
||||||
model: model === '' ? null : model,
|
model: model === '' ? null : model,
|
||||||
hw: Number(formData.hw ?? 0),
|
hw: Number(formData.hw ?? 0),
|
||||||
target: Number(formData.target ?? 0),
|
target: Number(formData.target ?? 0),
|
||||||
@@ -332,14 +362,71 @@ function buildPayload() {
|
|||||||
pawVerName: (formData.pawVerName || '').trim(),
|
pawVerName: (formData.pawVerName || '').trim(),
|
||||||
pawUrl: (formData.pawUrl || '').trim(),
|
pawUrl: (formData.pawUrl || '').trim(),
|
||||||
pawMd5,
|
pawMd5,
|
||||||
startTime: formData.startTime || null,
|
startTime: null,
|
||||||
endTime: formData.endTime || null,
|
endTime: null,
|
||||||
status: Number(formData.status ?? 1)
|
status: Number(formData.status ?? 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
Object.assign(formData, emptyForm())
|
Object.assign(formData, emptyForm())
|
||||||
|
packageFileList.value = []
|
||||||
|
packageUploadRef.value?.clearFiles?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPackageFields() {
|
||||||
|
formData.url = ''
|
||||||
|
formData.md5 = ''
|
||||||
|
packageFileList.value = []
|
||||||
|
packageUploadRef.value?.clearFiles?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => formData.model,
|
||||||
|
(val, oldVal) => {
|
||||||
|
if (skipPackageClearOnModelWatch.value) return
|
||||||
|
const wasUpload = PACKAGE_UPLOAD_MODELS.includes(oldVal)
|
||||||
|
const isUpload = PACKAGE_UPLOAD_MODELS.includes(val)
|
||||||
|
if (wasUpload && (!isUpload || val !== oldVal)) {
|
||||||
|
clearPackageFields()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePackageChange = async (file) => {
|
||||||
|
if (!file?.raw) return
|
||||||
|
if (!isPackageUploadModel.value) {
|
||||||
|
ElMessage.warning('当前型号不支持上传升级包')
|
||||||
|
packageFileList.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
packageFileList.value = [file]
|
||||||
|
packageUploading.value = true
|
||||||
|
try {
|
||||||
|
const res = await uploadOtaPackage(file.raw, formData.model)
|
||||||
|
if (res.code === 1 && res.data) {
|
||||||
|
formData.md5 = res.data.md5 || ''
|
||||||
|
formData.url = res.data.url || res.data.filename || ''
|
||||||
|
ElMessage.success('升级包上传成功')
|
||||||
|
formRef.value?.validateField?.('url')
|
||||||
|
formRef.value?.validateField?.('md5')
|
||||||
|
} else {
|
||||||
|
packageFileList.value = []
|
||||||
|
ElMessage.error(res.msg || '上传失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
packageFileList.value = []
|
||||||
|
ElMessage.error('上传失败')
|
||||||
|
} finally {
|
||||||
|
packageUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePackageRemove = () => {
|
||||||
|
formData.url = ''
|
||||||
|
formData.md5 = ''
|
||||||
|
packageFileList.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -393,17 +480,14 @@ const handleAdd = () => {
|
|||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (row) => {
|
function applyOtaRowToForm(row, isCopy) {
|
||||||
dialogTitle.value = '编辑 OTA'
|
|
||||||
resetForm()
|
|
||||||
Object.assign(formData, {
|
Object.assign(formData, {
|
||||||
id: row.id,
|
id: isCopy ? null : row.id,
|
||||||
verCode: row.verCode,
|
verCode: row.verCode,
|
||||||
verName: row.verName ?? '',
|
verName: row.verName ?? '',
|
||||||
url: row.url ?? '',
|
url: row.url ?? '',
|
||||||
md5: row.md5 ?? '',
|
md5: row.md5 ?? '',
|
||||||
force: row.force ?? 0,
|
force: row.force ?? 0,
|
||||||
desc: row.desc ?? '',
|
|
||||||
model:
|
model:
|
||||||
row.model && ['Luxsin-X8', 'Luxsin-X9'].includes(row.model)
|
row.model && ['Luxsin-X8', 'Luxsin-X9'].includes(row.model)
|
||||||
? row.model
|
? row.model
|
||||||
@@ -415,11 +499,32 @@ const handleEdit = (row) => {
|
|||||||
pawVerName: row.pawVerName ?? '',
|
pawVerName: row.pawVerName ?? '',
|
||||||
pawUrl: row.pawUrl ?? '',
|
pawUrl: row.pawUrl ?? '',
|
||||||
pawMd5: row.pawMd5 || '',
|
pawMd5: row.pawMd5 || '',
|
||||||
startTime: row.startTime || '',
|
|
||||||
endTime: row.endTime || '',
|
|
||||||
status: row.status ?? 1
|
status: row.status ?? 1
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
dialogTitle.value = '编辑 OTA'
|
||||||
|
skipPackageClearOnModelWatch.value = true
|
||||||
|
resetForm()
|
||||||
|
applyOtaRowToForm(row, false)
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
skipPackageClearOnModelWatch.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (row) => {
|
||||||
|
dialogTitle.value = '新增 OTA'
|
||||||
|
skipPackageClearOnModelWatch.value = true
|
||||||
|
resetForm()
|
||||||
|
applyOtaRowToForm(row, true)
|
||||||
|
dialogVisible.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
skipPackageClearOnModelWatch.value = false
|
||||||
|
formRef.value?.clearValidate?.()
|
||||||
|
})
|
||||||
|
ElMessage.info('已复制当前行数据,请修改版本号等信息后保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
@@ -494,9 +599,6 @@ onMounted(() => {
|
|||||||
.ota-container {
|
.ota-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.search-card {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -521,6 +623,21 @@ onMounted(() => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.ota-form {
|
.ota-form {
|
||||||
|
padding-top: 16px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 复制:绿底白字(避免主题覆盖为渐变) */
|
||||||
|
.ota-copy-btn {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-color: var(--el-color-success) !important;
|
||||||
|
border-color: var(--el-color-success) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ota-copy-btn:hover,
|
||||||
|
.ota-copy-btn:focus {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-color: var(--el-color-success-light-3) !important;
|
||||||
|
border-color: var(--el-color-success-light-3) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user