diff --git a/app/index/controller/Common.php b/app/index/controller/Common.php
index 880f8072..703890c8 100644
--- a/app/index/controller/Common.php
+++ b/app/index/controller/Common.php
@@ -107,13 +107,6 @@ abstract class Common extends BaseController
'status' => 1
])
->where('status', '=', 1)
- ->where(function($query) {
- // 临时代码,移动端暂时不显示 "AI PC"
- if (request()->from == 'mobile') {
- $table_name = SysNavigationItemModel::getTable();
- $query->whereNotIn($table_name . ".id", [77, 78]);
- }
- })
->order(['sort' => 'asc', 'id' => 'asc'])
->select();
if ($nav->isEmpty()) {
diff --git a/app/index/controller/TopicLaptop.php b/app/index/controller/TopicLaptop.php
index 1cba3335..0682bb0b 100644
--- a/app/index/controller/TopicLaptop.php
+++ b/app/index/controller/TopicLaptop.php
@@ -5,7 +5,6 @@ namespace app\index\controller;
use app\index\model\SysBannerModel;
use think\facade\View;
-use think\Request;
class TopicLaptop extends Common
{
@@ -96,7 +95,7 @@ class TopicLaptop extends Common
// 产品检测介绍
$data['product_testing'] = data_get($banners_map, 'BANNER_693a2ff4629bd')?->items->toArray();
// 网页脚注
- $data['webpage_footnotes'] = data_get($banners_map, 'BANNER_693a30e9e4572')?->items->first()?->toArray();
+ $data['webpage_footnotes'] = data_get($banners_map, 'BANNER_693a30e9e4572')?->items->first()?->toArray();
}
View::assign('data', $data);
diff --git a/app/index/lang/en-us/mobile.php b/app/index/lang/en-us/mobile.php
index 175712be..4e0cde9b 100644
--- a/app/index/lang/en-us/mobile.php
+++ b/app/index/lang/en-us/mobile.php
@@ -92,9 +92,9 @@ return [
],
'attachment/video' => [
'软件和驱动程序' => 'Software and Drivers',
- '您的浏览器不支持 video 标签。' => 'Your browser does not support HTML5 video.',
+ '您的浏览器不支持 video 标签。' => 'Your browser does not support HTML5 video.',
],
-
+
// 联系我们批量购买页面
'contactus/bulkbuy' => [
'批量购买' => 'Bulk Buy',
@@ -219,4 +219,21 @@ return [
'联系我们' => 'Contact US',
'目录' => 'Content'
],
-];
\ No newline at end of file
+
+ // 笔记本专题 - 首页
+ 'topiclaptop/index' => [
+ 'CineBench R23 多核跑分' => 'Outperforms Ryzen 5 & Intel i5',
+ '*此跑分为ORICO实验室测定所得,请以实际使用为准' => '*Data measured by ORICO Lab. Actual performance may vary.',
+ '3DMARK Time Spy显卡得分' => 'Handles Office & Gaming with Ease',
+ '肯辛通锁孔' => 'Kensington
Lock Slot',
+ '千兆网口' => 'Gigabit
Ethernet',
+ 'USB-A
(5Gbps)' => 'USB-A
(5Gbps)',
+ '3.5mm
耳麦合一' => '3.5mm
Combo Audio',
+ 'TF口3.0' => 'TF 3.0
Card Slot',
+ '全功能
USB-C' => 'All-in-One
USB-C',
+ "接口大满贯" => "Full-Featured Ports",
+ "酷睿i5-12450H" => "Core i5-12450H",
+ "锐龙9 6900HX" => "Ryzen9 6900HX",
+ "标配多种接口,会议室连接电脑、U盘传输文件、TF卡读取等,全都轻松搞定" => "Versatile Ports for Easy Connectivity. Effortlessly link to projectors, U disks, TF cards, and more.",
+ ],
+];
diff --git a/app/index/view/mobile/topic_laptop/index.html b/app/index/view/mobile/topic_laptop/index.html
index 6de16ef2..a56d293c 100644
--- a/app/index/view/mobile/topic_laptop/index.html
+++ b/app/index/view/mobile/topic_laptop/index.html
@@ -1,13 +1,1270 @@
{extend name="public/base" /}
{block name="style"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{/block}
-{block name="header"}
-
-{/block}
+
{block name="main"}
+{notempty name="data.top_focus_images"}
+
+
-
{$tfi.title}
-
{$tfi.desc||raw|htmlspecialchars_decode}
+
{$tfi.title}
+
{$tfi.desc|raw|htmlspecialchars_decode}
@@ -1045,14 +1040,14 @@
let videoTip = null;
// 初始化视频提示
- const initVideoTip = () => {
- if (!fsBox || videoTip) return;
- videoTip = document.createElement('div');
- videoTip.className = 'video-tip';
- videoTip.innerText = '点击播放视频';
- fsBox.appendChild(videoTip);
- videoTip.style.display = 'none';
- };
+ // const initVideoTip = () => {
+ // if (!fsBox || videoTip) return;
+ // videoTip = document.createElement('div');
+ // videoTip.className = 'video-tip';
+ // videoTip.innerText = '点击播放视频';
+ // fsBox.appendChild(videoTip);
+ // videoTip.style.display = 'none';
+ // };
// 停止所有视频
function stopAllVideos ()
@@ -1145,7 +1140,7 @@
};
// 初始化
- initVideoTip();
+ // initVideoTip();
window.addEventListener('scroll', handleVideoScroll, { passive: true });
window.addEventListener('resize', handleVideoResize);
// 页面加载完成后尝试播放
diff --git a/public/static/index/mobile/css/topic_laptop/amd.css b/public/static/index/mobile/css/topic_laptop/amd.css
new file mode 100644
index 00000000..cface428
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/amd.css
@@ -0,0 +1,40 @@
+.amd-box {
+ width: 100%;
+ position: relative;
+ max-width: 6.82rem;
+ margin: 0 auto;
+ margin-top: -0.86rem;
+ z-index: 1;
+}
+
+.amd-img-header,
+.amd-img-main,
+.amd-img-footer {
+ width: 100%;
+ max-width: 6.82rem;
+}
+.amd-img-main {
+ display: flex;
+ justify-content: space-between;
+}
+.amd-img-main-left,
+.amd-img-main-right {
+ flex: 1;
+ max-width: 3.36rem;
+ display: block;
+}
+.amd-img-main-left img,
+.amd-img-main-right img {
+ width: 3.36rem;
+ display: block;
+ margin-top: 0.1rem;
+}
+.amd-img-header img {
+ width: 6.82rem;
+ display: block;
+}
+.amd-img-footer img {
+ width: 6.82rem;
+ margin-top: 0.1rem;
+ display: block;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/bly.css b/public/static/index/mobile/css/topic_laptop/bly.css
new file mode 100644
index 00000000..dde850be
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/bly.css
@@ -0,0 +1,50 @@
+.bly {
+ margin: 0 0.34rem;
+ aspect-ratio: 682/338;
+ /* max-height: 6.97rem; */
+ padding-top: 1.5rem;
+}
+
+.bly .ba-slider .handle:after {
+ position: absolute;
+ top: 50%;
+ /* 1. 缩小宽高:从48px改为32px(可根据需求再调,比如28px/30px) */
+ width: 0.32rem;
+ height: 0.32rem;
+ /* 2. 同步调整margin:宽高的一半,保证居中(48px对应-24px,32px对应-16px) */
+ margin: -0.16rem 0 0 -0.16rem;
+ content: '';
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #fff
+ url('https://dev.ow.f2b211.com/static/index/pc/images/ba-arrow.png')
+ /* 3. 缩小背景箭头:从22px改为16px(匹配整体尺寸) */ center center /
+ 0.16rem 0.16rem no-repeat;
+ border: 1px solid #fff;
+ border-radius: 50%;
+ transition: all 0.3s ease;
+ transform: scale(1);
+ z-index: 5;
+ box-shadow: none;
+}
+
+.bly .ba-slider .handle.ba-draggable:after {
+ transform: scale(0.8);
+}
+.bly-t {
+ font-size: 0.48rem;
+ color: #fff;
+ text-align: center;
+ width: 100%;
+}
+.bly-p {
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ text-align: center;
+ width: 100%;
+ padding-top: 0.37rem;
+ padding-bottom: 0.56rem;
+ font-family: 'HarmonyOS-Light';
+ line-height: 1.5;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/cpu.css b/public/static/index/mobile/css/topic_laptop/cpu.css
new file mode 100644
index 00000000..a02eb452
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/cpu.css
@@ -0,0 +1,50 @@
+.cpu {
+ margin-top: 1.5rem;
+ width: 100%;
+ aspect-ratio: 750/882;
+ position: relative;
+}
+.cpu-main {
+ width: 100%;
+ aspect-ratio: 750/882;
+}
+.cpu-texts-t {
+ color: #fff;
+ font-size: 0.48rem;
+ line-height: 1;
+ text-align: center;
+ padding-top: 0.19rem;
+}
+.cpu-texts-p {
+ color: #cbcfd8;
+ font-size: 0.20rem;
+ margin-top: 0.37rem;
+ line-height: 1;
+ text-align: center;
+}
+
+.cpu-footer {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+.cpu-footer-img {
+ display: flex;
+ gap: 0.1rem;
+}
+.cpu-footer img {
+ width: 100%;
+ max-width: 1.48rem;
+ display: block;
+}
+.cpu-footer-desc {
+ font-size: 0.16rem;
+ color: #cbcfd8;
+ text-align: center;
+ margin-top: 0.38rem;
+ margin-bottom: 0.51rem;
+ width: 100%;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/endurance.css b/public/static/index/mobile/css/topic_laptop/endurance.css
new file mode 100644
index 00000000..4d0385a8
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/endurance.css
@@ -0,0 +1,59 @@
+.endurance {
+ position: relative;
+ width: 100%;
+ margin-top: 1.78rem;
+ aspect-ratio: 750/778;
+}
+.endurance-texts {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ position: absolute;
+ top: -0.4rem;
+}
+.endurance-t {
+ width: 100%;
+ font-size: 0.5rem;
+ color: #fff;
+ text-align: center;
+}
+.endurance-t span:nth-child(1) {
+ font-size: 0.4rem;
+ line-height: 1;
+}
+.endurance-t span:nth-child(2) {
+ font-size: 0.48rem;
+ margin-left: 0.11rem;
+ line-height: 1;
+}
+.endurance-p {
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ text-align: center;
+ padding-top: 0.37rem;
+ font-family: 'HarmonyOS-Light';
+}
+.endurance-img {
+ width: 100%;
+
+ position: absolute;
+ bottom: 0.62rem;
+ display: flex;
+ justify-content: center;
+ gap: 0.1rem;
+}
+.endurance-img img {
+ width: 2.46rem;
+ display: block;
+}
+.endurance-footer-p {
+ color: #ffffff;
+ font-size: 0.18rem;
+ position: absolute;
+ bottom: 0;
+ text-align: center;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 100%;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/footer.css b/public/static/index/mobile/css/topic_laptop/footer.css
new file mode 100644
index 00000000..ed6b16ef
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/footer.css
@@ -0,0 +1,161 @@
+.footer-imgs {
+ width: 6.8rem;
+ margin: 0 auto;
+ margin-top: 1.5rem;
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+.footer-imgs img {
+ width: 2.22rem;
+}
+.footer-imgs img:nth-child(4),
+.footer-imgs img:nth-child(5),
+.footer-imgs img:nth-child(6) {
+ margin-top: 0.09rem;
+}
+
+.footer-texts {
+ width: 6.8rem;
+ margin: 1.5rem 0.35rem;
+ /* font-size: 0.16rem; */
+ color: #909399;
+ /* white-space: normal; */
+ /* margin-top: 1.5rem;
+ margin-bottom: 1.5rem; */
+ box-sizing: border-box;
+}
+.footer-texts p {
+ width: 6.8rem;
+ font-size: 0.16rem;
+ padding-left: 0.1rem;
+ text-indent: -0.2rem;
+ /* line-height: 1.5;
+ margin-bottom: 0.1rem; */
+ font-family: 'HarmonyOS-Light';
+ letter-spacing: 1px;
+}
+
+
+.footer-texts-en {
+ width: 6.8rem;
+ margin: 1.5rem 0.35rem;
+ /* font-size: 0.16rem; */
+ color: #909399;
+ /* white-space: normal; */
+ /* margin-top: 1.5rem;
+ margin-bottom: 1.5rem; */
+ box-sizing: border-box;
+}
+.footer-texts-en p {
+ width: 6.8rem;
+ font-size: 0.16rem;
+ padding-left: 0.1rem;
+ text-indent: -0.1rem;
+ /* line-height: 1.5;
+ margin-bottom: 0.1rem; */
+ font-family: 'HarmonyOS-Light';
+ /* letter-spacing: 1px; */
+}
+
+
+.oircoEgapp-foot .logo-white img {
+ margin: 0 auto;
+ }
+ .oircoEgapp-foot .m_footer {
+ display: flex;
+ /* flex-direction: row; */
+ align-items: center;
+ justify-content: center;
+ margin:0 4%;
+ padding-top: 0 !important;
+ }
+ /* .oircoEgapp-foot .m_footer .left,
+ .oircoEgapp-foot .foot-con {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ } */
+ .oircoEgapp-foot .m_footer .right {
+ flex:1;
+ width: 50% !important;
+ display: flex !important;
+ justify-content: end !important;
+ /* max-width: 55%; */
+ }
+ .oircoEgapp-foot .m_footer .left {
+ flex:1 !important;
+ /* max-width: 40%;
+ justify-content: end;
+ margin-right: 4%; */
+ width: 50% !important;
+ display: flex;
+ justify-content: space-between;
+ }
+ .oircoEgapp-foot .foot-con span {
+ width: auto;
+ padding: 0 0.625rem;
+ }
+ .oircoEgapp-foot .foot-cate .clearfix li h3 {
+ margin-bottom: 10px;
+ }
+ .oircoEgapp-foot .foot-cate .clearfix li p,
+ .oircoEgapp-foot .foot-cate .clearfix li p a {
+ font-size: 12px;
+ }
+ .logo-white {
+ text-align: center;
+ padding: 1rem 0 0;
+ display: flex;
+ align-items: center;
+}
+.foot-cate {
+ padding: 0.16rem;
+}
+.foot-cate h3 {
+ font-size: 0.28rem;
+}
+.foot-cate li {
+ padding: 0.16rem 0;
+ min-height: 276px;
+
+}
+.foot-cate li p {
+ line-height: 40px;
+}
+.m_footer .right {
+ float: right;
+ width: 57%;
+ text-align: left;
+}
+.foot-con span {
+ font-size:14px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -webkit-line-clamp: 1;
+ width: 100%;
+ height: auto;
+ cursor: pointer;
+ font-family: 'HarmonyOS-Medium';
+}
+.oircoEgapp-foot .foot-con span {
+ width: auto;
+ padding: 0 0.125rem;
+}
+.m_footer .left a{
+ display: flex;
+ align-items: center;
+
+}
+.m_footer .left img {
+ width: 30px;
+ padding-right:0 !important;
+ padding-top: 0;
+}
+/* .oircoEgapp-foot .m_footer .right {
+ max-width: 50%;
+} */
+.m_footer .right {
+ padding: 0 !important;
+}
\ No newline at end of file
diff --git a/public/static/index/mobile/css/topic_laptop/fs.css b/public/static/index/mobile/css/topic_laptop/fs.css
new file mode 100644
index 00000000..6414f014
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/fs.css
@@ -0,0 +1,69 @@
+.fs {
+ width: 100%;
+}
+.fs-box {
+ width: 100%;
+ aspect-ratio: 16/9;
+}
+.fs-img,
+.fs-video {
+ backface-visibility: hidden; /* 开启硬件加速 */
+ transform: translateZ(0); /* 硬件加速 */
+}
+.fs-box img {
+ width: 100%;
+}
+.fs-box-img {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.fs-h-img {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ gap: 0.11rem;
+
+}
+.fs-h-img img {
+ width: 3.35rem;
+ aspect-ratio: 335/95;
+
+}
+.fs-b-img {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin-top: 0.1rem;
+ aspect-ratio: 681/122;
+}
+.fs-b-img img {
+ width: 6.81rem;
+}
+.fs-ts {
+ font-size: 0.18rem;
+ text-align: center;
+ color: #cbcfd8;
+ padding: 0.4rem 0;
+}
+.dl {
+ width: 100%;
+ padding-top: 1.01rem;
+}
+.dl-t {
+ color: #fff;
+ font-size: 0.48rem;
+ width: 100%;
+ text-align: center;
+ line-height: 1.5;
+}
+.dl-p {
+ color: #cbcfd8;
+ font-size: 0.20rem;
+ width: 100%;
+ text-align: center;
+ padding-top: 0.37rem;
+ /* padding-bottom: 0.37rem; */
+ line-height: 1.5;
+ font-family: 'HarmonyOS-Light';
+}
diff --git a/public/static/index/mobile/css/topic_laptop/gpu.css b/public/static/index/mobile/css/topic_laptop/gpu.css
new file mode 100644
index 00000000..cf62eb91
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/gpu.css
@@ -0,0 +1,44 @@
+.gpu {
+ width: 100%;
+ position: relative;
+ box-sizing: border-box;
+}
+.gpu-texts {
+ color: #fff;
+ font-size: 0.48rem;
+ margin: 0 0.34rem;
+}
+.gpu-texts-t {
+ line-height: 1.5;
+}
+
+.gpu-texts-p {
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ line-height: 1.5;
+ margin-top: 0.37rem;
+}
+.gpu-main-img {
+ width: 100%;
+ aspect-ratio: 750/780;
+}
+.gpu-amd-img {
+ width: 100%;
+}
+.gpu-amd-img img {
+ max-width: 2.26rem;
+ width: 100%;
+ margin-top: 2.39rem;
+ margin-left: 0.45rem;
+}
+.gpu-footer-imgs {
+ display: flex;
+ position: absolute;
+ bottom: 0.61rem;
+ gap: 0.1rem;
+ left: 50%;
+ transform: translateX(-50%);
+}
+.gpu-footer-imgs img {
+ max-width: 1.63rem;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/header.css b/public/static/index/mobile/css/topic_laptop/header.css
new file mode 100644
index 00000000..1d1c4c96
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/header.css
@@ -0,0 +1,233 @@
+.oircoEgapp-head {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: auto;
+ position: fixed;
+ top: 0;
+ z-index: 1000;
+ width: -webkit-fill-available;
+ }
+ .oircoEgapp-head .headtop {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 0.16rem;
+ height:60px;
+ background: #fff;
+ }
+ .oircoEgapp-head .headtop .headerlogimg {
+
+ max-width: 140px;
+ min-width: 123px;
+ height: auto !important;
+ display: block;
+ /* height: 2.25rem; */
+ }
+ .oircoEgapp-head .top-menu {
+ background-color: #fff;
+ overflow: hidden;
+ width: 100%;
+ color: #000;
+ display: none;
+ }
+ .oircoEgapp-head .top-menu .it-ct {
+ font-family: "HarmonyOS-SemiBold";
+ font-weight: bold;
+ }
+ .oircoEgapp-head .top-menu .it-ct .it-1 {
+ padding: 0.16rem 3.5% 0.2rem;
+ border-bottom: 1px solid #e5e5e5;
+ font-size: 14px;
+ position: relative;
+ }
+ .oircoEgapp-head .top-menu .it-ct .it-1-more {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ padding-bottom: 1.5%;
+ }
+ .oircoEgapp-head .top-menu .it-ct .it-1-more i {
+ font-weight: bold;
+ }
+ .oircoEgapp-head .top-menu .it-ct .it-1-2 {
+ padding-left: 0.16rem;
+ font-size: 13px;
+ line-height: 1.8;
+ font-weight: 100;
+ font-weight: bold;
+ display: none;
+ }
+ .oircoEgapp-head .top-menu .it-ct .it-1-2 a {
+ color: #666;
+ }
+ .cursor_p span {
+ font-size: 22px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -webkit-line-clamp: 1;
+ width: 100%;
+ /* height: 1.2rem; */
+ padding-left: 0.2rem;
+ }
+ .cursor_p span:hover {
+ font-size:22px;
+ white-space: normal;
+ width: 100%;
+ overflow: inherit;
+ line-height: 1.5;
+ }
+/* .cursor_p {
+ height: 0.38rem !important;
+} */
+.img-responsive {
+ display: flex !important;
+ justify-content: center !important;
+ align-items: center !important;
+}
+.img-responsive span {
+ display: flex !important;
+ align-items: center !important;
+}
+.action-sheet {
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ display: none;
+ background: #fff;
+ width: 100%;
+ position: fixed;
+ bottom: 0;
+ z-index: 11111;
+ left: 0;
+ }
+
+ .menu-name {
+ text-indent: 0.2rem;
+ font-size: 14px;
+ font-weight: 700;
+ color: #000;
+ }
+ .action-sheet ul li {
+ margin: 0 10px;
+ color: #333;
+ font-size: 12px;
+ text-align: left;
+ border-bottom: 1px solid #eee;
+ padding: 12px 14px;
+ }
+ .action-sheet ul li a {
+ color: #333;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
+ .action-sheet ul li img {
+ margin-right: 14px;
+ }
+ .menu-title {
+ display: flex;
+ align-items: center;
+ margin: 0 10px;
+ justify-content: space-between;
+ background: #fff;
+ border-top-left-radius: 6px;
+ border-top-right-radius:6px;
+ padding: 5px 0;
+ }
+ .close-icon {
+ width: 24px;
+ display: flex;
+ align-items: center;
+ }
+ .close-icon img {
+ width: 24px !important;
+ }
+ .title-text {
+ padding-top: 0.24rem;
+ color: #333;
+ font-size: 14px;
+ }
+ .title-text p {
+ line-height: 2;
+ }
+ .title-text p a {
+ color: #989898;
+
+ }
+ .title-text p a:hover {
+ color: #989898;
+ }
+ .marsk-container {
+ background: rgba(0, 0, 0, 0.8);
+ display: none;
+ position: absolute;
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0px;
+ z-index: 9999;
+ }
+ .marsk-container-detail {
+ background: rgba(0, 0, 0, 0.8);
+ display: none;
+ position: absolute;
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0px;
+ z-index: 9999;
+ }
+ .search-in {
+ margin-top:0.8rem;
+ }
+ .search-in input {
+ width: 70% !important;
+ height: 0.8rem;
+ background: #ffffff;
+ border: 1px solid #d6d6d6;
+ opacity: 1;
+ border-radius: 20px;
+ padding: 0 14px;
+ font-size: 14px;
+ }
+ ::-webkit-input-placeholder {
+ color: #989898;
+ }
+ .search-button {
+ border: none;
+ font-size: 14px;
+ color: #fff;
+ position: absolute;
+ right: 10px;
+ cursor: pointer;
+ background: #0060ff;
+ height:0.7rem;
+ border-radius: 20px;
+ padding: 0 14px;
+ width: 22%;
+ }
+ .search-in form{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
+ .search-in .title-text p a{
+ font-size: 14px;
+ }
+ /*头部搜索弹窗*/
+.popup-quick {
+ width: 86%;
+ background-color: #fff;
+ z-index: 9999;
+ box-shadow: 0px 2px 5px rgba(255, 255, 255, 0.2);
+ color: #333;
+ padding: 14px;
+ border-radius: 5px;
+ margin: 14px auto 0;
+ position: relative;
+ margin-top: 1rem;
+ }
\ No newline at end of file
diff --git a/public/static/index/mobile/css/topic_laptop/index.css b/public/static/index/mobile/css/topic_laptop/index.css
new file mode 100644
index 00000000..347ee8e7
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/index.css
@@ -0,0 +1,42 @@
+* {
+ margin: 0;
+ padding: 0;
+}
+html {
+ width: 100% !important;
+ overflow-x: hidden;
+ margin: 0 !important;
+ padding: 0 !important;
+ max-width: 100vw !important;
+}
+body {
+ width: 100%;
+ background: #000;
+ overflow-x: hidden;
+ margin: 0 !important;
+ padding: 0 !important;
+ max-width: 100vw !important;
+}
+
+.f36{
+ font-size: 0.36rem !important;
+ font-family: "HarmonyOS-Medium" !important;
+ /* line-height: 1 !important; */
+}
+.f18{
+ font-size: 0.18rem !important;
+ line-height: 1.5 !important;
+}
+.lh1 {
+ line-height: 1 !important;
+}
+.f28 {
+ font-size: 0.28rem !important;
+ font-family: "HarmonyOS-Medium" !important;
+}
+.f48 {
+ font-size: 0.48rem !important;
+ line-height: 1 !important;
+ margin-bottom: 0.6rem !important;
+ font-family: "HarmonyOS-Medium" !important;
+}
\ No newline at end of file
diff --git a/public/static/index/mobile/css/topic_laptop/ips.css b/public/static/index/mobile/css/topic_laptop/ips.css
new file mode 100644
index 00000000..3c84437c
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/ips.css
@@ -0,0 +1,43 @@
+.ips {
+ position: relative;
+}
+.ips-t {
+ font-size: 0.48rem;
+ color: #fff;
+ line-height: 1.5;
+ margin-top: 1.5rem;
+ margin-left: 0.38rem;
+ margin-right: 0.38rem;
+ margin-bottom: 0.46rem;
+}
+.ips-bg {
+ aspect-ratio: 750/678;
+ position: relative;
+}
+.ips-bg p {
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ line-height: 1.5;
+ margin-left: 0.38rem;
+ margin-right: 0.38rem;
+ position: absolute;
+ top: -0.3rem;
+}
+.ips-imgs {
+ width: 6.8rem;
+ flex-wrap: wrap;
+ gap: 0.1rem;
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: -3rem;
+ display: flex;
+}
+.ips-imgs img {
+ width: 3.35rem;
+ display: block;
+}
+/* .ips-imgs img:nth-child(3),
+.ips-imgs img:nth-child(4) {
+ margin-top: 0.1rem;
+} */
diff --git a/public/static/index/mobile/css/topic_laptop/m2.css b/public/static/index/mobile/css/topic_laptop/m2.css
new file mode 100644
index 00000000..35b6d8ba
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/m2.css
@@ -0,0 +1,37 @@
+.m2 {
+ width: 100%;
+ margin-top: 1.09rem;
+}
+.m2-bg {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 750/846;
+}
+
+.m2-bg-t1 {
+ font-size: 0.48rem;
+ color: #fff;
+ width: 100%;
+ text-align: center;
+ line-height: 1.5;
+ padding-top: 1.3rem;
+}
+.m2-bg-p {
+ color: #CBCFD8ff;
+ font-size: 0.18rem;
+ padding-top:0.37rem;
+ width: 100%;
+ text-align: center;
+
+}
+.m2-img-box {
+ position: absolute;
+ bottom: 0.66rem;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ gap: 0.11rem;
+}
+.m2-img-box img {
+ width: 2.22rem;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/memory.css b/public/static/index/mobile/css/topic_laptop/memory.css
new file mode 100644
index 00000000..9a0c5204
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/memory.css
@@ -0,0 +1,39 @@
+.memory {
+ width: 100%;
+}
+.memory-t {
+ font-size: 0.48rem;
+ color: #fff;
+ width: 100%;
+ text-align: center;
+ padding-bottom: 0.37rem;
+ line-height: 1.5;
+}
+.memory-img {
+ width: 100%;
+ aspect-ratio: 750/727;
+ position: relative;
+}
+.memory-p {
+ width: 100%;
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ text-align: center;
+ line-height: 1.5;
+}
+.memory-footer-img {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: -0.74rem;
+ width: 6.83rem;
+}
+.memory-footer-img p {
+ height:0;
+}
+.memory-footer-img img {
+ width: 6.83rem;
+
+ display: block;
+ aspect-ratio: 683/148;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/progress.css b/public/static/index/mobile/css/topic_laptop/progress.css
new file mode 100644
index 00000000..05bfda76
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/progress.css
@@ -0,0 +1,129 @@
+.progress {
+ margin-left: 0.34rem;
+ margin-right: 0.34rem;
+ box-sizing: border-box;
+}
+/* .progress-section {
+ margin-left: 0.34rem;
+ margin-right: 0.34rem;
+ box-sizing: border-box;
+} */
+
+.progress-title {
+ font-size: 0.24rem;
+ color: #fff;
+ padding: 0.16rem 0 0.36rem 0;
+ font-family: 'HarmonyOS-Medium';
+}
+.progress-item .label {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ line-height: 1;
+ margin-top: 0.13rem;
+ margin-bottom: 0.36rem;
+}
+
+.progress-item .label .device-name {
+ flex: 1;
+ font-family: 'HarmonyOS-Medium';
+ margin-left: 0.04rem;
+}
+
+.progress-item .label .value {
+ text-align: right;
+ font-family: 'HarmonyOS-Medium';
+ margin-right: 0.04rem;
+}
+
+.colorLinearGradient {
+ background: linear-gradient(90deg, #7e51ff, #d5dfff);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+.progress-item .label .value.m4-max {
+ color: #5e5ce6;
+}
+
+.progress-bar {
+ flex: 1;
+ height: 0.1rem;
+ max-height: 0.2rem;
+ min-height: 0.1rem;
+ border-radius: 0.1rem;
+ overflow: hidden;
+ position: relative;
+ /* 性能优化1:开启硬件加速,减少重绘 */
+ transform: translateZ(0);
+ will-change: transform;
+}
+
+.progress-fill {
+ height: 100%;
+ border-radius: 0.05rem;
+ transform: scaleX(0);
+ transform-origin: left center;
+ transition: transform 1.2s ease-out;
+ will-change: transform;
+ position: relative;
+}
+
+/* 保留你所有原始的进度条颜色类 */
+.progress-fill.m4-max {
+ background: linear-gradient(40deg, #7e51ff, #e1d5ff);
+}
+
+.progress-fill.m2-max {
+ background: #bfc5d9;
+}
+
+@keyframes shimmer {
+ 100% {
+ left: 150%;
+ }
+}
+
+.font20 {
+ font-size: 0.2rem !important;
+}
+.font18 {
+ font-size: 0.18rem !important;
+}
+.colorCBCFD8 {
+ color: #646778 !important;
+}
+
+.progress-p {
+ color: #cbcfd8;
+ font-size: 0.18rem;
+ margin-top: 0.51rem;
+ margin-bottom: 1.5rem;
+ text-align: center;
+}
+
+.progress-p1 {
+ color: #cbcfd8;
+ font-size: 0.18rem;
+ margin-top: 1.56rem;
+ text-align: center;
+}
+.w645 {
+ width: 6.45rem;
+}
+.w585 {
+ width: 5.85rem;
+}
+.w454 {
+ width: 4.54rem;
+}
+.w321 {
+ width: 3.21rem;
+}
+.w596 {
+ width: 5.96rem;
+}
+.w467 {
+ width: 4.67rem;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/qb.css b/public/static/index/mobile/css/topic_laptop/qb.css
new file mode 100644
index 00000000..1d2e74b5
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/qb.css
@@ -0,0 +1,25 @@
+/* .qb {
+ position: absolute;
+ top:3rem;
+ left: 50%;
+ transform: translateX(-50%);
+} */
+.qb-t {
+ width: 100%;
+ font-size: 0.48rem;
+ color: #fff;
+ text-align: center;
+ padding-top: 1.5rem;
+}
+.qb-p {
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ text-align: center;
+ padding-top: 0.37rem;
+ padding-bottom: 0.34rem;
+ font-family: 'HarmonyOS-Light';
+ line-height: 1.5;
+}
+.qb-bg-img1 {
+ width: 6.9rem;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/rgb.css b/public/static/index/mobile/css/topic_laptop/rgb.css
new file mode 100644
index 00000000..a6c98a92
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/rgb.css
@@ -0,0 +1,28 @@
+.rgb-t {
+ font-size: 0.48rem;
+ color: #fff;
+ width: 100%;
+ text-align: center;
+ margin-top: 4.84rem;
+ margin-bottom: 0.37rem;
+ line-height: 1.5;
+}
+.rgb-bg {
+ aspect-ratio: 750/582;
+}
+.rgb-p {
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ line-height: 1.5;
+ text-align: center;
+}
+.rgb-imgs {
+ display: flex;
+ gap: 0.1rem;
+ margin-top: 0.28rem;
+ justify-content: center;
+}
+.rgb-imgs img {
+ width: 2.17rem;
+ display: block;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/swiper.css b/public/static/index/mobile/css/topic_laptop/swiper.css
new file mode 100644
index 00000000..58a505c8
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/swiper.css
@@ -0,0 +1,86 @@
+/* 轮播容器 - 核心:基于视口高度自适应 */
+.auto-swiper-container {
+ width: 100%;
+ overflow: hidden;
+ position: relative;
+ z-index: 1;
+}
+
+/* 轮播项 - 填充容器高度 */
+.auto-swiper-slide {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 图片自适应核心:填充屏幕比例高度,保持比例 */
+.auto-swiper-slide img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover; /* 优先填充容器,裁剪超出部分(无拉伸) */
+ /* object-fit: contain; 可选:完整显示图片,不裁剪(可能留黑边) */
+ display: block;
+}
+.swiper-container-texts {
+ position: absolute;
+ left: 50%;
+ top: 1.2rem;
+ transform: translateX(-50%);
+ z-index: 10;
+ width: 100%;
+}
+
+.swiper-container-texts-t {
+ font-size: 0.48rem;
+ text-align: center;
+ width: 100%;
+ line-height: 1;
+}
+.swiper-container-texts-p {
+ padding-top: 0.54rem;
+ font-size: 0.16rem;
+ /* text-align: center; */
+ width: 100%;
+ display: flex;
+ align-items: center;
+ /* letter-spacing: px; */
+ justify-content: center;
+}
+.swiper-container-texts-p div {
+ font-family: 'HarmonyOS-Light';
+}
+.swiper-container-texts-line {
+ width: 1px;
+ height: 0.16rem;
+ background: #fff;
+ margin: 0 12px;
+}
+.swiper-container-texts-img {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ padding-top: 0.54rem;
+ cursor: pointer;
+}
+.swiper-container-texts-img a {
+ width: 100%;
+ max-width: 1.18rem;
+}
+.swiper-container-texts-img img {
+ width: 100%;
+ max-width: 1.18rem;
+}
+.swiper-slide-t {
+ padding-top: 0.3rem;
+ font-size: 0.2rem;
+ text-align: center;
+ color: #fff;
+}
+.swiper-slide-p {
+ font-size: 0.16rem;
+ color: #cbcfd8;
+ text-align: center;
+ margin-top: 0.18rem;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/tabs.css b/public/static/index/mobile/css/topic_laptop/tabs.css
new file mode 100644
index 00000000..ed9f9213
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/tabs.css
@@ -0,0 +1,105 @@
+.tabs-container {
+ width: 6.03rem;
+ margin: 0 auto;
+}
+.tabs-header-box {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin-top: 0.48rem;
+}
+.tabs-header {
+ display: inline-flex;
+ /* 改为inline-flex,宽度由子元素决定 */
+ position: relative;
+ border-bottom: 1px solid #909399;
+ margin: 0 auto;
+ width: 1.75rem;
+ display: flex;
+}
+
+.tab-item {
+ /* padding: 12px 24px; */
+ font-size: 0.18rem;
+ cursor: pointer;
+ transition: color 0.2s ease;
+ white-space: nowrap;
+ color: #cbcfd8;
+ flex: 1;
+ text-align: center;
+}
+
+.tab-item {
+ margin-bottom: 0.08rem;
+}
+
+.tab-indicator {
+ position: absolute;
+ bottom: 0;
+ height:1px;
+ background-color: #fff;
+ transform: translateX(0);
+ width: auto;
+ transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1),
+ width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
+ will-change: transform, width;
+ backface-visibility: hidden;
+ perspective: 1000px;
+}
+
+.tab-content {
+ width: 6.03rem;
+ width: 6.03rem;
+ /* min-width: 1280px; */
+ margin: 0 auto;
+}
+
+.content-video {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+}
+.tabs-p {
+ width: 100%;
+ font-size: 0.18rem;
+ color: #cbcfd8;
+ text-align: center;
+ margin-top: 0.48rem;
+ display: none;
+}
+.tabs-p.active {
+ display: block;
+}
+.tab-panel {
+ display: none;
+}
+.tab-panel-img {
+ background: #1c1c1e;
+ width: 5.77rem;
+ overflow: hidden;
+ display: flex;
+ justify-content: flex-end;
+ border-radius: 0.16rem;
+}
+.tab-panel-img img {
+ width: 5.77rem;
+}
+.tab-panel.active {
+ display: block;
+}
+.tab-t {
+ font-size: 0.48rem;
+ color: #cbcfd8;
+ width: 100%;
+ text-align: center;
+ margin-top: 1.5rem;
+ margin-bottom: 0.7rem;
+ display: none;
+}
+.tab-t.active {
+ display: block;
+}
+/* .tab-ts {
+ margin-top: 2.97rem;
+ margin-bottom: 1rem;
+} */
diff --git a/public/static/index/mobile/css/topic_laptop/wift.css b/public/static/index/mobile/css/topic_laptop/wift.css
new file mode 100644
index 00000000..7ceed630
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/wift.css
@@ -0,0 +1,37 @@
+.wifi {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 750/595;
+ margin-top: 3.45rem;
+}
+.wifi-texts {
+ position: absolute;
+ top: -2.3rem;
+ left: 0.42rem;
+ width: 100%;
+}
+.wifi-t {
+ width: 100%;
+ font-size: 0.48rem;
+ color: #fff;
+ line-height: 1.5;
+}
+.wifi-p {
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ padding-top: 0.37rem;
+ line-height: 1.4;
+ font-family: 'HarmonyOS-Light';
+}
+.wifi-img {
+ width: 100%;
+ position: absolute;
+ bottom: -0.22rem;
+ display: flex;
+ justify-content: center;
+ gap: 0.1rem;
+}
+.wifi-img img {
+ width: 3.35rem;
+ display: block;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/window.css b/public/static/index/mobile/css/topic_laptop/window.css
new file mode 100644
index 00000000..d843f6de
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/window.css
@@ -0,0 +1,9 @@
+.window {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin-top: 1.5rem;
+}
+.window img {
+ width: 6.8rem;
+}
diff --git a/public/static/index/mobile/css/topic_laptop/xn.css b/public/static/index/mobile/css/topic_laptop/xn.css
new file mode 100644
index 00000000..b033071f
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/xn.css
@@ -0,0 +1,64 @@
+.xn-container {
+ width: 6.8rem;
+ margin: 0 auto;
+ box-sizing: border-box;
+}
+
+/* 图片容器样式(核心) */
+.xn-image-section {
+ overflow: hidden;
+ border-radius: 0.16rem;
+ background: #0d0c10;
+ border: 1px solid #3f3f45;
+ width: 6.8rem;
+}
+
+.zoom-image {
+ width: 100%;
+ height: auto;
+ display: block;
+ /* 调整transform过渡时间为2s,opacity为1.5s,可按需修改 */
+ transition: transform 2s cubic-bezier(0.25, 0.1, 0.25, 1), opacity 1.5s ease;
+ transform: scale(0.8);
+ /* 初始缩放比例 */
+ opacity: 0.9;
+ /* 初始透明度(略暗,放大后变亮) */
+}
+
+/* 滚动触发后的放大状态 */
+.zoom-image.active {
+ transform: scale(1.1);
+ /* 放大5%(官网常用比例,不夸张) */
+ opacity: 1;
+}
+.xn-t {
+ font-size: 0.48rem;
+ color: #fff;
+ text-align: center;
+ margin-top: 1.5rem;
+ margin-bottom: 0.6rem;
+
+}
+.xn-p {
+ padding-top: 0.6rem;
+ font-size: 0.16rem;
+ margin-left: 0.47rem;
+}
+.xn-p p {
+ width: 5.84rem;
+ color: #cbcfd8;
+ line-height: 1.5;
+ /* 核心:左内边距控制第二行缩进量 */
+ padding-left: 0.8rem;
+ /* 首行向左偏移,抵消左内边距,实现第二行缩进 */
+ text-indent: -0.8rem;
+ /* 确保p标签是块级,且换行正常 */
+ display: block;
+ word-wrap: break-word;
+ white-space: nowrap;
+ /* 2. 超出元素宽度的内容隐藏 */
+ overflow: hidden;
+ /* 3. 将超出的文本替换为省略号... */
+ text-overflow: ellipsis;
+ /* 可选:给元素设置一个固定宽度(确保省略效果生效) */
+}
\ No newline at end of file
diff --git a/public/static/index/mobile/css/topic_laptop/zoom.css b/public/static/index/mobile/css/topic_laptop/zoom.css
new file mode 100644
index 00000000..a41814f3
--- /dev/null
+++ b/public/static/index/mobile/css/topic_laptop/zoom.css
@@ -0,0 +1,122 @@
+/* 图片容器:100vw宽,最小宽度1440px,按图片原始比例(2560:1857)定高 */
+.zoom-container {
+ width: 7.5rem;
+ height: 6.05rem;
+ position: relative;
+}
+
+/* 图片包裹层:与容器同尺寸,定位参考系,承接缩放变换 */
+.img-wrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ /* z-index: -10; */
+}
+
+/* 图片:按原始比例填充包裹层,不裁切,初始放大+过渡动画 */
+.bg-img {
+ width: 7.5rem;
+ height: 6.05rem;
+ display: block;
+ transform: scale(1.5);
+ transition: transform 1.8s ease;
+ transform-origin: center center;
+}
+
+/* 图片缩小后的状态 */
+.bg-img.zoom-out {
+ transform: scale(1);
+}
+
+/* 标注样式:关键修正 - 基于容器绝对定位,百分比参考图片原始比例 */
+.annotation {
+ position: absolute;
+ color: #fff;
+ font-size: calc(12px + 0.3vw);
+ opacity: 0;
+ transform: translateY(calc(10px + 0.5vw));
+ transition: opacity 0.8s ease, transform 0.8s ease;
+ pointer-events: none;
+ z-index: 10;
+ white-space: nowrap;
+ /* 关键:标注的定位参考系是容器(与图片同比例),而非缩放后的图片 */
+ top: 0;
+ left: 0;
+ /* 重置默认值,依赖内联style的百分比定位 */
+}
+
+/* 标注显示状态 */
+.annotation.anno-show {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.annotation span {
+ position: relative;
+ display: inline-block;
+ /* 确保文字居中对齐 */
+ text-align: center;
+}
+
+/* 标注线条样式:基于文字定位,适配缩放 */
+.annotation span::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0.01rem;
+ height: 0;
+ bottom: calc(100% + 6px);
+ background-color: #fff;
+ transition: height 0.8s ease;
+ transform-origin: bottom center;
+}
+/* 向上延伸的标注线条(给需要向上的.annotation加anno-up类) */
+.annotation.anno-up span::before {
+ /* 把线条位置从文字下方,改成文字上方 */
+ bottom: auto;
+ top: 0.3rem; /* 定位到文字顶部外 */
+ /* 线条方向改为向上延伸 */
+ transform-origin: top center;
+}
+.annotation.anno-up1 span::before {
+ /* 把线条位置从文字下方,改成文字上方 */
+ bottom: auto;
+ top: 0.5rem; /* 定位到文字顶部外 */
+ /* 线条方向改为向上延伸 */
+ transform-origin: top center;
+}
+/* 标注显示时,设置线条最终高度 */
+.annotation.anno-show span::before {
+ height: 0.57rem;
+}
+
+.zoom-t {
+ width: 100%;
+ text-align: center;
+ font-size: 0.48rem;
+ color: #fff;
+ padding-top: 1.5rem;
+}
+.zoom-p {
+ width: 100%;
+ text-align: center;
+ font-size: 0.20rem;
+ color: #cbcfd8;
+ position: absolute;
+ top: 0.37rem;
+ z-index: 10;
+ line-height: 1.5;
+ font-family: 'HarmonyOS-Light';
+}
+
+/* 标注延迟类 */
+.anno-delay-1 {
+ transition-delay: 0.8s;
+}
+.anno-delay-1 span::before {
+ transition-delay: 0.8s;
+}
diff --git a/public/static/index/mobile/images/topic_laptop/zoom-img-1.png b/public/static/index/mobile/images/topic_laptop/zoom-img-1.png
new file mode 100644
index 00000000..5fbae73b
Binary files /dev/null and b/public/static/index/mobile/images/topic_laptop/zoom-img-1.png differ
diff --git a/public/static/index/pc/css/topic_laptop/fs.css b/public/static/index/pc/css/topic_laptop/fs.css
index 60c04e64..3abe7376 100644
--- a/public/static/index/pc/css/topic_laptop/fs.css
+++ b/public/static/index/pc/css/topic_laptop/fs.css
@@ -54,4 +54,23 @@
text-align: center;
color:#cbcfd8;
padding:0.4rem 0;
-}
\ No newline at end of file
+}.dl {
+ width: 100%;
+ padding-top: 0.71rem;
+}
+.dl-t {
+ color: #fff;
+ font-size: 0.63rem;
+ width: 100%;
+ text-align: center;
+}
+.dl-p {
+ color: #cbcfd8;
+ font-size: clamp(16px, 1vw, 0.22rem);
+ width: 100%;
+ text-align: center;
+ padding-top: 0.43rem;
+ /* padding-bottom: 0.37rem; */
+ line-height: 1.8;
+ font-family: "HarmonyOS-Light";
+}
diff --git a/scripts/QUICK_START.md b/scripts/QUICK_START.md
new file mode 100644
index 00000000..e479a5c7
--- /dev/null
+++ b/scripts/QUICK_START.md
@@ -0,0 +1,186 @@
+# 图片迁移脚本快速开始指南
+
+## 概述
+这是一个通过SSH将本地图片迁移到远程服务器的Python脚本。支持保持相对路径结构,自动创建目录,批量处理图片文件。
+
+## 环境要求
+- Python 3.6+
+- paramiko库 (SSH客户端库)
+
+## 快速开始
+
+### 1. 安装依赖
+```bash
+cd /var/www/html/orico-official-website/scripts
+pip install -r requirements.txt
+```
+
+### 2. 创建配置文件
+```bash
+# 创建示例配置文件
+python image_migrate.py --create-config config.json
+```
+
+编辑生成的 `config.json` 文件,填写目标服务器信息:
+- 修改 `target.ssh` 部分的目标服务器地址、用户名、密码/密钥
+- 设置 `source.base_dir` 为本地图片目录路径
+
+### 3. 准备图片路径列表
+创建 `images.txt` 文件,每行一个相对路径:
+```txt
+uploads/products/2024/01/product1.jpg
+uploads/products/2024/01/product2.jpg
+assets/images/logo.png
+uploads/banners/main-banner.jpg
+```
+
+### 4. 执行迁移
+```bash
+python image_migrate.py --config config.json --input images.txt --verbose
+```
+
+## 配置文件示例(本地源)
+```json
+{
+ "source": {
+ "type": "local",
+ "base_dir": "/var/www/html/orico-official-website/public/uploads"
+ },
+ "target": {
+ "type": "ssh",
+ "base_dir": "/var/www/html/images",
+ "ssh": {
+ "host": "your-server.com",
+ "port": 22,
+ "username": "deploy",
+ "password": "your_password",
+ "key_file": "/path/to/private/key"
+ }
+ }
+}
+```
+
+## 常用命令
+
+### 基本迁移
+```bash
+python image_migrate.py --config config.json --input images.txt
+```
+
+### 详细输出模式
+```bash
+python image_migrate.py --config config.json --input images.txt --verbose
+```
+
+### 覆盖已存在的文件
+```bash
+python image_migrate.py --config config.json --input images.txt --overwrite
+```
+
+### 直接从命令行指定文件
+```bash
+python image_migrate.py --config config.json \
+ "uploads/test1.jpg" \
+ "uploads/test2.jpg" \
+ "assets/logo.png"
+```
+
+### 不使用配置文件,直接指定参数
+```bash
+python image_migrate.py \
+ --source-type local \
+ --source-dir /path/to/local/images \
+ --target-host server.example.com \
+ --target-user username \
+ --target-dir /remote/path/images \
+ --input images.txt \
+ --verbose
+```
+
+## 常见场景
+
+### 场景1:迁移网站上传目录
+```bash
+# 配置文件中的源目录设置为:
+"source": {
+ "type": "local",
+ "base_dir": "/var/www/html/orico-official-website/public/uploads"
+}
+
+# 执行迁移
+python image_migrate.py --config config.json --input uploads_list.txt
+```
+
+### 场景2:从数据库导出路径并迁移
+```bash
+# 从数据库导出图片路径
+mysql -u username -p database -e "SELECT image_path FROM products" --skip-column-names > products.txt
+
+# 清理路径(如果需要)
+sed -i 's/^\.\///' products.txt
+
+# 迁移图片
+python image_migrate.py --config config.json --input products.txt --verbose
+```
+
+### 场景3:迁移整个目录结构
+```bash
+# 使用find命令生成所有图片文件列表
+find /path/to/local/images -type f \( -name "*.jpg" -o -name "*.png" -o -name "*.gif" -o -name "*.webp" \) | sed 's|^/path/to/local/images/||' > all_images.txt
+
+# 迁移所有图片
+python image_migrate.py --config config.json --input all_images.txt
+```
+
+## 路径说明
+- **相对路径**:相对于配置文件中指定的 `base_dir`
+- **示例**:如果 `base_dir` 是 `/var/www/html/images`,相对路径 `uploads/test.jpg` 对应完整路径 `/var/www/html/images/uploads/test.jpg`
+- **目标路径**:在目标服务器上保持相同的相对路径结构
+
+## 故障排查
+
+### 1. 连接失败
+```bash
+# 测试SSH连接
+ssh -p 22 username@server.example.com
+
+# 检查配置文件中的主机、端口、用户名是否正确
+```
+
+### 2. 文件不存在
+```bash
+# 检查本地文件是否存在
+ls -la /var/www/html/orico-official-website/public/uploads/uploads/test.jpg
+
+# 使用详细模式查看完整路径
+python image_migrate.py --config config.json --input images.txt --verbose
+```
+
+### 3. 权限不足
+```bash
+# 检查本地文件读取权限
+ls -la /path/to/local/images
+
+# 检查目标目录写入权限(通过SSH)
+ssh username@server.example.com "ls -la /remote/path/images"
+```
+
+## 注意事项
+1. 建议使用SSH密钥认证而非密码(更安全)
+2. 首次使用前,建议用少量文件测试
+3. 大文件传输可能需要较长时间,建议分批处理
+4. 使用 `--verbose` 参数可查看详细传输信息
+5. 脚本会自动创建目标服务器上的目录结构
+
+## 获取帮助
+```bash
+# 查看完整帮助
+python image_migrate.py --help
+
+# 查看详细使用说明
+cat README.md
+```
+
+---
+*快速开始指南更新日期:2024年*
+*脚本位置:/var/www/html/orico-official-website/scripts/*
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 00000000..1466fc1b
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,415 @@
+# 图片迁移脚本 (Image Migration Script)
+
+从本地目录或SSH服务器迁移图片到远程SSH服务器,保持相对路径结构一致。
+
+## 功能特性
+
+- ✅ **灵活的源支持**:支持本地目录(`local`)和SSH服务器(`ssh`)作为源
+- ✅ **远程目标**:通过SSH迁移到远程服务器
+- ✅ **路径保持**:在目标服务器上保持相同的相对路径和文件名
+- ✅ **配置灵活**:支持配置文件和命令行参数
+- ✅ **目录自动创建**:递归创建目标目录结构
+- ✅ **重复文件处理**:默认跳过已存在文件,支持覆盖选项
+- ✅ **进度统计**:显示详细的传输进度和统计信息
+- ✅ **错误处理**:完善的连接管理和错误处理
+
+## 环境要求
+
+- Python 3.6+
+- paramiko 库(用于SSH连接)
+
+## 快速开始
+
+### 1. 安装依赖
+
+```bash
+cd /var/www/html/orico-official-website/scripts
+pip install -r requirements.txt
+```
+
+### 2. 创建配置文件
+
+```bash
+# 创建示例配置文件
+python image_migrate.py --create-config config.json
+
+# 编辑配置文件,填写真实的服务器信息
+vim config.json
+```
+
+### 3. 准备图片路径列表
+
+创建一个文本文件(如 `images.txt`),每行一个相对路径:
+
+```
+uploads/products/2024/01/product1.jpg
+uploads/products/2024/01/product2.jpg
+assets/images/logo.png
+```
+
+### 4. 执行迁移
+
+#### 从本地目录迁移到远程服务器(最常用)
+
+```bash
+# 使用配置文件迁移(源为本地目录)
+python image_migrate.py --config config.json --input images.txt
+
+# 或直接指定参数迁移(源为本地目录)
+python image_migrate.py \
+ --source-type local --source-dir /path/to/local/images \
+ --target-host dst.example.com --target-user user2 --target-dir /home/user/images \
+ --input images.txt --verbose
+```
+
+#### 从SSH服务器迁移到另一个SSH服务器
+
+```bash
+# 使用配置文件迁移(源为SSH服务器)
+python image_migrate.py --config config.json --input images.txt
+
+# 或直接指定参数迁移(源为SSH服务器)
+python image_migrate.py \
+ --source-type ssh --source-host src.example.com --source-user user1 --source-dir /var/www/images \
+ --target-host dst.example.com --target-user user2 --target-dir /home/user/images \
+ --input images.txt --verbose
+```
+
+## 配置文件格式
+
+配置文件为JSON格式,支持两种源类型配置:
+
+### 本地目录作为源(推荐)
+
+```json
+{
+ "source": {
+ "type": "local",
+ "base_dir": "/path/to/local/images"
+ },
+ "target": {
+ "type": "ssh",
+ "base_dir": "/var/www/html/images",
+ "ssh": {
+ "host": "target-server.example.com",
+ "port": 22,
+ "username": "username",
+ "password": "your_password_here",
+ "key_file": "/path/to/private/key"
+ }
+ }
+}
+```
+
+### SSH服务器作为源
+
+```json
+{
+ "source": {
+ "type": "ssh",
+ "base_dir": "/var/www/html/images",
+ "ssh": {
+ "host": "source-server.example.com",
+ "port": 22,
+ "username": "username",
+ "password": "your_password_here",
+ "key_file": "/path/to/private/key"
+ }
+ },
+ "target": {
+ "type": "ssh",
+ "base_dir": "/var/www/html/images",
+ "ssh": {
+ "host": "target-server.example.com",
+ "port": 22,
+ "username": "username",
+ "password": "your_password_here",
+ "key_file": "/path/to/private/key"
+ }
+ }
+}
+```
+
+**注意:**
+
+- `type`:可以是 `"local"`(本地目录)或 `"ssh"`(SSH服务器)
+- `base_dir`:图片的基础目录,图片的相对路径将相对于这个目录
+- 对于SSH类型,需要提供`ssh`配置部分
+- `password` 和 `key_file` 二选一即可
+- 目标服务器**必须**是SSH类型
+
+## 命令行参数
+
+### 主要选项
+
+| 参数 | 说明 |
+| ---------------------- | ---------------------- |
+| `--config FILE` | 使用配置文件 |
+| `--create-config FILE` | 创建示例配置文件 |
+| `--input FILE` | 包含图片路径列表的文件 |
+| `--verbose`, `-v` | 显示详细输出 |
+| `--overwrite` | 覆盖已存在的文件 |
+
+### 源服务器参数(可覆盖配置文件)
+
+| 参数 | 说明 |
+| ------------------------ | ------------------------------------------------------------ |
+| `--source-type TYPE` | 源类型: `local`(本地目录) 或 `ssh`(SSH服务器),默认: `local` |
+| `--source-dir DIR` | **必需** 源服务器图片基础目录 |
+| `--source-host HOST` | 源服务器地址(仅SSH类型需要) |
+| `--source-port PORT` | 源服务器端口(仅SSH类型,默认: 22) |
+| `--source-user USER` | 源服务器用户名(仅SSH类型需要) |
+| `--source-password PASS` | 源服务器密码(仅SSH类型需要) |
+| `--source-key FILE` | 源服务器私钥文件路径(仅SSH类型需要) |
+
+### 目标服务器参数(可覆盖配置文件)
+
+| 参数 | 说明 |
+| ------------------------ | ------------------------------------ |
+| `--target-host HOST` | **必需** 目标服务器地址(必须是SSH) |
+| `--target-port PORT` | 目标服务器端口(默认: 22) |
+| `--target-user USER` | **必需** 目标服务器用户名 |
+| `--target-password PASS` | 目标服务器密码 |
+| `--target-key FILE` | 目标服务器私钥文件路径 |
+| `--target-dir DIR` | **必需** 目标服务器图片基础目录 |
+
+## 使用示例
+
+### 示例1:从本地目录迁移到远程服务器(推荐)
+
+```bash
+# 1. 创建配置文件
+python image_migrate.py --create-config myconfig.json
+
+# 2. 编辑配置文件
+# 将 source.type 设置为 "local"
+# 设置 source.base_dir 为本地目录路径
+# 填写目标服务器的SSH信息
+
+# 3. 创建图片路径文件
+echo "uploads/test.jpg" > images.txt
+echo "assets/logo.png" >> images.txt
+
+# 4. 执行迁移
+python image_migrate.py --config myconfig.json --input images.txt
+```
+
+### 示例2:直接从命令行迁移本地图片
+
+```bash
+python image_migrate.py \
+ --source-type local \
+ --source-dir /home/user/my_website/images \
+ --target-host 192.168.1.200 \
+ --target-user deploy \
+ --target-key ~/.ssh/id_rsa \
+ --target-dir /var/www/html/images \
+ --input images.txt \
+ --verbose
+```
+
+### 示例3:从SSH服务器迁移到另一个SSH服务器
+
+```bash
+python image_migrate.py \
+ --source-type ssh \
+ --source-host old-server.example.com \
+ --source-user admin \
+ --source-key ~/.ssh/id_rsa \
+ --source-dir /var/www/images \
+ --target-host new-server.example.com \
+ --target-user deploy \
+ --target-dir /home/deploy/images \
+ --input images.txt \
+ --overwrite
+```
+
+### 示例4:直接从命令行指定图片路径
+
+```bash
+python image_migrate.py --config config.json \
+ "uploads/product1.jpg" \
+ "uploads/product2.jpg" \
+ "assets/logo.png"
+```
+
+### 示例5:批量迁移数据库导出的图片路径
+
+```bash
+# 从数据库导出图片路径(假设每行一个路径)
+mysql -e "SELECT image_path FROM products" --skip-column-names > products.txt
+
+# 清理路径(如果需要)
+sed -i 's/^\.\///' products.txt
+
+# 迁移图片
+python image_migrate.py --config config.json --input products.txt --verbose
+```
+
+## 图片路径格式
+
+图片路径应为**相对路径**,相对于配置文件中指定的 `base_dir`:
+
+```
+# 正确示例(相对路径)
+uploads/products/2024/01/product1.jpg
+assets/images/logo.png
+
+# 错误示例(绝对路径)
+/var/www/html/images/uploads/product1.jpg # 错误!应该使用相对路径
+
+# 在目标服务器上,文件将保存为:
+# 目标 base_dir + 相对路径
+```
+
+## 工作流程
+
+1. **连接建立**:连接到目标SSH服务器(源为本地时不需要连接)
+2. **路径处理**:为每个图片构建完整源路径和目标路径
+3. **目录创建**:在目标服务器上创建必要的目录结构
+4. **文件检查**:检查源文件是否存在,目标文件是否已存在(跳过)
+5. **文件传输**:通过本地临时文件传输(源→本地临时文件→目标)
+6. **统计报告**:显示传输结果和统计信息
+
+## 故障排除
+
+### 常见问题
+
+#### 1. 连接失败
+
+```
+错误: 连接失败: [Errno 111] Connection refused
+```
+
+- 检查目标服务器地址和端口
+- 确认SSH服务正在运行
+- 检查防火墙设置
+
+#### 2. 认证失败
+
+```
+错误: Authentication failed.
+```
+
+- 确认用户名和密码正确
+- 检查SSH密钥权限(chmod 600 ~/.ssh/id_rsa)
+- 确认密钥已添加到目标服务器的 authorized_keys
+
+#### 3. 源文件不存在
+
+```
+错误: 源文件不存在: /path/to/local/images/uploads/test.jpg
+```
+
+- 确认相对路径正确
+- 确认源基础目录正确
+- 使用 `--verbose` 查看完整路径
+
+#### 4. 权限不足
+
+```
+错误: Permission denied
+```
+
+- 确认用户有读取源文件的权限(本地文件)
+- 确认用户有写入目标目录的权限
+
+#### 5. 目录创建失败
+
+```
+错误: 无法创建目标目录: /var/www/html/images/uploads
+```
+
+- 检查目标目录的写入权限
+- 确认目标服务器磁盘空间充足
+
+### 调试技巧
+
+1. **使用详细模式**:添加 `--verbose` 参数查看详细信息
+2. **测试连接**:手动SSH连接测试目标服务器是否可达
+3. **检查路径**:在源目录上确认文件存在
+4. **查看日志**:脚本会显示详细的错误信息
+
+## 性能优化
+
+### 大文件传输
+
+- 脚本使用流式传输,适用于大文件
+- 临时文件存储在系统临时目录,传输完成后自动清理
+
+### 批量传输
+
+- 对于大量文件,建议分批处理
+- 可以使用多个图片路径文件,分别执行迁移
+
+### 网络优化
+
+- 确保到目标服务器的网络连接稳定
+- 考虑使用内网传输以提高速度
+
+## 安全注意事项
+
+1. **认证方式**:建议使用SSH密钥认证而非密码
+2. **权限控制**:配置文件建议设置权限 `chmod 600 config.json`
+3. **最小权限**:使用具有最小必要权限的账户执行迁移
+4. **本地文件安全**:确保本地目录不包含敏感信息
+5. **临时文件清理**:脚本会自动清理临时传输文件
+
+## 扩展和定制
+
+### 修改脚本
+
+- 可以在 `ImageMigrator` 类中添加额外的功能
+- 支持断点续传、并行传输等高级功能
+
+### 集成到工作流
+
+- 可以与其他脚本结合使用
+- 可以作为CI/CD流程的一部分
+
+### 添加进度条
+
+```python
+# 可选:安装 tqdm 库
+pip install tqdm
+
+# 在脚本中添加进度条显示
+```
+
+## 文件结构
+
+```
+scripts/
+├── image_migrate.py # 主脚本
+├── image_migrate_backup.py # 原始版本备份
+├── requirements.txt # Python依赖
+├── README.md # 本文档
+└── examples/ # 示例文件
+ ├── config.example.json # 配置文件示例
+ └── images.example.txt # 图片路径示例
+```
+
+## 版本更新
+
+### v2.0 更新
+
+- ✅ 新增本地目录(`local`)作为源的支持
+- ✅ 默认源类型改为`local`
+- ✅ 简化配置文件结构
+- ✅ 改进错误处理和临时文件管理
+
+## 许可证
+
+本脚本根据项目需要自由使用和修改。
+
+## 支持
+
+如有问题,请:
+
+1. 检查本文档的故障排除部分
+2. 使用 `--verbose` 参数获取详细信息
+3. 确认服务器配置正确
+
+---
+
+**提示**:首次使用前,建议在测试服务器上进行小规模测试。
diff --git a/scripts/config.json b/scripts/config.json
new file mode 100644
index 00000000..208e7c97
--- /dev/null
+++ b/scripts/config.json
@@ -0,0 +1,24 @@
+{
+ "source": {
+ "type": "local",
+ "base_dir": "public/storage",
+ "ssh": {
+ "host": "source-server.example.com",
+ "port": 22,
+ "username": "username",
+ "password": "your_password_here",
+ "key_file": ""
+ }
+ },
+ "target": {
+ "type": "ssh",
+ "base_dir": "/www/wwwroot/orico-official-website/public/storage",
+ "ssh": {
+ "host": "47.91.149.172",
+ "port": 22,
+ "username": "root",
+ "password": "Orico666tx5d",
+ "key_file": ""
+ }
+ }
+}
diff --git a/scripts/examples/config.example.json b/scripts/examples/config.example.json
new file mode 100644
index 00000000..20e6d098
--- /dev/null
+++ b/scripts/examples/config.example.json
@@ -0,0 +1,24 @@
+{
+ "source": {
+ "type": "local",
+ "base_dir": "/var/www/html/orico-official-website/public/storage",
+ "ssh": {
+ "host": "source-server.example.com",
+ "port": 22,
+ "username": "username",
+ "password": "your_password_here",
+ "key_file": "/path/to/private/key"
+ }
+ },
+ "target": {
+ "type": "ssh",
+ "base_dir": "/www/wwwroot/orico-official-website/public/storage",
+ "ssh": {
+ "host": "47.91.149.172",
+ "port": 22,
+ "username": "root",
+ "password": "Orico666tx5d",
+ "key_file": ""
+ }
+ }
+}
diff --git a/scripts/examples/images.example.txt b/scripts/examples/images.example.txt
new file mode 100644
index 00000000..554e5345
--- /dev/null
+++ b/scripts/examples/images.example.txt
@@ -0,0 +1,12 @@
+# 图片路径示例文件
+# 每行一个相对路径(相对于配置文件中指定的基础目录)
+# 空行和以 # 开头的行会被忽略
+# 路径使用正斜杠 /,即使在Windows系统上
+
+# 注意:这些路径都是相对路径
+# 实际文件在源服务器上的完整路径是:源基础目录 + 相对路径
+# 例如:如果源基础目录是 /var/www/html/images
+# 那么 uploads/products/2024/01/product1.jpg 对应的完整路径是:
+# /var/www/html/images/uploads/products/2024/01/product1.jpg
+
+images/article/logo.png
diff --git a/scripts/image_migrate.py b/scripts/image_migrate.py
new file mode 100755
index 00000000..1d0fed97
--- /dev/null
+++ b/scripts/image_migrate.py
@@ -0,0 +1,922 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+图片迁移脚本 - 通过SSH或本地目录迁移图片到远程服务器
+保持相对路径结构一致
+"""
+
+import argparse
+import json
+import os
+import shutil
+import sys
+import tempfile
+import time
+from typing import Any, Dict, List, Optional, Tuple
+
+# paramiko 导入被延迟到实际需要时
+paramiko: Any = None
+
+
+def ensure_paramiko() -> None:
+ """确保paramiko已导入,如果未安装则报错"""
+ global paramiko
+ if paramiko is None:
+ try:
+ import paramiko as paramiko_module
+
+ paramiko = paramiko_module
+ except ImportError:
+ print("错误: 需要安装paramiko库")
+ print("请运行: pip install paramiko")
+ sys.exit(1)
+
+
+class ServerConfig:
+ """服务器配置(支持SSH或本地目录)"""
+
+ def __init__(
+ self,
+ config_type: str = "ssh", # "ssh" 或 "local"
+ host: str = "",
+ port: int = 22,
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ key_file: Optional[str] = None,
+ base_dir: Optional[str] = None,
+ ):
+ """
+ 初始化服务器配置
+
+ Args:
+ config_type: 配置类型,'ssh' 表示SSH服务器,'local' 表示本地目录
+ host: 服务器地址(对于local类型可为None)
+ port: 服务器端口(仅SSH类型)
+ username: 用户名(仅SSH类型)
+ password: 密码(仅SSH类型)
+ key_file: 私钥文件路径(仅SSH类型)
+ base_dir: 基础目录路径
+ """
+ self.config_type = config_type
+ self.host = host
+ self.port = port
+ self.username = username
+ self.password = password
+ self.key_file = key_file
+ self.base_dir = base_dir.rstrip("/") if base_dir else ""
+
+ def to_dict(self) -> Dict:
+ """转换为字典"""
+ return {
+ "type": self.config_type,
+ "host": self.host,
+ "port": self.port,
+ "username": self.username,
+ "password": self.password,
+ "key_file": self.key_file,
+ "base_dir": self.base_dir,
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict) -> "ServerConfig":
+ """从字典创建"""
+ ssh_data = data.get("ssh", {})
+ return cls(
+ config_type=data.get("type", "ssh"),
+ host=ssh_data.get("host", "localhost"),
+ port=ssh_data.get("port", 22),
+ username=ssh_data.get("username", ""),
+ password=ssh_data.get("password", ""),
+ key_file=ssh_data.get("key_file", ""),
+ base_dir=data.get("base_dir", ""),
+ )
+
+ def is_local(self) -> bool:
+ """是否是本地目录配置"""
+ return self.config_type == "local"
+
+ def __str__(self) -> str:
+ if self.is_local():
+ return f"LocalDirectory(base_dir={self.base_dir})"
+ else:
+ return f"SSHServer(host={self.host}:{self.port}, user={self.username}, base_dir={self.base_dir})"
+
+
+class ImageMigrator:
+ """图片迁移器"""
+
+ def __init__(
+ self,
+ source_config: ServerConfig,
+ target_config: ServerConfig,
+ verbose: bool = False,
+ overwrite: bool = False,
+ ) -> None:
+ """
+ 初始化迁移器
+
+ Args:
+ source_config: 源服务器配置(可以是本地目录或SSH服务器)
+ target_config: 目标服务器配置(必须为SSH服务器)
+ verbose: 是否显示详细输出
+ overwrite: 是否覆盖已存在的文件
+ """
+ self.source_config = source_config
+ self.target_config = target_config
+ self.verbose = verbose
+ self.overwrite = overwrite
+
+ # SSH客户端连接(仅用于SSH类型的服务器)
+ self.source_client: Any = None
+ self.target_client: Any = None
+ self.source_sftp: Any = None
+ self.target_sftp: Any = None
+
+ # 统计信息
+ self.stats = {
+ "total": 0,
+ "success": 0,
+ "failed": 0,
+ "skipped": 0,
+ "bytes_transferred": 0,
+ }
+
+ def connect(self) -> bool:
+ """连接到服务器"""
+ try:
+ # 确保paramiko已导入
+ ensure_paramiko()
+
+ # 连接目标服务器(必须为SSH)
+ if self.target_config.is_local():
+ print("错误: 目标服务器必须为SSH服务器,不能是本地目录")
+ return False
+
+ self.target_client = paramiko.SSHClient()
+ self.target_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+
+ if self.verbose:
+ print(
+ f"连接到目标服务器: {self.target_config.host}:{self.target_config.port}"
+ )
+
+ if self.target_config.key_file:
+ key = paramiko.RSAKey.from_private_key_file(self.target_config.key_file)
+
+ self.target_client.connect(
+ hostname=self.target_config.host,
+ port=self.target_config.port,
+ username=self.target_config.username,
+ pkey=key,
+ )
+ else:
+ self.target_client.connect(
+ hostname=self.target_config.host,
+ port=self.target_config.port,
+ username=self.target_config.username,
+ password=self.target_config.password,
+ )
+
+ self.target_sftp = self.target_client.open_sftp()
+
+ # 检查SFTP服务器的工作目录
+ if self.verbose:
+ try:
+ cwd = self.target_sftp.getcwd()
+ print(f"DEBUG: 目标SFTP服务器当前工作目录: {cwd}")
+ except Exception as e:
+ print(f"DEBUG: 无法获取目标SFTP服务器工作目录: {e}")
+
+ # 连接源服务器(如果是SSH类型)
+ if not self.source_config.is_local():
+ self.source_client = paramiko.SSHClient()
+ self.source_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+
+ if self.verbose:
+ print(
+ f"连接到源服务器: {self.source_config.host}:{self.source_config.port}"
+ )
+
+ if self.source_config.key_file:
+ key = paramiko.RSAKey.from_private_key_file(
+ self.source_config.key_file
+ )
+
+ self.source_client.connect(
+ hostname=self.source_config.host,
+ port=self.source_config.port,
+ username=self.source_config.username,
+ pkey=key,
+ )
+ else:
+ self.source_client.connect(
+ hostname=self.source_config.host,
+ port=self.source_config.port,
+ username=self.source_config.username,
+ password=self.source_config.password,
+ )
+
+ self.source_sftp = self.source_client.open_sftp()
+
+ if self.verbose:
+ source_type = (
+ "本地目录" if self.source_config.is_local() else "SSH服务器"
+ )
+ print(f"连接成功! 源: {source_type}, 目标: SSH服务器")
+ return True
+
+ except Exception as e:
+ print(f"连接失败: {e}")
+ self.close()
+ return False
+
+ def close(self) -> None:
+ """关闭所有连接"""
+ if self.source_sftp:
+ self.source_sftp.close()
+ if self.source_client:
+ self.source_client.close()
+ if self.target_sftp:
+ self.target_sftp.close()
+ if self.target_client:
+ self.target_client.close()
+
+ if self.verbose:
+ print("连接已关闭")
+
+ def ensure_target_directory(self, remote_dir: str) -> bool:
+ """确保目标目录存在(递归创建)"""
+ try:
+ if self.verbose:
+ print(f"DEBUG ensure_target_directory: 检查/创建目录: {remote_dir}")
+
+ # 确保路径以斜杠开头(绝对路径)
+ if not remote_dir.startswith("/"):
+ remote_dir = "/" + remote_dir
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 转换为绝对路径: {remote_dir}"
+ )
+
+ # 首先检查目录是否已经存在
+ try:
+ # 尝试列出目录内容
+ self.target_sftp.listdir(remote_dir)
+ if self.verbose:
+ print(f"DEBUG ensure_target_directory: 目录已存在: {remote_dir}")
+ return True
+ except (FileNotFoundError, IOError):
+ # 目录不存在,需要递归创建
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 目录不存在,开始递归创建: {remote_dir}"
+ )
+
+ # 分割路径为各个组件,保留空字符串以处理根目录
+ parts = [p for p in remote_dir.split("/") if p]
+
+ # 从根目录开始逐级创建
+ current_path = ""
+ for i, part in enumerate(parts):
+ # 构建当前路径(确保以斜杠开头)
+ if current_path:
+ current_path = f"{current_path}/{part}"
+ else:
+ current_path = f"/{part}"
+
+ # 检查当前路径是否存在
+ try:
+ self.target_sftp.listdir(current_path)
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 路径已存在: {current_path}"
+ )
+ except (FileNotFoundError, IOError):
+ # 当前路径不存在,尝试创建
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 创建目录: {current_path}"
+ )
+ try:
+ self.target_sftp.mkdir(current_path)
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 目录创建成功: {current_path}"
+ )
+ except Exception as mkdir_e:
+ # 如果创建失败,可能是权限问题或目录已存在
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 创建目录时出错 {current_path}: {mkdir_e}"
+ )
+ # 尝试检查目录是否真的不存在
+ try:
+ self.target_sftp.listdir(current_path)
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 目录实际上已存在: {current_path}"
+ )
+ except Exception as listdir_e:
+ # 目录确实不存在且创建失败
+ print(f"错误: 无法创建目录 {current_path}: {listdir_e}")
+ return False
+
+ # 最终确认目录是否创建成功
+ try:
+ self.target_sftp.listdir(remote_dir)
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 目录创建完成: {remote_dir}"
+ )
+ return True
+ except Exception as final_check_e:
+ if self.verbose:
+ print(
+ f"DEBUG ensure_target_directory: 最终检查失败 {remote_dir}: {final_check_e}"
+ )
+ return False
+ except Exception as e:
+ print(f"创建目录失败 {remote_dir}: {e}")
+ if self.verbose:
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+ def get_source_file_info(self, source_path: str) -> Tuple[bool, Optional[int]]:
+ """
+ 获取源文件信息
+
+ Returns:
+ Tuple[exists: bool, size: Optional[int]]
+ """
+ try:
+ if self.source_config.is_local():
+ # 本地文件
+ if self.verbose:
+ print(f"DEBUG: 检查本地文件路径: {source_path}")
+ print(f"DEBUG: 当前工作目录: {os.getcwd()}")
+ print(f"DEBUG: 绝对路径: {os.path.abspath(source_path)}")
+ print(f"DEBUG: 文件是否存在: {os.path.exists(source_path)}")
+
+ if os.path.exists(source_path):
+ if self.verbose:
+ print(f"DEBUG: 文件大小: {os.path.getsize(source_path)} bytes")
+ return True, os.path.getsize(source_path)
+ else:
+ if self.verbose:
+ print("DEBUG: 文件不存在!")
+ return False, 0
+ else:
+ # SSH远程文件
+ source_attr = self.source_sftp.stat(source_path)
+ return True, source_attr.st_size
+
+ except Exception as e:
+ if self.verbose:
+ print(f"DEBUG: 获取文件信息时出错: {e}")
+ return False, 0
+
+ def read_source_file(self, source_path: str, temp_path: str) -> bool:
+ """
+ 读取源文件到临时文件
+
+ Returns:
+ bool: 是否成功
+ """
+ try:
+ if self.source_config.is_local():
+ # 从本地文件复制
+ if self.verbose:
+ print("DEBUG read_source_file: 开始复制文件")
+ print(f"DEBUG read_source_file: 源路径: {source_path}")
+ print(f"DEBUG read_source_file: 临时路径: {temp_path}")
+ print(
+ f"DEBUG read_source_file: 源文件是否存在: {os.path.exists(source_path)}"
+ )
+ print(
+ f"DEBUG read_source_file: 源文件绝对路径: {os.path.abspath(source_path)}"
+ )
+
+ shutil.copy2(source_path, temp_path)
+
+ if self.verbose:
+ print("DEBUG read_source_file: 复制完成")
+ print(
+ f"DEBUG read_source_file: 临时文件是否存在: {os.path.exists(temp_path)}"
+ )
+ if os.path.exists(temp_path):
+ print(
+ f"DEBUG read_source_file: 临时文件大小: {os.path.getsize(temp_path)} bytes"
+ )
+
+ return True
+ else:
+ # 从SSH服务器下载
+ self.source_sftp.get(source_path, temp_path)
+
+ return True
+ except Exception as e:
+ with open("fails.txt", "a", encoding="utf-8") as file:
+ file.write(f"{source_path}\n")
+ print(f"读取源文件失败 {source_path}: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+ def transfer_file(self, relative_path: str) -> bool:
+ """
+ 传输单个文件
+
+ Args:
+ relative_path: 相对路径(相对于基础目录)
+
+ Returns:
+ bool: 是否成功
+ """
+ try:
+ # 构建完整路径
+ source_path = f"{self.source_config.base_dir}/{relative_path}"
+ target_path = f"{self.target_config.base_dir}/{relative_path}"
+
+ if self.verbose:
+ source_type = "本地" if self.source_config.is_local() else "远程"
+ print(f"传输: {relative_path}")
+ print(f" 源({source_type}): {source_path}")
+ print(f" 目标(远程): {target_path}")
+
+ # 检查源文件是否存在并获取大小
+ source_exists, file_size = self.get_source_file_info(source_path)
+ if not source_exists:
+ print(f"错误: 源文件不存在: {source_path}")
+ self.stats["failed"] += 1
+ return False
+
+ # 检查目标文件是否已存在(根据overwrite选项处理)
+ try:
+ self.target_sftp.stat(target_path)
+
+ if not self.overwrite:
+ if self.verbose:
+ print(f"跳过: 目标文件已存在: {target_path}")
+ self.stats["skipped"] += 1
+ return True
+ else:
+ if self.verbose:
+ print(f"覆盖: 目标文件已存在: {target_path}")
+ except FileNotFoundError:
+ if self.verbose:
+ print(f"DEBUG: 目标文件不存在,将创建新文件: {target_path}")
+ except Exception as e:
+ if self.verbose:
+ print(f"DEBUG: 检查目标文件时出错: {e}")
+
+ # 确保目标目录存在
+ target_dir = os.path.dirname(target_path)
+ if self.verbose:
+ print(f"DEBUG: 目标目录: {target_dir}")
+ print(f"DEBUG: 目标路径: {target_path}")
+ if target_dir and not self.ensure_target_directory(target_dir):
+ print(f"错误: 无法创建目标目录: {target_dir}")
+ self.stats["failed"] += 1
+ return False
+
+ # 传输文件
+ start_time = time.time()
+ temp_path = None
+
+ try:
+ # 创建临时文件
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
+ temp_path = temp_file.name
+
+ if self.verbose:
+ print(f"DEBUG: 创建临时文件: {temp_path}")
+
+ # 读取源文件到临时文件
+ if not self.read_source_file(source_path, temp_path):
+ self.stats["failed"] += 1
+ return False
+
+ # 上传到目标服务器
+ if self.verbose:
+ print(f"DEBUG: 开始上传到目标服务器: {temp_path} -> {target_path}")
+ print(f"DEBUG: 临时文件大小: {os.path.getsize(temp_path)} bytes")
+ print(f"DEBUG: 临时文件是否存在: {os.path.exists(temp_path)}")
+ # 尝试列出目标目录内容
+ try:
+ target_dir = os.path.dirname(target_path)
+ dir_list = self.target_sftp.listdir(target_dir)
+ print(f"DEBUG: 目标目录内容: {dir_list}")
+ except Exception as e:
+ print(f"DEBUG: 无法列出目标目录内容 {target_dir}: {e}")
+ # 尝试检查目录是否存在
+ try:
+ self.target_sftp.stat(target_dir)
+ print(f"DEBUG: 但目录stat成功: {target_dir}")
+ except Exception as stat_e:
+ print(f"DEBUG: 目录stat也失败: {stat_e}")
+
+ self.target_sftp.put(temp_path, target_path)
+
+ if self.verbose:
+ print("DEBUG: 上传完成")
+
+ except Exception as e:
+ if self.verbose:
+ print(f"DEBUG: 上传过程中出错: {e}")
+ import traceback
+
+ traceback.print_exc()
+ raise
+ finally:
+ # 清理临时文件
+ if temp_path and os.path.exists(temp_path):
+ try:
+ if self.verbose:
+ print(f"DEBUG: 清理临时文件: {temp_path}")
+ os.remove(temp_path)
+ except Exception as e:
+ if self.verbose:
+ print(f"DEBUG: 清理临时文件时出错: {e}")
+
+ # 记录统计
+ transfer_time = time.time() - start_time
+ speed = (file_size or 0) / transfer_time / 1024 if transfer_time > 0 else 0
+
+ if self.verbose:
+ print(
+ f" 完成: {file_size} bytes, 耗时: {transfer_time:.2f}s, 速度: {speed:.2f} KB/s"
+ )
+
+ self.stats["success"] += 1
+ self.stats["bytes_transferred"] += file_size or 0
+ return True
+
+ except Exception as e:
+ print(f"传输失败 {relative_path}: {e}")
+ if self.verbose:
+ import traceback
+
+ traceback.print_exc()
+ self.stats["failed"] += 1
+ return False
+
+ def migrate_images(self, image_paths: List[str]) -> Dict[str, int]:
+ """
+ 迁移图片列表
+
+ Args:
+ image_paths: 相对路径列表
+
+ Returns:
+ Dict: 统计信息
+ """
+ if not self.connect():
+ return self.stats
+
+ try:
+ self.stats["total"] = len(image_paths)
+
+ source_type = "本地目录" if self.source_config.is_local() else "SSH服务器"
+ print(f"开始从{source_type}迁移 {len(image_paths)} 个图片到SSH服务器...")
+
+ for i, relative_path in enumerate(image_paths, 1):
+ # 清理路径
+ relative_path = relative_path.strip()
+ if not relative_path:
+ continue
+
+ # 显示进度
+ print(f"[{i}/{len(image_paths)}] {relative_path}", end="")
+
+ # 传输文件
+ success = self.transfer_file(relative_path)
+
+ if success:
+ print(" ✓")
+ else:
+ print(" ✗")
+
+ # 打印统计信息
+ print("\n" + "=" * 50)
+ print("迁移完成!")
+ print(f"总计: {self.stats['total']}")
+ print(f"成功: {self.stats['success']}")
+ print(f"失败: {self.stats['failed']}")
+ print(f"跳过: {self.stats['skipped']}")
+ print(f"传输字节: {self.stats['bytes_transferred']:,}")
+
+ return self.stats
+
+ finally:
+ self.close()
+
+
+def load_config(config_file: str) -> Dict:
+ """加载配置文件"""
+ try:
+ with open(config_file, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ return config
+ except Exception as e:
+ print(f"加载配置文件失败 {config_file}: {e}")
+ return {}
+
+
+def save_config(config_file: str, config: Dict):
+ """保存配置文件"""
+ try:
+ with open(config_file, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=2, ensure_ascii=False)
+ print(f"配置文件已保存: {config_file}")
+ except Exception as e:
+ print(f"保存配置文件失败 {config_file}: {e}")
+
+
+def create_example_config(config_file: str):
+ """创建示例配置文件"""
+ example_config = {
+ "source": {
+ "type": "local", # 可以是 "local" 或 "ssh"
+ "base_dir": "/path/to/local/images",
+ # 如果是SSH类型,需要以下配置
+ "ssh": {
+ "host": "source-server.example.com",
+ "port": 22,
+ "username": "username",
+ "password": "your_password_here",
+ "key_file": "/path/to/private/key",
+ },
+ },
+ "target": {
+ "type": "ssh", # 目标必须是SSH类型
+ "base_dir": "/var/www/html/images",
+ "ssh": {
+ "host": "target-server.example.com",
+ "port": 22,
+ "username": "username",
+ "password": "your_password_here",
+ "key_file": "/path/to/private/key",
+ },
+ },
+ }
+
+ save_config(config_file, example_config)
+ print("已创建示例配置文件")
+ print("注意: 源服务器可以是本地目录(local)或SSH服务器(ssh)")
+ print(" 目标服务器必须是SSH服务器(ssh)")
+
+
+def read_image_paths(image_list_file: str) -> List[str]:
+ """从文件读取图片路径列表"""
+ paths = []
+ not_images = []
+ try:
+ with open(image_list_file, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith("#"):
+ if not line.endswith((".png", ".jpeg", ".jpg", ".gif", ".webp")):
+ not_images.append(line)
+ else:
+ paths.append(line)
+ print(f"从文件中读取到 {len(paths)} 个图片路径数据行")
+ print(f"从文件中读取到 {len(not_images)} 个非图片路径数据行")
+ except Exception as e:
+ print(f"读取图片路径文件失败 {image_list_file}: {e}")
+ return paths
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="从本地目录或SSH服务器迁移图片到远程SSH服务器,保持相对路径结构",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+示例:
+ # 使用配置文件迁移(源为本地目录)
+ python image_migrate.py --config config.json --input images.txt
+
+ # 使用配置文件迁移(源为SSH服务器)
+ # 配置文件中 source.type="ssh"
+
+ # 直接指定参数迁移(源为本地目录)
+ python image_migrate.py \\
+ --source-type local --source-dir /path/to/local/images \\
+ --target-host dst.example.com --target-user user2 --target-dir /home/user/images \\
+ --input images.txt
+
+ # 直接指定参数迁移(源为SSH服务器)
+ python image_migrate.py \\
+ --source-type ssh --source-host src.example.com --source-user user1 --source-dir /var/www/images \\
+ --target-host dst.example.com --target-user user2 --target-dir /home/user/images \\
+ --input images.txt
+
+ # 创建示例配置文件
+ python image_migrate.py --create-config config.json
+
+ # 从命令行直接指定图片路径
+ python image_migrate.py --config config.json images/product1.jpg images/product2.jpg
+ """,
+ )
+
+ # 配置文件选项
+ parser.add_argument("--config", help="配置文件路径")
+ parser.add_argument("--create-config", metavar="FILE", help="创建示例配置文件")
+
+ # 源服务器选项
+ parser.add_argument(
+ "--source-type",
+ choices=["local", "ssh"],
+ default="local",
+ help="源服务器类型: local(本地目录) 或 ssh(SSH服务器),默认: local",
+ )
+ parser.add_argument("--source-host", help="源服务器地址(仅SSH类型需要)")
+ parser.add_argument(
+ "--source-port",
+ type=int,
+ default=22,
+ help="源服务器端口(仅SSH类型,默认: 22)",
+ )
+ parser.add_argument("--source-user", help="源服务器用户名(仅SSH类型需要)")
+ parser.add_argument("--source-password", help="源服务器密码(仅SSH类型需要)")
+ parser.add_argument("--source-key", help="源服务器私钥文件路径(仅SSH类型需要)")
+ parser.add_argument("--source-dir", help="源服务器图片基础目录")
+
+ # 目标服务器选项
+ parser.add_argument("--target-host", help="目标服务器地址")
+ parser.add_argument(
+ "--target-port", type=int, default=22, help="目标服务器端口(默认: 22)"
+ )
+ parser.add_argument("--target-user", help="目标服务器用户名")
+ parser.add_argument("--target-password", help="目标服务器密码")
+ parser.add_argument("--target-key", help="目标服务器私钥文件路径")
+ parser.add_argument("--target-dir", help="目标服务器图片基础目录")
+
+ # 输入选项
+ parser.add_argument(
+ "--input", "-i", help="包含图片路径列表的文件(每行一个相对路径)"
+ )
+ parser.add_argument("--verbose", "-v", action="store_true", help="显示详细输出")
+ parser.add_argument("--overwrite", action="store_true", help="覆盖已存在的文件")
+
+ # 直接传递图片路径
+ parser.add_argument("image_paths", nargs="*", help="直接指定图片相对路径")
+
+ args = parser.parse_args()
+
+ # 创建示例配置文件
+ if args.create_config:
+ create_example_config(args.create_config)
+ return
+
+ # 检查必要参数(如果不是创建配置文件)
+ if not args.config and not (
+ args.source_dir and args.target_host and args.target_user and args.target_dir
+ ):
+ print("错误: 必须提供配置文件或指定必要的服务器参数")
+ print("必要参数: --source-dir, --target-host, --target-user, --target-dir")
+ parser.print_help()
+ sys.exit(1)
+
+ # 加载配置
+ config = {}
+ if args.config:
+ config = load_config(args.config)
+ if not config:
+ print("错误: 无法加载配置文件")
+ sys.exit(1)
+
+ # 构建源服务器配置
+ source_config_data = config.get("source", {})
+
+ # 命令行参数覆盖配置文件
+ if args.source_type:
+ source_config_data["type"] = args.source_type
+
+ # 如果是SSH类型,需要SSH配置
+ if args.source_type == "ssh" or source_config_data.get("type") == "ssh":
+ ssh_config = source_config_data.get("ssh", {})
+
+ if args.source_host:
+ ssh_config["host"] = args.source_host
+ if args.source_port:
+ ssh_config["port"] = args.source_port
+ if args.source_user:
+ ssh_config["username"] = args.source_user
+ if args.source_password:
+ ssh_config["password"] = args.source_password
+ if args.source_key:
+ ssh_config["key_file"] = args.source_key
+
+ source_config_data["ssh"] = ssh_config
+
+ # 检查必要的SSH参数
+ if not ssh_config.get("host"):
+ print("错误: 源服务器为SSH类型,必须指定 --source-host")
+ sys.exit(1)
+ if not ssh_config.get("username"):
+ print("错误: 源服务器为SSH类型,必须指定 --source-user")
+ sys.exit(1)
+ else:
+ # 本地类型,不需要SSH配置
+ source_config_data.pop("ssh", None)
+
+ # 设置基础目录
+ if args.source_dir:
+ source_config_data["base_dir"] = args.source_dir
+ elif "base_dir" not in source_config_data:
+ print("错误: 必须指定源服务器图片基础目录 (--source-dir)")
+ sys.exit(1)
+
+ # 构建目标服务器配置
+ target_config_data = config.get("target", {})
+ target_config_data["type"] = "ssh" # 目标必须是SSH
+
+ ssh_config = target_config_data.get("ssh", {})
+
+ # 命令行参数覆盖配置文件
+ if args.target_host:
+ ssh_config["host"] = args.target_host
+ if args.target_port:
+ ssh_config["port"] = args.target_port
+ if args.target_user:
+ ssh_config["username"] = args.target_user
+ if args.target_password:
+ ssh_config["password"] = args.target_password
+ if args.target_key:
+ ssh_config["key_file"] = args.target_key
+
+ target_config_data["ssh"] = ssh_config
+
+ # 设置基础目录
+ if args.target_dir:
+ target_config_data["base_dir"] = args.target_dir
+ elif "base_dir" not in target_config_data:
+ print("错误: 必须指定目标服务器图片基础目录 (--target-dir)")
+ sys.exit(1)
+
+ # 检查必要的目标SSH参数
+ if not ssh_config.get("host"):
+ print("错误: 必须指定目标服务器地址 (--target-host)")
+ sys.exit(1)
+ if not ssh_config.get("username"):
+ print("错误: 必须指定目标服务器用户名 (--target-user)")
+ sys.exit(1)
+
+ # 创建服务器配置对象
+ try:
+ source_config = ServerConfig.from_dict(source_config_data)
+ target_config = ServerConfig.from_dict(target_config_data)
+
+ if args.verbose:
+ print(f"源服务器配置: {source_config}")
+ print(f"目标服务器配置: {target_config}")
+ except Exception as e:
+ print(f"创建服务器配置失败: {e}")
+ sys.exit(1)
+
+ # 获取图片路径列表
+ image_paths = []
+
+ # 从文件读取
+ if args.input:
+ file_paths = read_image_paths(args.input)
+ image_paths.extend(file_paths)
+
+ # 从命令行参数添加
+ if args.image_paths:
+ image_paths.extend(args.image_paths)
+
+ if not image_paths:
+ print("错误: 没有指定要迁移的图片路径")
+ print("请使用 --input 指定文件或直接在命令行提供路径")
+ sys.exit(1)
+
+ origin_paths = image_paths
+ # 去重
+ image_paths = list(set(image_paths))
+
+ repeats = len(origin_paths) - len(image_paths)
+ if repeats > 0:
+ print(f"从文件中读取到 {repeats} 个重复图片路径数据行")
+
+ # 创建并运行迁移器
+ migrator = ImageMigrator(
+ source_config=source_config,
+ target_config=target_config,
+ verbose=args.verbose,
+ overwrite=args.overwrite,
+ )
+
+ # 执行迁移
+ stats = migrator.migrate_images(image_paths)
+
+ # 如果有失败,返回错误代码
+ if stats["failed"] > 0:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/images.txt b/scripts/images.txt
new file mode 100644
index 00000000..f24a309d
--- /dev/null
+++ b/scripts/images.txt
@@ -0,0 +1,2 @@
+images/banner/20251217/c16f15d090170b9cecaf6b88e02384f3_thumb.png
+images/banner/20251217/c16f15d090170b9cecaf6b88e02384f3.png
diff --git a/scripts/requirements.txt b/scripts/requirements.txt
new file mode 100644
index 00000000..cbf7b443
--- /dev/null
+++ b/scripts/requirements.txt
@@ -0,0 +1,8 @@
+# Requirements for image migration script
+paramiko>=2.12.0
+
+# Optional: for better performance with large files
+# Optional: for progress bars
+# tqdm>=4.66.0
+
+# Install with: pip install -r requirements.txt