update
This commit is contained in:
204
Cargo.lock
generated
204
Cargo.lock
generated
@@ -214,6 +214,12 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "adler32"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
@@ -426,6 +432,15 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cedarwood"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d910bedd62c24733263d0bed247460853c9d22e8956bd4cd964302095e04e90"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -606,6 +621,12 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dary_heap"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -877,6 +898,15 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -970,6 +1000,17 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -1265,6 +1306,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"config",
|
||||
"image",
|
||||
"jieba-rs",
|
||||
"lazy_static",
|
||||
"mimalloc",
|
||||
"rand 0.8.7",
|
||||
@@ -1286,6 +1328,39 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
|
||||
|
||||
[[package]]
|
||||
name = "include-flate"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce"
|
||||
dependencies = [
|
||||
"include-flate-codegen",
|
||||
"include-flate-compress",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate-codegen"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0"
|
||||
dependencies = [
|
||||
"include-flate-compress",
|
||||
"proc-macro-error3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate-compress"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd"
|
||||
dependencies = [
|
||||
"libflate",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -1308,6 +1383,30 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jieba-macros"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c676b32a471d3cfae8dac2ad2f8334cd52e53377733cca8c1fb0a5062fec192"
|
||||
dependencies = [
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jieba-rs"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5dd552bbb95d578520ee68403bf8aaf0dbbb2ce55b0854d019f9350ad61040a"
|
||||
dependencies = [
|
||||
"cedarwood",
|
||||
"fxhash",
|
||||
"include-flate",
|
||||
"jieba-macros",
|
||||
"lazy_static",
|
||||
"phf",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
@@ -1367,6 +1466,30 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libflate"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df"
|
||||
dependencies = [
|
||||
"adler32",
|
||||
"crc32fast",
|
||||
"dary_heap",
|
||||
"libflate_lz77",
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libflate_lz77"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"no_std_io2",
|
||||
"rle-decode-fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
@@ -1524,6 +1647,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -1702,6 +1834,44 @@ dependencies = [
|
||||
"pest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -1778,6 +1948,28 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr3"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error3"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -1963,6 +2155,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rle-decode-fast"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.7.1"
|
||||
@@ -2220,6 +2418,12 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
<i class="fas fa-arrow-left"></i> 返回首页
|
||||
</a>
|
||||
<h1 class="album-title" id="album-title">加载中...</h1>
|
||||
<div class="album-info" id="album-info">
|
||||
<i class="fas fa-images"></i> 正在加载...
|
||||
<div class="album-header-row">
|
||||
<div class="album-info" id="album-info">
|
||||
正在加载...
|
||||
</div>
|
||||
<div class="album-meta-tags" id="album-meta-tags"></div>
|
||||
</div>
|
||||
<div class="album-meta-tags" id="album-meta-tags"></div>
|
||||
</div>
|
||||
|
||||
<div class="images-grid" id="images-container">
|
||||
@@ -38,13 +40,6 @@
|
||||
<span class="preview-info" id="lightbox-album">来自: 相册名称</span>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="preview-nav preview-prev" onclick="prevImage()" id="lightbox-prev">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="preview-counter" id="lightbox-counter">1 / 1</span>
|
||||
<button class="preview-nav preview-next" onclick="nextImage()" id="lightbox-next">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<button class="preview-close" onclick="closeLightbox()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -70,7 +65,19 @@
|
||||
0 KB
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-center-controls">
|
||||
<button class="preview-nav preview-prev" onclick="prevImage()" id="lightbox-prev">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="preview-counter" id="lightbox-counter">1 / 1</span>
|
||||
<button class="preview-nav preview-next" onclick="nextImage()" id="lightbox-next">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="preview-controls">
|
||||
<button class="preview-action preview-action-close" onclick="closeLightbox()">
|
||||
<i class="fas fa-times"></i> 关闭
|
||||
</button>
|
||||
<button class="preview-action" onclick="addToFavoritesFromLightbox()" id="lightbox-favorite-btn">
|
||||
<i class="fas fa-heart"></i> 加入收藏
|
||||
</button>
|
||||
|
||||
@@ -88,25 +88,103 @@ body {
|
||||
.album-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-gray);
|
||||
gap: 6px;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
padding: 6px 14px;
|
||||
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid #cbd5e1;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.album-info i { color: var(--secondary-blue); }
|
||||
.album-info i { color: #64748b; }
|
||||
|
||||
/* ============ 相册标签区(新:分类着色) ============ */
|
||||
.album-meta-tags {
|
||||
/* 照片数量 + 标签 合并到一行 */
|
||||
.album-header-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px 16px;
|
||||
margin: 12px auto 24px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.album-header-row .album-info {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* ============ 相册标签区(简洁版:与照片数量同行) ============ */
|
||||
.album-header-row .album-meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 标签分组 */
|
||||
.tag-group {
|
||||
margin-bottom: 14px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--border-gray);
|
||||
padding: 12px 16px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.tag-group:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.tag-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--border-gray);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.tag-group-header i {
|
||||
font-size: 0.9rem;
|
||||
color: var(--secondary-blue);
|
||||
}
|
||||
|
||||
.tag-group-header span {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.tag-group-count {
|
||||
margin-left: auto;
|
||||
background: var(--bg-hover);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tag-group-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 20px auto 32px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.meta-chip {
|
||||
@@ -114,13 +192,31 @@ body {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta-chip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.meta-chip:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.meta-chip:hover {
|
||||
@@ -130,13 +226,14 @@ body {
|
||||
|
||||
.meta-chip:active { transform: translateY(0); }
|
||||
|
||||
.meta-chip i { font-size: 0.75rem; }
|
||||
.meta-chip i { font-size: 0.75rem; opacity: 0.9; }
|
||||
|
||||
/* 人物标签 */
|
||||
.meta-chip-person {
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(236, 72, 153, 0.1) 100%);
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(236, 72, 153, 0.12) 100%);
|
||||
color: var(--accent-purple);
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
border-color: rgba(139, 92, 246, 0.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-chip-person:hover {
|
||||
@@ -147,54 +244,120 @@ body {
|
||||
|
||||
/* 分类标签(按类别着色) */
|
||||
.meta-chip-tag-style {
|
||||
background: #f0f9ff;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
color: #0284c7;
|
||||
border-color: #bae6fd;
|
||||
}
|
||||
.meta-chip-tag-style:hover { background: #0284c7; color: #fff; border-color: #0284c7; }
|
||||
.meta-chip-tag-style:hover { background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-outfit {
|
||||
background: #fdf2f8;
|
||||
background: linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%);
|
||||
color: #db2777;
|
||||
border-color: #fbcfe8;
|
||||
}
|
||||
.meta-chip-tag-outfit:hover { background: #db2777; color: #fff; border-color: #db2777; }
|
||||
.meta-chip-tag-outfit:hover { background: linear-gradient(135deg, #db2777 0%, #be185d 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-scene {
|
||||
background: #f0fdf4;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
color: #15803d;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
.meta-chip-tag-scene:hover { background: #15803d; color: #fff; border-color: #15803d; }
|
||||
.meta-chip-tag-scene:hover { background: linear-gradient(135deg, #15803d 0%, #166534 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-body {
|
||||
background: #fef3c7;
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
color: #b45309;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
.meta-chip-tag-body:hover { background: #b45309; color: #fff; border-color: #b45309; }
|
||||
.meta-chip-tag-body:hover { background: linear-gradient(135deg, #b45309 0%, #92400e 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-mood {
|
||||
background: #faf5ff;
|
||||
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
|
||||
color: #7c3aed;
|
||||
border-color: #e9d5ff;
|
||||
}
|
||||
.meta-chip-tag-mood:hover { background: #7c3aed; color: #fff; border-color: #7c3aed; }
|
||||
.meta-chip-tag-mood:hover { background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-topic {
|
||||
background: #eff6ff;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #0369a1;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
.meta-chip-tag-topic:hover { background: #0369a1; color: #fff; border-color: #0369a1; }
|
||||
.meta-chip-tag-topic:hover { background: linear-gradient(135deg, #0369a1 0%, #075985 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
/* jieba 提取的新分类 */
|
||||
.meta-chip-tag-noun {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #1d4ed8;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
.meta-chip-tag-noun:hover { background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-proper {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
|
||||
color: #4338ca;
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
.meta-chip-tag-proper:hover { background: linear-gradient(135deg, #4338ca 0%, #3730a3 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-location {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
|
||||
color: #047857;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.meta-chip-tag-location:hover { background: linear-gradient(135deg, #047857 0%, #065f46 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-organization {
|
||||
background: linear-gradient(135deg, #fef9c3 0%, #fef08a 100%);
|
||||
color: #a16207;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
.meta-chip-tag-organization:hover { background: linear-gradient(135deg, #a16207 0%, #854d0e 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-noun_verb {
|
||||
background: linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%);
|
||||
color: #be185d;
|
||||
border-color: #f9a8d4;
|
||||
}
|
||||
.meta-chip-tag-noun_verb:hover { background: linear-gradient(135deg, #be185d 0%, #9d174d 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-adj {
|
||||
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
|
||||
color: #6d28d9;
|
||||
border-color: #ddd6fe;
|
||||
}
|
||||
.meta-chip-tag-adj:hover { background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-verb {
|
||||
background: linear-gradient(135deg, #fed7aa 0%, #fdba74 100%);
|
||||
color: #c2410c;
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
.meta-chip-tag-verb:hover { background: linear-gradient(135deg, #c2410c 0%, #9a3412 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-chinese {
|
||||
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||
color: #475569;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
.meta-chip-tag-chinese:hover { background: linear-gradient(135deg, #475569 0%, #334155 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
.meta-chip-tag-english {
|
||||
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||
color: #64748b;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
.meta-chip-tag-english:hover { background: linear-gradient(135deg, #64748b 0%, #475569 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
/* 默认标签 */
|
||||
.meta-chip-tag-default,
|
||||
.meta-chip-tag {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-light);
|
||||
border-color: var(--border-gray);
|
||||
}
|
||||
.meta-chip-tag:hover { background: var(--text-light); color: #fff; border-color: var(--text-light); }
|
||||
.meta-chip-tag-default:hover,
|
||||
.meta-chip-tag:hover { background: linear-gradient(135deg, #64748b 0%, #475569 100%); color: #fff; border-color: transparent; }
|
||||
|
||||
/* ============ 瀑布流布局(核心) ============ */
|
||||
.images-grid {
|
||||
@@ -214,7 +377,6 @@ body {
|
||||
box-shadow: var(--shadow-sm);
|
||||
background: var(--bg-surface);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-gray);
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
@@ -233,6 +395,7 @@ body {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
transition: transform 0.4s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-item:hover img {
|
||||
@@ -249,6 +412,7 @@ body {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border-gray);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.image-caption small {
|
||||
@@ -368,7 +532,7 @@ body {
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.preview-modal.active { opacity: 1; visibility: visible; }
|
||||
@@ -377,44 +541,48 @@ body {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
width: 1200px;
|
||||
height: 800px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: scale(0.92);
|
||||
transition: transform 0.3s ease;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.preview-modal.active .preview-container { transform: scale(1); }
|
||||
|
||||
/* 顶部栏 - 浮在图片上方 */
|
||||
.preview-header {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%);
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-title h3 {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
color: #f1f5f9;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.preview-info { font-size: 0.85rem; color: var(--text-light); }
|
||||
.preview-info { font-size: 0.8rem; color: #94a3b8; }
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
@@ -423,51 +591,60 @@ body {
|
||||
}
|
||||
|
||||
.preview-nav {
|
||||
width: 36px; height: 36px;
|
||||
border: 1px solid var(--border-gray);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-dark);
|
||||
width: 40px; height: 40px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #f1f5f9;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.preview-nav:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--secondary-blue);
|
||||
color: var(--secondary-blue);
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-color: rgba(255,255,255,0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview-nav:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.preview-nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.preview-counter { font-size: 0.85rem; color: var(--text-light); font-weight: 500; }
|
||||
.preview-counter {
|
||||
font-size: 0.9rem;
|
||||
color: #cbd5e1;
|
||||
font-weight: 500;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.preview-close {
|
||||
width: 36px; height: 36px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-dark);
|
||||
width: 40px; height: 40px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #f1f5f9;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.preview-close:hover { background: #ef4444; color: #fff; }
|
||||
.preview-close:hover { background: rgba(239, 68, 68, 0.8); color: #fff; border-color: rgba(239, 68, 68, 0.6); }
|
||||
|
||||
/* 图片内容区 - 占满全屏 */
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
padding: 60px 80px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f172a;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.preview-image-container {
|
||||
@@ -482,8 +659,12 @@ body {
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
transition: opacity 0.3s ease;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
@@ -494,54 +675,107 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-light);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.preview-loading i { font-size: 1.8rem; color: var(--secondary-blue); }
|
||||
.preview-loading i { font-size: 2rem; color: #3b82f6; }
|
||||
|
||||
/* 底部栏 - 浮在图片上方 */
|
||||
.preview-footer {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-gray);
|
||||
gap: 20px;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.75) 0%, transparent 100%);
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-meta { display: flex; gap: 16px; }
|
||||
.preview-meta { display: flex; gap: 16px; flex: 1; }
|
||||
|
||||
.preview-meta .meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.preview-controls { display: flex; gap: 10px; }
|
||||
/* 底部居中的切换控制栏 */
|
||||
.preview-center-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-center-controls .preview-nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #f1f5f9;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-center-controls .preview-nav:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.25);
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.preview-center-controls .preview-nav:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-center-controls .preview-counter {
|
||||
font-size: 0.9rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-controls { display: flex; gap: 10px; flex: 1; justify-content: flex-end; }
|
||||
|
||||
.preview-action {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border-gray);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-dark);
|
||||
padding: 8px 16px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 20px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #f1f5f9;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.preview-action:hover { background: var(--bg-hover); }
|
||||
.preview-action:hover { background: rgba(255,255,255,0.2); }
|
||||
|
||||
.preview-remove { border-color: #fecaca; color: #dc2626; }
|
||||
.preview-remove:hover { background: #fef2f2; border-color: #fca5a5; }
|
||||
.preview-action-close:hover {
|
||||
background: rgba(239, 68, 68, 0.7);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.preview-action.favorited { border-color: #fbcfe8; color: #ec4899; background: #fdf2f8; }
|
||||
.preview-action.favorited:hover { background: #fce7f3; border-color: #f9a8d4; }
|
||||
.preview-action.favorited { border-color: rgba(236, 72, 153, 0.5); color: #f9a8d4; background: rgba(236, 72, 153, 0.15); }
|
||||
.preview-action.favorited:hover { background: rgba(236, 72, 153, 0.25); }
|
||||
|
||||
/* 暗色模式 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -554,6 +788,15 @@ body {
|
||||
--text-light: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
}
|
||||
.preview-container { background: var(--bg-surface); }
|
||||
.preview-nav, .preview-close, .preview-action { background: var(--bg-hover); color: var(--text-dark); border-color: var(--border-gray); }
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.preview-content { padding: 40px 10px 70px; }
|
||||
.preview-header, .preview-footer { padding: 12px 16px; }
|
||||
.preview-footer { flex-wrap: wrap; gap: 12px; }
|
||||
.preview-meta { order: 2; justify-content: center; }
|
||||
.preview-center-controls { order: 1; width: 100%; }
|
||||
.preview-controls { order: 3; justify-content: center; }
|
||||
.preview-center-controls .preview-nav { width: 36px; height: 36px; }
|
||||
}
|
||||
|
||||
@@ -107,30 +107,56 @@ input { font-family: inherit; }
|
||||
.sidebar.collapsed .brand-text,
|
||||
.sidebar.collapsed .nav-item span,
|
||||
.sidebar.collapsed .sidebar-section-title span,
|
||||
.sidebar.collapsed .sidebar-section-title .section-arrow,
|
||||
.sidebar.collapsed .sidebar-tags,
|
||||
.sidebar.collapsed .collapse-btn span {
|
||||
.sidebar.collapsed .sidebar-people,
|
||||
.sidebar.collapsed .sidebar-section-content,
|
||||
.sidebar.collapsed .sidebar-divider {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-section {
|
||||
padding: var(--spacer-md) 0;
|
||||
gap: var(--spacer-xs);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-section-title {
|
||||
justify-content: center;
|
||||
padding: var(--spacer-sm) 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-section-group {
|
||||
margin-bottom: var(--spacer-sm);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin: 0 var(--spacer-xs);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-brand {
|
||||
padding: var(--spacer-lg) 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .collapse-btn {
|
||||
justify-content: center;
|
||||
padding: var(--spacer-md) 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .collapse-btn i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-nav {
|
||||
padding: var(--spacer-md) var(--spacer-xs);
|
||||
}
|
||||
|
||||
/* 品牌 Logo */
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
@@ -228,6 +254,7 @@ input { font-family: inherit; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
gap: var(--spacer-sm);
|
||||
}
|
||||
|
||||
.sidebar-section::-webkit-scrollbar {
|
||||
@@ -238,6 +265,12 @@ input { font-family: inherit; }
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 可折叠区块组 */
|
||||
.sidebar-section-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -251,7 +284,49 @@ input { font-family: inherit; }
|
||||
margin-bottom: var(--spacer-sm);
|
||||
}
|
||||
|
||||
.sidebar-section-title i {
|
||||
.sidebar-section-title.collapsible {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-section-title.collapsible:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.section-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
transition: transform var(--transition-base);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sidebar-section-group.collapsed .section-arrow {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.sidebar-section-content {
|
||||
overflow: hidden;
|
||||
max-height: 600px;
|
||||
transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease,
|
||||
margin 0.2s ease;
|
||||
opacity: 1;
|
||||
margin-bottom: var(--spacer-sm);
|
||||
}
|
||||
|
||||
.sidebar-section-group.collapsed .sidebar-section-content {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-bottom: 0;
|
||||
transition: max-height 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.15s ease,
|
||||
margin 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-section-title i:first-child {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
@@ -309,24 +384,95 @@ input { font-family: inherit; }
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
/* 侧边栏人物列表 */
|
||||
.sidebar-people {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacer-xs);
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.sidebar-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacer-sm);
|
||||
padding: var(--spacer-sm) var(--spacer-lg);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.sidebar-person:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.sidebar-person.active {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: var(--accent-purple);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-person-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: var(--white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-person-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-person-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-person-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-person.active .sidebar-person-count {
|
||||
color: var(--accent-purple);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 侧边栏底部 */
|
||||
.sidebar-footer {
|
||||
padding: var(--spacer-md);
|
||||
border-top: 1px solid var(--border-gray);
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacer-md);
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: var(--spacer-md) var(--spacer-lg);
|
||||
padding: var(--spacer-md);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
@@ -338,12 +484,6 @@ input { font-family: inherit; }
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
transition: transform var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-btn span {
|
||||
transition: opacity var(--transition-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============ 主内容区 ============ */
|
||||
|
||||
@@ -37,22 +37,38 @@
|
||||
<!-- 分隔线 -->
|
||||
<div class="sidebar-divider"></div>
|
||||
|
||||
<!-- 热门人物 -->
|
||||
<div class="sidebar-section-group">
|
||||
<div class="sidebar-section-title collapsible" data-section="people" onclick="toggleSidebarSection('people')">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>热门人物</span>
|
||||
<i class="fas fa-chevron-down section-arrow"></i>
|
||||
</div>
|
||||
<div class="sidebar-section-content" id="sidebar-people-content">
|
||||
<div class="sidebar-people" id="sidebar-people">
|
||||
<!-- 动态填充 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门标签 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">
|
||||
<div class="sidebar-section-group">
|
||||
<div class="sidebar-section-title collapsible" data-section="tags" onclick="toggleSidebarSection('tags')">
|
||||
<i class="fas fa-tags"></i>
|
||||
<span>热门标签</span>
|
||||
<i class="fas fa-chevron-down section-arrow"></i>
|
||||
</div>
|
||||
<div class="sidebar-tags" id="sidebar-tags">
|
||||
<!-- 动态填充 -->
|
||||
<div class="sidebar-section-content" id="sidebar-tags-content">
|
||||
<div class="sidebar-tags" id="sidebar-tags">
|
||||
<!-- 动态填充 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部折叠按钮 -->
|
||||
<div class="sidebar-footer">
|
||||
<button class="collapse-btn" onclick="toggleSidebar()" id="collapse-btn">
|
||||
<button class="collapse-btn" onclick="toggleSidebar()" id="collapse-btn" title="收起侧边栏">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span>收起侧边栏</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -124,20 +140,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门人物横向滚动条 -->
|
||||
<div class="people-strip" id="people-strip" style="display:none;">
|
||||
<div class="people-strip-header">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>热门人物</span>
|
||||
</div>
|
||||
<div class="people-scroll" id="people-scroll">
|
||||
<!-- 动态填充 -->
|
||||
</div>
|
||||
<div class="people-scroll-hint">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相册网格 -->
|
||||
<div class="albums-grid" id="albums-container">
|
||||
<div class="loading">
|
||||
|
||||
@@ -262,7 +262,7 @@ async function loadAlbum(albumId) {
|
||||
function updateAlbumInfo() {
|
||||
document.getElementById('album-title').textContent = currentAlbum.title;
|
||||
|
||||
let infoHtml = `<i class="fas fa-images"></i> 共 ${images.length} 张图片`;
|
||||
let infoHtml = `${images.length} 张图片`;
|
||||
document.getElementById('album-info').innerHTML = infoHtml;
|
||||
|
||||
// 显示相册元数据(人名 + 标签)
|
||||
@@ -316,7 +316,7 @@ function getTagCategory(tag) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
// 更新相册元数据(人名 + 标签)点击可搜索
|
||||
// 更新相册元数据(人名 + 标签)简洁版:只用颜色区分,不写分类名
|
||||
function updateAlbumMeta() {
|
||||
const metaContainer = document.getElementById('album-meta-tags');
|
||||
if (!metaContainer) return;
|
||||
@@ -337,7 +337,6 @@ function updateAlbumMeta() {
|
||||
if (currentAlbum.tags) {
|
||||
let tagList = [];
|
||||
try {
|
||||
// 格式1: JSON数组 [["标签名", "分类"], ...]
|
||||
const parsed = JSON.parse(currentAlbum.tags);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(item => {
|
||||
@@ -353,7 +352,6 @@ function updateAlbumMeta() {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 格式2: 逗号分隔字符串
|
||||
currentAlbum.tags.split(',').forEach(tag => {
|
||||
const t = tag.trim();
|
||||
if (t) tagList.push({ text: t, category: getTagCategory(t) });
|
||||
@@ -378,9 +376,8 @@ function updateAlbumMeta() {
|
||||
metaContainer.style.display = 'flex';
|
||||
metaContainer.innerHTML = chips.map(chip => {
|
||||
const icon = chip.type === 'person' ? 'fa-user' : 'fa-tag';
|
||||
// 生成 CSS 类名:人物用 person,标签用 tag-{category}
|
||||
const cssClass = chip.type === 'person' ? 'meta-chip-person' : `meta-chip-tag-${chip.category || 'default'}`;
|
||||
return `<span class="meta-chip ${cssClass}" data-meta-type="${chip.type}" data-meta-query="${encodeURIComponent(chip.query)}">
|
||||
return `<span class="meta-chip ${cssClass}" data-meta-type="${chip.type}" data-meta-query="${encodeURIComponent(chip.query)}" title="点击搜索: ${chip.text}">
|
||||
<i class="fas ${icon}"></i> ${chip.text}
|
||||
</span>`;
|
||||
}).join('');
|
||||
@@ -446,7 +443,7 @@ function displayImages() {
|
||||
// 检查图片是否已被收藏
|
||||
isImageFavorited(image.id || image.original_url);
|
||||
return `
|
||||
<div class="image-item ${orientation}" onclick="openLightbox(${index})">
|
||||
<div class="image-item ${orientation}">
|
||||
<img
|
||||
src="${imageUrl}"
|
||||
${image.id ? `data-image-id="${image.id}"` : ''}
|
||||
@@ -454,6 +451,7 @@ function displayImages() {
|
||||
class="lazy"
|
||||
loading="lazy"
|
||||
onerror="this.src='/assets/placeholder.svg'"
|
||||
onclick="openLightbox(${index})"
|
||||
>
|
||||
<div class="image-caption">
|
||||
${image.width ? `<small>${image.width}×${image.height}</small>` : ''}
|
||||
|
||||
136
assets/js/app.js
136
assets/js/app.js
@@ -109,6 +109,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
initSearch();
|
||||
initInfiniteScroll();
|
||||
initLazyLoading(); // 初始化图片懒加载
|
||||
initSidebarSections(); // 初始化侧边栏区块折叠状态
|
||||
|
||||
// 检查URL中是否有search参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -539,7 +540,10 @@ async function performServerSearch(searchTerm) {
|
||||
|
||||
// 显示服务器搜索结果
|
||||
displayAlbums(serverResults);
|
||||
document.getElementById('stats').innerHTML = `在全站找到 ${serverResults.length} 个包含 "${searchTerm}" 的相册`;
|
||||
const oldStats = document.getElementById('stats');
|
||||
if (oldStats) {
|
||||
oldStats.innerHTML = `在全站找到 ${serverResults.length} 个包含 "${searchTerm}" 的相册`;
|
||||
}
|
||||
|
||||
// 初始化图片懒加载
|
||||
initLazyLoading();
|
||||
@@ -636,11 +640,11 @@ function updateStats(albums) {
|
||||
const oldStats = document.getElementById('stats');
|
||||
if (oldStats) oldStats.innerHTML = `共发现 ${albums.length} 个相册,包含 ${totalImages} 张图片`;
|
||||
|
||||
// 获取人物数量
|
||||
fetch(`${API_BASE}/recommend/popular-persons?limit=1`).then(r => r.ok && r.json()).then(data => {
|
||||
if (data && data.length > 0 && data[0].total) {
|
||||
// 获取人物数量(用 metadata stats API)
|
||||
fetch(`${API_BASE}/metadata/stats`).then(r => r.ok && r.json()).then(data => {
|
||||
if (data && data.total_persons !== undefined) {
|
||||
const statPeople = document.getElementById('stat-people');
|
||||
if (statPeople) statPeople.textContent = data[0].total.toLocaleString();
|
||||
if (statPeople) statPeople.textContent = data.total_persons.toLocaleString();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -799,14 +803,20 @@ function openAlbum(albumId) {
|
||||
|
||||
// 随机打开相册
|
||||
function openRandomAlbum() {
|
||||
// 添加随机动画效果
|
||||
const randomButton = document.querySelector('.random-button');
|
||||
randomButton.style.transform = 'scale(0.95)';
|
||||
randomButton.disabled = true;
|
||||
|
||||
// 显示加载状态
|
||||
const originalText = randomButton.textContent;
|
||||
randomButton.textContent = '随机加载中...';
|
||||
// 添加随机动画效果 - 兼容侧边栏导航项
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
let randomButton = null;
|
||||
navItems.forEach(item => {
|
||||
if (item.textContent.includes('随机')) randomButton = item;
|
||||
});
|
||||
// 也试试顶部操作按钮
|
||||
if (!randomButton) {
|
||||
randomButton = document.querySelector('.action-btn[onclick*="openRandomAlbum"]');
|
||||
}
|
||||
|
||||
if (randomButton) {
|
||||
randomButton.style.transform = 'scale(0.95)';
|
||||
}
|
||||
|
||||
// 调用新的随机相册API
|
||||
fetch(`${API_BASE}/album/random`)
|
||||
@@ -821,22 +831,22 @@ function openRandomAlbum() {
|
||||
})
|
||||
.then(randomAlbum => {
|
||||
// 恢复按钮状态
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
randomButton.disabled = false;
|
||||
randomButton.textContent = originalText;
|
||||
}, 200);
|
||||
if (randomButton) {
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 跳转到随机相册
|
||||
window.location.href = `/album?id=${randomAlbum.id}`;
|
||||
})
|
||||
.catch(error => {
|
||||
// 恢复按钮状态
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
randomButton.disabled = false;
|
||||
randomButton.textContent = originalText;
|
||||
}, 200);
|
||||
if (randomButton) {
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
alert(error.message);
|
||||
});
|
||||
@@ -847,6 +857,11 @@ function showFavorites() {
|
||||
// 设置当前在收藏页
|
||||
isInFavoritesPage = true;
|
||||
|
||||
// 切换侧边栏选中状态
|
||||
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
|
||||
const favNav = document.querySelector('.nav-item[data-view="favorites"]');
|
||||
if (favNav) favNav.classList.add('active');
|
||||
|
||||
// 从后端API获取收藏列表
|
||||
fetch(`${API_BASE}/favorites`)
|
||||
.then(response => {
|
||||
@@ -919,8 +934,10 @@ function displayFavorites(favoriteImages) {
|
||||
`}
|
||||
`;
|
||||
|
||||
// 更新统计信息 - 显示收藏数量
|
||||
document.getElementById('stats').innerHTML = `<div class="favorites-header">
|
||||
// 更新统计信息 - 显示收藏数量(兼容新旧版布局)
|
||||
const statsEl = document.getElementById('stats');
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML = `<div class="favorites-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="favorites-icon">
|
||||
@@ -940,6 +957,7 @@ function displayFavorites(favoriteImages) {
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 初始化图片懒加载,确保收藏的图片被加载
|
||||
initLazyLoading();
|
||||
@@ -959,8 +977,12 @@ function formatDate(dateString) {
|
||||
function backToHome() {
|
||||
// 重置收藏页标记
|
||||
isInFavoritesPage = false;
|
||||
// 重新加载首页相册
|
||||
loadAlbums(true);
|
||||
// 切换导航高亮到首页
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.view === 'home');
|
||||
});
|
||||
// 清除筛选
|
||||
clearFilter();
|
||||
}
|
||||
|
||||
// 全局变量存储收藏列表和当前预览索引
|
||||
@@ -1241,7 +1263,9 @@ async function loadDiscoverData() {
|
||||
]);
|
||||
|
||||
if (tagsResp.ok) {
|
||||
const tags = await tagsResp.json();
|
||||
const tagsData = await tagsResp.json();
|
||||
// 兼容两种格式:直接数组 或 { total, tags }
|
||||
const tags = Array.isArray(tagsData) ? tagsData : (tagsData.tags || tagsData.items || []);
|
||||
if (tags && tags.length > 0) {
|
||||
renderTagCloud(tags);
|
||||
}
|
||||
@@ -1300,7 +1324,24 @@ function renderTagCloud(tags) {
|
||||
|
||||
// 渲染热门人物(横向滚动条)
|
||||
function renderPeopleCloud(people) {
|
||||
// 新版:横向滚动条
|
||||
// 侧边栏人物列表
|
||||
const sidebarPeople = document.getElementById('sidebar-people');
|
||||
if (sidebarPeople && people && people.length) {
|
||||
sidebarPeople.innerHTML = people.slice(0, 25).map(p => {
|
||||
const name = p.name || p.display_name || '';
|
||||
const albumCount = p.total_albums || 0;
|
||||
const isActive = currentFilter && currentFilter.type === 'person' && currentFilter.value === name;
|
||||
return `<div class="sidebar-person ${isActive ? 'active' : ''}" onclick="filterByPerson('${name}')" title="${albumCount} 个相册">
|
||||
<div class="sidebar-person-avatar">${name.charAt(0)}</div>
|
||||
<div class="sidebar-person-info">
|
||||
<div class="sidebar-person-name">${name}</div>
|
||||
<div class="sidebar-person-count">${albumCount} 相册</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 横向滚动条
|
||||
const strip = document.getElementById('people-strip');
|
||||
const scroll = document.getElementById('people-scroll');
|
||||
if (strip && scroll && people && people.length) {
|
||||
@@ -1339,6 +1380,32 @@ function renderPeopleCloud(people) {
|
||||
}
|
||||
}
|
||||
|
||||
// 侧边栏区块折叠/展开
|
||||
function toggleSidebarSection(section) {
|
||||
const group = document.querySelector(`.sidebar-section-group [data-section="${section}"]`)?.closest('.sidebar-section-group');
|
||||
if (!group) return;
|
||||
group.classList.toggle('collapsed');
|
||||
// 保存状态到 localStorage
|
||||
try {
|
||||
const state = JSON.parse(localStorage.getItem('sidebarSections') || '{}');
|
||||
state[section] = group.classList.contains('collapsed');
|
||||
localStorage.setItem('sidebarSections', JSON.stringify(state));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// 初始化侧边栏区块折叠状态
|
||||
function initSidebarSections() {
|
||||
try {
|
||||
const state = JSON.parse(localStorage.getItem('sidebarSections') || '{}');
|
||||
Object.keys(state).forEach(section => {
|
||||
if (state[section]) {
|
||||
const group = document.querySelector(`.sidebar-section-group [data-section="${section}"]`)?.closest('.sidebar-section-group');
|
||||
if (group) group.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// 按标签筛选
|
||||
async function filterByTag(tag) {
|
||||
currentFilter = { type: 'tag', value: tag };
|
||||
@@ -1372,6 +1439,15 @@ async function filterByPerson(personName) {
|
||||
currentFilter = { type: 'person', value: personName };
|
||||
showFilterStatus(`人物: ${personName}`);
|
||||
|
||||
// 刷新侧边栏人物高亮
|
||||
try {
|
||||
const peopleResp = await fetch(`${API_BASE}/recommend/popular-persons?limit=30`);
|
||||
if (peopleResp.ok) {
|
||||
const peopleData = await peopleResp.json();
|
||||
renderPeopleCloud(peopleData);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
try {
|
||||
showSkeleton(6);
|
||||
const response = await fetch(`${API_BASE}/people/search?q=${encodeURIComponent(personName)}`);
|
||||
@@ -1489,6 +1565,8 @@ function switchView(view) {
|
||||
|
||||
if (view === 'home') {
|
||||
backToHome();
|
||||
} else if (view === 'favorites') {
|
||||
showFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
226
docs/pipeline-architecture.md
Normal file
226
docs/pipeline-architecture.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# 流云图库 — 元数据管线架构文档
|
||||
|
||||
> 版本:v2.0 · 更新:2026-07-19
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
流云图库是一个 Rust 编写的图片管理应用,核心功能是从相册标题中自动提取**人物名称**和**分类标签**,并建立人物-相册关联关系。所有提取和关联逻辑由一套三阶段的**处理管线(Pipeline)** 驱动。
|
||||
|
||||
```
|
||||
相册标题(如 "水淼Aqua cosplay Zani – Wuthering Waves 69 photos")
|
||||
│
|
||||
▼
|
||||
┌─ Pipeline ──────────────────────────────────────┐
|
||||
│ Phase 1 全量解析 提取人名 + 提取标签 │
|
||||
│ Phase 2 人物合并 按中文核心合并重复人物 │
|
||||
│ Phase 3 缓存刷新 重新加载内存缓存 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
│
|
||||
├──→ albums 表(person_name_original, tags 等)
|
||||
├──→ persons 表(人物主表)
|
||||
├──→ person_aliases 表(别名关联)
|
||||
└──→ album_tags 表(标签关联)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 启动流程与触发时机
|
||||
|
||||
应用启动后,管线任务按顺序串行执行三个阶段:
|
||||
|
||||
```
|
||||
启动管线(单次,后台任务 A)
|
||||
│
|
||||
├── ① 扫描导入新相册(import)
|
||||
├── ② 全量解析 + 人物合并(run_pipeline)
|
||||
└── ③ 刷新 AlbumsCache
|
||||
```
|
||||
|
||||
- **启动管线**:import → run_pipeline → refreshCache,三件事**顺序串行**,不并发
|
||||
- **定时管线**:每 30 分钟触发一次,执行 run_pipeline → refreshCache(不重复 import)
|
||||
- **API 触发**:POST /api/pipeline/run 可手动触发 run_pipeline
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:全量解析
|
||||
|
||||
**入口函数:** `phase_parse_all(db)` → `(person_updated, tag_updated)`
|
||||
|
||||
对数据库中**每一个相册**独立执行以下流程:
|
||||
|
||||
```
|
||||
for each album:
|
||||
│
|
||||
├── jieba 分词(词性标注)
|
||||
│ ├──→ 提取推荐人名(词性 nr/nrfg/nrt/nz)
|
||||
│ └──→ 提取推荐标签(词性 n/nz/ns/eng/x)
|
||||
│
|
||||
├── 确定最佳人名
|
||||
│ ├── 有人物库匹配?→ 用库匹配结果
|
||||
│ └── 否则 → 用 jieba 推荐
|
||||
│
|
||||
├── 写入 DB(强制覆盖,不比对旧值)
|
||||
│
|
||||
├── 提取标签
|
||||
│ ├── 三步提取:分类词库 → jieba → 正则兜底
|
||||
│ ├── unique_tags() 去重+排序+坏标签过滤
|
||||
│ └── 写入 DB(强制覆盖,不比对旧值)
|
||||
│
|
||||
└── 下一个相册
|
||||
```
|
||||
|
||||
### 人名提取策略
|
||||
|
||||
按优先级依次尝试:
|
||||
|
||||
1. **人物库精确匹配** — `person_merger::match_title()`,在已有 persons 表中查询标题子串
|
||||
2. **jieba 词性标注** — `extract_person_from_tags()`,优先取 nr(人名)类词
|
||||
3. **标题模式匹配** — `extract_person_name_from_title()`,尝试 cosplay 格式、分隔符切分、正则扫描
|
||||
|
||||
### 人名处理规则
|
||||
|
||||
- **禁止中英混杂**:人名主体为纯中文或纯英文(中文优先级更高)
|
||||
- 中英文混合(如"水淼Aqua")由 `split_person_name_with_aliases` 拆分为:主名="水淼" + 别名="Aqua"
|
||||
- 别名保存到 `person_aliases` 表,用于后续搜索匹配
|
||||
|
||||
### 标签分类体系
|
||||
|
||||
| 分类 | 标识 | 示例词 |
|
||||
|------|------|--------|
|
||||
| 风格 | style | 日系、韩系、复古、甜美、sexy |
|
||||
| 服装 | outfit | 比基尼、旗袍、制服、丝袜 |
|
||||
| 场景 | scene | 室内、户外、街拍、浴室、beach |
|
||||
| 身体 | body | 美腿、翘臀、玉足、美背 |
|
||||
| 情绪 | mood | 清纯、诱惑、唯美、冷艳 |
|
||||
| 主题 | topic | 写真、私房、旅拍、cosplay |
|
||||
| 名词 | noun | jieba 提取的普通名词 |
|
||||
| 专名 | proper | jieba 提取的专有名词 |
|
||||
| 地名 | location | jieba 提取的地名 |
|
||||
| 其他 | other | 正则兜底提取的剩余关键词 |
|
||||
|
||||
### 标签过滤链(从外到内共 5 层)
|
||||
|
||||
```
|
||||
1. is_punctuation_only() → 检测不含任何字/字母/数字,则跳过
|
||||
2. 停用词表过滤 → photos、cosplay、美女、模特 等 50+ 词
|
||||
3. 人名排除 → 排除标签提取时确定的人名
|
||||
4. 词性排除 → 形容词(a)、动词(v)、名动词(vn)、机构(nt) 等不产出标签
|
||||
5. unique_tags() 最终过滤 → 调用 is_bad_tag() 二次确认
|
||||
```
|
||||
|
||||
### 强制覆盖写入
|
||||
|
||||
每次管线执行都是**完全重新解析**,提取到人名就写入覆盖、提取到标签就写入覆盖。不做"新旧好坏"比对:
|
||||
|
||||
- **人名**:jieba/fuzzy 提取到 → 直接写入 `albums.person_name_*`,创建/更新 `persons` 记录及别名关联
|
||||
- **标签**:经过 5 层过滤链后仍有结果 → 直接写入 `albums.tags`,重建 `album_tags` 表
|
||||
|
||||
这样每次启动或定时触发时,标签和人名都会用最新的解析逻辑重新计算并强制覆盖,解析逻辑改进后老数据自动受益。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:人物合并
|
||||
|
||||
**入口函数:** `phase_merge_persons(db)` → `merged_count`
|
||||
|
||||
按**中文核心**自动合并重复人物:
|
||||
|
||||
```
|
||||
1. 扫描所有 persons,提取其中文核心(开头的连续中文字符)
|
||||
2. 将中文核心相同的 person 分为一组
|
||||
3. 每组选相册数最多的作为目标(dst)
|
||||
4. 其余合并到目标:
|
||||
- 移动所有相册关联
|
||||
- 移动所有别名
|
||||
- 源人物原名也作为目标别名
|
||||
- 删除源人物
|
||||
5. 更新目标的统计计数
|
||||
```
|
||||
|
||||
示例:`杨晨晨Yome` 和 `杨晨晨Sugar` 的中文核心都是 "杨晨晨" → 合并为一个人物
|
||||
|
||||
---
|
||||
|
||||
## 核心数据表结构
|
||||
|
||||
### albums 表(关键字段)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | TEXT | UUID |
|
||||
| title | TEXT | 相册标题,来源是文件夹名或 index.json |
|
||||
| person_name_original | TEXT | 提取的原始人名(可能带英文后缀) |
|
||||
| person_name_cleaned | TEXT | 清洗后的人名(仅中文主名) |
|
||||
| tags | TEXT | JSON 数组,如 `["比基尼","室内","清纯"]` |
|
||||
| all_words | TEXT | JSON 数组,如 `[["比基尼","outfit"],["室内","scene"]]` |
|
||||
| processed_at | TEXT | 最后处理时间 |
|
||||
|
||||
### persons 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 自增主键 |
|
||||
| name | TEXT | 人物名(通常是中文主名) |
|
||||
| display_name | TEXT | 显示名 |
|
||||
| total_albums | INTEGER | 关联相册数 |
|
||||
| total_images | INTEGER | 关联图片数 |
|
||||
|
||||
### person_aliases 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 自增主键 |
|
||||
| person_id | INTEGER | 关联 persons.id |
|
||||
| alias_type | TEXT | 别名类型(english/nickname/chinese/original) |
|
||||
| alias_name | TEXT | 别名文本 |
|
||||
|
||||
### album_tags 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 自增主键 |
|
||||
| album_id | TEXT | 关联 albums.id |
|
||||
| tag | TEXT | 标签文本 |
|
||||
|
||||
---
|
||||
|
||||
## 定时任务
|
||||
|
||||
每 30 分钟触发一次,顺序执行:
|
||||
|
||||
```
|
||||
1. run_pipeline() → Phase 1(全量解析)+ Phase 2(人物合并)
|
||||
2. 刷新 AlbumsCache(内存缓存,为 API 提供快速查询)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/pipeline/run | 手动触发一次完整管线 |
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"persons_updated": 12,
|
||||
"tags_updated": 45,
|
||||
"persons_merged": 3,
|
||||
"message": "管线执行完毕:更新人名 12 个,更新标签 45 个,合并人物 3 人"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计原则
|
||||
|
||||
1. **幂等性**:管线可反复执行,每次都是完整重新解析 + 强制覆盖写入
|
||||
2. **渐进改进**:解析逻辑改进后,老数据在下次管线执行时自动受益
|
||||
3. **强制覆盖**:提取到即写入,不做新旧比对,确保数据始终使用最新逻辑
|
||||
4. **中文优先**:人名提取优先中文,中英文混合被拆分为主名+别名
|
||||
5. **多层过滤**:标签经过 5 层过滤链,确保质量
|
||||
6. **纯中文/纯英文**:人名主体不允许中英混杂
|
||||
124
docs/pipeline-spec.md
Normal file
124
docs/pipeline-spec.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 元数据管线 — 开发规格
|
||||
|
||||
> 2026-07-19
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求概述
|
||||
|
||||
系统在启动和运行时,需要自动完成三个任务:
|
||||
|
||||
| # | 任务 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | **新相册解析** | 扫描磁盘导入的新相册 → 提取人名+标签 → 写入 DB |
|
||||
| 2 | **老数据重解析** | 已有数据的相册用最新逻辑重新提取,强制覆盖 |
|
||||
| 3 | **人物合并** | 按中文核心合并同一人的不同记录 |
|
||||
|
||||
这三件事通过**同一个管道**完成,管道在**启动时跑一次** + **定时每 30 分钟跑一次**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 启动流程(时序)
|
||||
|
||||
当前问题:import 和 pipeline 是两个并发的 `tokio::spawn`,pipeline 固定等 3s,import 可能耗时更长,导致刚导入的相册没被解析。
|
||||
|
||||
**目标时序:**
|
||||
|
||||
```
|
||||
服务启动
|
||||
│
|
||||
├── DB 初始化
|
||||
│
|
||||
├─── 后台任务 A:启动管线(单次)─────┐
|
||||
│ ├── ① 扫描导入新相册 │
|
||||
│ ├── ② Phase 1:全量解析(强制覆盖) │
|
||||
│ ├── ③ Phase 2:人物合并 │
|
||||
│ └── ④ 刷新内存缓存 │
|
||||
│ │
|
||||
├─── 后台任务 B:定时管线(每 30 分钟)──┤
|
||||
│ ├── ① Phase 1:全量解析(强制覆盖) │
|
||||
│ ├── ② Phase 2:人物合并 │
|
||||
│ └── ③ 刷新内存缓存 │
|
||||
│ │
|
||||
└─── HTTP 服务启动 │
|
||||
│
|
||||
注:后台任务 A 和 B 互斥,不会同时执行
|
||||
```
|
||||
|
||||
### 变化要点
|
||||
|
||||
- **启动管线 = import + parse + merge + refreshCache** 依次串行
|
||||
- **定时管线 = parse + merge + refreshCache**(不重复 import)
|
||||
- 管线内部顺序固定不可并发
|
||||
|
||||
---
|
||||
|
||||
## 3. 管线内部结构
|
||||
|
||||
```
|
||||
run_pipeline(db, with_import: bool)
|
||||
│
|
||||
├── [with_import=true] 扫描导入新相册
|
||||
│
|
||||
├── Phase 1:全量解析
|
||||
│ for each album:
|
||||
│ ├── jieba 分词 → 候选人名 + 候选标签
|
||||
│ ├── 人物库匹配 / jieba → 最佳人名
|
||||
│ ├── 强制写入 albums.person_name_* + persons + aliases
|
||||
│ ├── 标签三步提取(词库→jieba→正则)
|
||||
│ ├── unique_tags(去重+排序+坏标签过滤)
|
||||
│ └── 强制写入 albums.tags + album_tags
|
||||
│
|
||||
├── Phase 2:人物合并
|
||||
│ ├── 提取所有人名的中文核心
|
||||
│ ├── 分组 → 每组选最多相册的为目标
|
||||
│ └── 合并相册关联 / 别名,删除源
|
||||
│
|
||||
└── Phase 3:刷新缓存
|
||||
└── 重新加载 albums 到 AlbumsCache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 模块职责
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Pipeline 引擎 | `engine/pipeline.rs` | `phase_parse_all()`、`phase_merge_persons()`、`run_pipeline()` |
|
||||
| 标题解析 | `engine/title_parser.rs` | jieba 分词、人名提取、中英文拆分、别名生成 |
|
||||
| 标签提取 | `engine/tag_extractor.rs` | 分类词库匹配、jieba 名词提取、标点/停用词/坏标签过滤 |
|
||||
| 人物合并 | `engine/person_merger.rs` | 中文核心提取、分组分析、合并执行 |
|
||||
| 导入 | `services/import.rs` | 扫描磁盘目录、创建相册和图片记录 |
|
||||
| DB | `db/metadata.rs` | persons、aliases、album_tags 表的读写 |
|
||||
| 缓存 | `cache/mod.rs` | AlbumsCache + TagCache + PersonCache |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键设计约束
|
||||
|
||||
1. **强制覆盖**:每次管线执行都是完整重解,直接覆盖 DB 数据
|
||||
2. **幂等**:管线可反复执行,不影响正确性
|
||||
3. **串行**:管线内部各阶段顺序执行,不并发
|
||||
4. **多级过滤**:标签经过 5 层过滤才入库,确保质量
|
||||
5. **中文优先**:人名优先中文,中英混杂拆为"主名+别名"
|
||||
6. **纯中/纯英**:人名主体不允许中英混杂
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现清单
|
||||
|
||||
### 6.1 Pipeline 引擎
|
||||
|
||||
- [x] `phase_parse_all()` — 全量解析
|
||||
- [x] `phase_merge_persons()` — 人物合并
|
||||
- [x] `phase_refresh_cache()` — 刷新缓存(在 main.rs 实现)
|
||||
- [x] `run_pipeline(db)` — 整合解析+合并
|
||||
|
||||
### 6.2 main.rs 启动逻辑
|
||||
|
||||
- [x] 后台任务 A:顺序执行 import → run_pipeline → cache refresh
|
||||
- [x] 后台任务 B:定时每 30 分钟 run_pipeline → cache refresh
|
||||
|
||||
### 6.3 API
|
||||
|
||||
- [x] POST /api/pipeline/run → 全量解析 + 人物合并
|
||||
@@ -171,6 +171,13 @@ impl Database {
|
||||
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
/// 彻底删除一个人物(包括别名、相册关联、自身)
|
||||
pub async fn delete_person_completely(&self, person_id: i64) {
|
||||
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
sqlx::query("DELETE FROM persons WHERE id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
/// 把 src_id 的所有相册关联、别名移动到 dst_id,然后删除 src_id
|
||||
pub async fn merge_person_into(&self, src_id: i64, dst_id: i64) {
|
||||
// 1. 移动相册关联
|
||||
@@ -216,6 +223,14 @@ impl Database {
|
||||
.bind(person_name_original).bind(person_name_cleaned).bind(album_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
/// 清空该人物所有关联相册的 person 字段(当该人物被确认为非人物后调用)
|
||||
pub async fn update_album_person_by_person_id(&self, person_id: i64) {
|
||||
sqlx::query(
|
||||
"UPDATE albums SET person_name_original = NULL, person_name_cleaned = NULL, \
|
||||
processed_at = datetime('now') WHERE id IN (SELECT album_id FROM person_albums WHERE person_id = ?)"
|
||||
).bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
pub async fn update_album_tags(&self, album_id: &str, tags_json: &str, all_words_json: &str) {
|
||||
sqlx::query("UPDATE albums SET tags = ?, all_words = ?, processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(tags_json).bind(all_words_json).bind(album_id).execute(&self.pool).await.ok();
|
||||
|
||||
@@ -296,13 +296,24 @@ pub async fn execute_merge(db: &Database) -> MergeResult {
|
||||
// 其余的合并到目标
|
||||
for (src_id, src_name, _, _) in &items[1..] {
|
||||
println!("[合并] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
|
||||
|
||||
// 合并前统计 src 的别名数
|
||||
let src_aliases = db.get_person_aliases(*src_id).await;
|
||||
let alias_count_before = src_aliases.len();
|
||||
|
||||
db.merge_person_into(*src_id, dst_id).await;
|
||||
|
||||
// 合并后统计新增的别名数 (= src 别名数 + src 原名)
|
||||
result.persons_merged += 1;
|
||||
// src 原名 + src 的 display_name + src 原有的别名
|
||||
let moved = alias_count_before + 1; // +1 因为原名会成为 merged_original 别名
|
||||
result.aliases_moved += moved;
|
||||
}
|
||||
}
|
||||
|
||||
if result.persons_merged > 0 {
|
||||
println!("[合并] 完成:分析 {} 组,合并 {} 个人物", result.groups_analyzed, result.persons_merged);
|
||||
println!("[合并] 完成:分析 {} 组,合并 {} 个人物,迁移 {} 个别名",
|
||||
result.groups_analyzed, result.persons_merged, result.aliases_moved);
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -1,91 +1,473 @@
|
||||
//! 处理管线 — 批量处理相册元数据
|
||||
//!
|
||||
//! 核心架构:脏集追踪 + 增量解析
|
||||
//!
|
||||
//! Phase 0 清洗标签标点
|
||||
//! Phase A1 检测标签中的人名并直接提取到 person 字段 → 加入脏集
|
||||
//! Phase A2 清理非人物 → 加入脏集
|
||||
//! Phase A3 收集新相册/半成品 → 加入脏集
|
||||
//! Phase 1 解析脏集相册(增量解析,不扫全库)
|
||||
//! Phase 2 人物合并
|
||||
//! Phase B 后校验标签中的人名
|
||||
//!
|
||||
//! 脏集(DirtySet)贯穿各阶段,每个阶段发现的"需要重新解析"的相册都会加入脏集,
|
||||
//! 最终只处理脏集里的相册,而非全库扫描,大幅提升效率。
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::engine::title_parser;
|
||||
use crate::engine::tag_extractor;
|
||||
use crate::engine::person_merger;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// 处理所有相册的元数据:提取人名 + 标签(单次 jieba 分词)
|
||||
/// 一阶段:一次调用 extract_person_and_tags 同时获取人名和标签
|
||||
pub async fn process_all_albums(db: &Database) -> (usize, usize) {
|
||||
// ════════════════════════════════════════════════
|
||||
// 脏集 — 追踪需要重新解析的相册 ID
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 脏集:记录哪些相册需要重新解析
|
||||
pub struct DirtySet {
|
||||
ids: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DirtySet {
|
||||
pub fn new() -> Self { Self { ids: HashSet::new() } }
|
||||
pub fn add(&mut self, id: &str) { self.ids.insert(id.to_string()); }
|
||||
pub fn extend(&mut self, ids: impl IntoIterator<Item = String>) { self.ids.extend(ids); }
|
||||
pub fn contains(&self, id: &str) -> bool { self.ids.contains(id) }
|
||||
pub fn len(&self) -> usize { self.ids.len() }
|
||||
pub fn is_empty(&self) -> bool { self.ids.is_empty() }
|
||||
pub fn into_inner(self) -> HashSet<String> { self.ids }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 0:清洗已有标签中的头尾标点
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_clean_tags(db: &Database) -> usize {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut person_count = 0;
|
||||
let mut tag_count = 0;
|
||||
|
||||
// 已有人物库时优先用匹配(更准确)
|
||||
let patterns = person_merger::collect_person_patterns(db).await;
|
||||
let use_patterns = !patterns.is_empty();
|
||||
let mut cleaned_count = 0usize;
|
||||
let mut removed_tags = HashSet::new();
|
||||
|
||||
for album in &albums {
|
||||
let title = album.title.as_deref().unwrap_or("");
|
||||
if title.is_empty() { continue; }
|
||||
let tags_json = match album.tags.as_deref() {
|
||||
Some(t) if !t.is_empty() && t != "[]" => t,
|
||||
_ => continue,
|
||||
};
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json) {
|
||||
Ok(t) => t, Err(_) => continue,
|
||||
};
|
||||
if old_tags.is_empty() { continue; }
|
||||
|
||||
// 判断是否需要处理
|
||||
let need_person = album.person_name_original.is_none()
|
||||
|| album.person_name_original.as_deref() == Some("");
|
||||
let need_tags = album.tags.as_deref().unwrap_or("").is_empty()
|
||||
|| album.tags.as_deref() == Some("[]");
|
||||
|
||||
if !need_person && !need_tags { continue; }
|
||||
|
||||
// 一次 jieba 分词,同时获取人名 + 标签
|
||||
let (person_from_jieba, tags_from_jieba) = title_parser::extract_person_and_tags(title);
|
||||
|
||||
// ── 处理人名 ──
|
||||
if need_person {
|
||||
let person_name = if use_patterns {
|
||||
// 已有人物库,优先用匹配;匹配不到再回退到 jieba
|
||||
person_merger::match_title(title, &patterns)
|
||||
.map(|(_, name, _)| name)
|
||||
.or(person_from_jieba)
|
||||
} else {
|
||||
person_from_jieba
|
||||
let mut changed = false;
|
||||
let mut new_tags = Vec::new();
|
||||
for tag in old_tags {
|
||||
let cleaned = match tag_extractor::clean_tag(&tag) {
|
||||
Some(t) => t,
|
||||
None => { removed_tags.insert(tag); changed = true; continue; }
|
||||
};
|
||||
if cleaned != tag { removed_tags.insert(tag); changed = true; }
|
||||
new_tags.push(cleaned);
|
||||
}
|
||||
if !changed { continue; }
|
||||
cleaned_count += 1;
|
||||
|
||||
if let Some(person_name) = person_name {
|
||||
// 拆分中文主名 + 别名
|
||||
let (primary_name, aliases) = title_parser::split_person_name_with_aliases(&person_name);
|
||||
let final_name = if primary_name.is_empty() { person_name.clone() } else { primary_name };
|
||||
db.update_album_person(&album.id, &person_name, &final_name).await;
|
||||
let tags_json_new = serde_json::to_string(&new_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|pair| { let cleaned = tag_extractor::clean_tag(&pair[0])?; Some([cleaned, pair[1].clone()]) })
|
||||
.collect();
|
||||
let all_words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &all_words_json_new).await;
|
||||
}
|
||||
|
||||
// 创建人员(先查是否已有同名)
|
||||
let pid = db.insert_person(&final_name, None).await;
|
||||
db.insert_person_album(pid, &album.id).await;
|
||||
if cleaned_count > 0 {
|
||||
println!("[管线·Phase0] 标签清洗完成:清洗 {} 个相册,移除标签: {:?}",
|
||||
cleaned_count, removed_tags.iter().collect::<Vec<_>>());
|
||||
} else {
|
||||
println!("[管线·Phase0] 标签清洗完成:无需要清洗的标签");
|
||||
}
|
||||
cleaned_count
|
||||
}
|
||||
|
||||
// 保存所有别名
|
||||
for (alias_type, alias_name) in &aliases {
|
||||
if !alias_name.is_empty() && alias_name != &final_name {
|
||||
db.insert_person_alias(pid, alias_type, alias_name).await;
|
||||
}
|
||||
}
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A1:检测标签中的人名并直接提取到 person 字段
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
db.update_person_counts(pid).await;
|
||||
person_count += 1;
|
||||
/// Phase A1:检测标签中可能的人名并直接提取到 person_name
|
||||
///
|
||||
/// 核心变化:不再只标记脏集等待 Phase 1,而是直接从 tags 中提取人名写入 person 字段。
|
||||
/// 因为 tags 中的人名比从 title 重解析更直接、更准确。
|
||||
///
|
||||
/// 两层检测 + 最终写入:
|
||||
/// 1. 逐个标签 — 标签是已知人物或像人名 → 收集为候选人名(中文加权)
|
||||
/// 2. jieba 合并(空格分隔)— 发现跨标签拆分的人名(["杨晨","晨Yome"])
|
||||
/// 3. 最佳候选人名 → 写入 person_name,从标签中移除关联标签
|
||||
///
|
||||
/// 返回 (脏集, 从标签中移除的人名标签数)
|
||||
pub async fn phase_detect_person_in_tags(db: &Database) -> (DirtySet, usize) {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut person_like_removed = 0usize;
|
||||
|
||||
let known_persons: HashSet<String> = db.get_all_person_names().await
|
||||
.into_iter().map(|(n, _)| n.to_lowercase()).collect();
|
||||
|
||||
for album in &albums {
|
||||
let tags_json = match album.tags.as_deref() {
|
||||
Some(t) if !t.is_empty() && t != "[]" => t,
|
||||
_ => continue,
|
||||
};
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json) {
|
||||
Ok(t) => t, Err(_) => continue,
|
||||
};
|
||||
|
||||
let has_person = album.person_name_original.as_deref()
|
||||
.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
// 快速判断是否需要处理
|
||||
let has_known_tag = old_tags.iter().any(|t| known_persons.contains(&t.to_lowercase()));
|
||||
let has_like_tag = !has_person && old_tags.iter().any(|t|
|
||||
title_parser::is_likely_person_name(t) && !title_parser::is_excluded_name(t));
|
||||
if has_person && !has_known_tag && !has_like_tag { continue; }
|
||||
|
||||
// ── 第一层:逐个标签收集候选人名 ──
|
||||
let mut known_person_found: Option<String> = None;
|
||||
let mut person_like_tags: Vec<String> = Vec::new();
|
||||
|
||||
for tag in &old_tags {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
if known_persons.contains(&tag_lower) {
|
||||
known_person_found = Some(tag.clone());
|
||||
continue;
|
||||
}
|
||||
if !has_person && title_parser::is_likely_person_name(tag) && !title_parser::is_excluded_name(tag) {
|
||||
person_like_tags.push(tag.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 处理标签 ──
|
||||
if need_tags {
|
||||
// 用当前 album 的人名(优先已有的,否则用刚提取的)
|
||||
let person = album.person_name_cleaned.as_deref()
|
||||
.or_else(|| album.person_name_original.as_deref());
|
||||
// 从 person_like_tags 中选最优(中文加权)
|
||||
let best_layer1 = person_like_tags.into_iter().max_by_key(|t| {
|
||||
if let Some(cn) = title_parser::extract_cn(t) {
|
||||
if cn.len() >= 2 { cn.len() * 10 + t.len() } else { t.len() }
|
||||
} else { t.len() }
|
||||
});
|
||||
|
||||
// 结合分类词库匹配(高质量标签)+ jieba 名词提取
|
||||
let mut tags = tag_extractor::extract_tags_from_title(title, person);
|
||||
tags.extend(tags_from_jieba);
|
||||
// ── 第二层:空格连接所有标签,用 jieba 提取人名 ──
|
||||
let combined = old_tags.join(" ");
|
||||
let (jieba_name, _) = title_parser::extract_person_and_tags(&combined);
|
||||
|
||||
let unique = tag_extractor::unique_tags(tags);
|
||||
if !unique.is_empty() {
|
||||
let tag_names: Vec<String> = unique.iter().map(|(t, _)| t.clone()).collect();
|
||||
let tags_json = serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words: Vec<[String; 2]> = unique.iter().map(|(t, tp)| [t.clone(), tp.clone()]).collect();
|
||||
let words_json = serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json, &words_json).await;
|
||||
tag_count += 1;
|
||||
// ── 确定最终人名 ──
|
||||
let final_person = known_person_found.or_else(|| {
|
||||
match (&jieba_name, &best_layer1) {
|
||||
(Some(j), Some(b)) => {
|
||||
// jieba 结果更完整 → 用 jieba
|
||||
if j.len() > b.len() || title_parser::is_excluded_name(b) { Some(j.clone()) } else { Some(b.clone()) }
|
||||
}
|
||||
(Some(j), None) => Some(j.clone()),
|
||||
(None, Some(b)) => Some(b.clone()),
|
||||
(None, None) => None,
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(ref person_name) = final_person {
|
||||
// ── 从标签中移除所有人名相关标签 ──
|
||||
let mut changed = false;
|
||||
let mut clean_tags: Vec<String> = Vec::new();
|
||||
let person_lower = person_name.to_lowercase();
|
||||
let (primary_cleaned, _) = title_parser::split_person_name_with_aliases(person_name);
|
||||
let primary_lower = primary_cleaned.to_lowercase();
|
||||
|
||||
for tag in &old_tags {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let should_remove =
|
||||
tag_lower == person_lower // 完全匹配
|
||||
|| tag_lower == primary_lower // 中文核心匹配
|
||||
|| (tag.len() >= 2 && tag.chars().all(|c| c.is_ascii_alphabetic())
|
||||
&& !tag_lower.starts_with("cos")
|
||||
&& person_lower.contains(&tag_lower)) // 英文后缀
|
||||
|| (tag_lower.len() >= 2 && person_lower.contains(&tag_lower)); // 人名包含
|
||||
|
||||
if should_remove {
|
||||
person_like_removed += 1;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
clean_tags.push(tag.clone());
|
||||
}
|
||||
|
||||
if changed || !has_person {
|
||||
// ── 写入 person_name(直接提取,无需等待 Phase 1)──
|
||||
let (primary, aliases) = title_parser::split_person_name_with_aliases(person_name);
|
||||
let final_name = if primary.is_empty() { person_name.clone() } else { primary };
|
||||
db.update_album_person(&album.id, person_name, &final_name).await;
|
||||
|
||||
if !title_parser::is_excluded_name(&final_name) {
|
||||
let pid = db.insert_person(&final_name, None).await;
|
||||
db.insert_person_album(pid, &album.id).await;
|
||||
for (at, an) in &aliases {
|
||||
if !an.is_empty() && an != &final_name {
|
||||
db.insert_person_alias(pid, at, an).await;
|
||||
}
|
||||
}
|
||||
db.update_person_counts(pid).await;
|
||||
}
|
||||
|
||||
// ── 写入清理后的标签 ──
|
||||
let tags_json_new = serde_json::to_string(&clean_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|pair| !known_persons.contains(&pair[0].to_lowercase()))
|
||||
.collect();
|
||||
let words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &words_json_new).await;
|
||||
|
||||
if has_person { dirty.add(&album.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[管线] 处理完成:{} 个人名,{} 个标签", person_count, tag_count);
|
||||
(person_count, tag_count)
|
||||
if person_like_removed > 0 {
|
||||
println!("[管线·PhaseA1] 从标签中检测并提取人名:移除 {} 个人名标签,写入 {} 个相册的 person 字段",
|
||||
person_like_removed, dirty.len());
|
||||
} else {
|
||||
println!("[管线·PhaseA1] 检测标签中的人名:无异常");
|
||||
}
|
||||
(dirty, person_like_removed)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A2:清理 persons 表中的非人物
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_clean_persons(db: &Database) -> (DirtySet, usize) {
|
||||
let persons = db.get_all_persons_with_counts().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut deleted = 0usize;
|
||||
|
||||
for (pid, name, _albums, _images) in &persons {
|
||||
if title_parser::is_excluded_name(name) || !title_parser::is_likely_person_name(name) {
|
||||
let album_list = db.get_person_albums(*pid).await;
|
||||
let album_ids: Vec<String> = album_list.into_iter().map(|a| a.id.to_string()).collect();
|
||||
for aid in &album_ids { dirty.add(aid); }
|
||||
println!("[管线·PhaseA2] 删除非人物:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
|
||||
db.update_album_person_by_person_id(*pid).await;
|
||||
db.delete_person_completely(*pid).await;
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
println!("[管线·PhaseA2] 清理完成:删除 {} 个非人物,标记 {} 个脏相册", deleted, dirty.len());
|
||||
} else {
|
||||
println!("[管线·PhaseA2] 清理完成:无非人物需要删除");
|
||||
}
|
||||
(dirty, deleted)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A3:收集新相册 + 半成品
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_collect_new_albums(db: &Database) -> DirtySet {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut new_count = 0usize;
|
||||
let mut partial_count = 0usize;
|
||||
|
||||
for album in &albums {
|
||||
let tags_empty = album.tags.as_deref()
|
||||
.map(|t| t.is_empty() || t == "[]" || t == "null").unwrap_or(true);
|
||||
if tags_empty { dirty.add(&album.id); new_count += 1; continue; }
|
||||
|
||||
let has_person = album.person_name_original.as_deref()
|
||||
.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
if !has_person { dirty.add(&album.id); partial_count += 1; }
|
||||
}
|
||||
|
||||
println!("[管线·PhaseA3] 收集相册:{} 个全新,{} 个半成品,脏集合计 {} 个", new_count, partial_count, dirty.len());
|
||||
dirty
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 1:增量解析 — 只处理脏集中的相册
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_parse_dirty(db: &Database, dirty_set: &DirtySet) -> (usize, usize) {
|
||||
if dirty_set.is_empty() {
|
||||
println!("[管线·Phase1] 增量解析跳过:脏集为空");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let patterns = person_merger::collect_person_patterns(db).await;
|
||||
let use_patterns = !patterns.is_empty();
|
||||
let mut person_updated = 0usize;
|
||||
let mut tag_updated = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
|
||||
for album in &albums {
|
||||
if !dirty_set.contains(&album.id) { continue; }
|
||||
let title = album.title.as_deref().unwrap_or("");
|
||||
if title.is_empty() { skipped += 1; continue; }
|
||||
|
||||
let (jieba_person, jieba_tags) = title_parser::extract_person_and_tags(title);
|
||||
|
||||
let best_person = if use_patterns {
|
||||
person_merger::match_title(title, &patterns)
|
||||
.map(|(_, name, _)| name).or(jieba_person)
|
||||
} else {
|
||||
jieba_person
|
||||
};
|
||||
|
||||
if let Some(name) = &best_person {
|
||||
let (primary, aliases) = title_parser::split_person_name_with_aliases(name);
|
||||
let final_name = if primary.is_empty() { name.clone() } else { primary };
|
||||
db.update_album_person(&album.id, name, &final_name).await;
|
||||
let pid = db.insert_person(&final_name, None).await;
|
||||
db.insert_person_album(pid, &album.id).await;
|
||||
for (at, an) in &aliases {
|
||||
if !an.is_empty() && an != &final_name {
|
||||
db.insert_person_alias(pid, at, an).await;
|
||||
}
|
||||
}
|
||||
db.update_person_counts(pid).await;
|
||||
person_updated += 1;
|
||||
}
|
||||
|
||||
// 确定标签排除用的人名 — 用最完整的名字
|
||||
// 优先级:person_name_original(完整含英文后缀) > best_person > person_name_cleaned(仅中文)
|
||||
let exclude_person = album.person_name_original.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| best_person.as_deref())
|
||||
.or_else(|| album.person_name_cleaned.as_deref().filter(|s| !s.is_empty()));
|
||||
|
||||
let mut raw_tags = tag_extractor::extract_tags_from_title(title, exclude_person);
|
||||
raw_tags.extend(jieba_tags);
|
||||
let unique = tag_extractor::unique_tags(raw_tags);
|
||||
let tag_names: Vec<String> = unique.iter().map(|(t, _)| t.clone()).collect();
|
||||
|
||||
if !tag_names.is_empty() {
|
||||
let tags_json = serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words: Vec<[String; 2]> = unique.iter().map(|(t, tp)| [t.clone(), tp.clone()]).collect();
|
||||
let words_json = serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json, &words_json).await;
|
||||
tag_updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("[管线·Phase1] 增量解析完成:脏集 {} 个,处理 {} 个,跳过 {} 个,更新人名 {} 个,更新标签 {} 个",
|
||||
dirty_set.len(), person_updated.max(tag_updated), skipped, person_updated, tag_updated);
|
||||
(person_updated, tag_updated)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 2:人物合并
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_merge_persons(db: &Database) -> (usize, usize) {
|
||||
let result = person_merger::execute_merge(db).await;
|
||||
if result.persons_merged > 0 {
|
||||
println!("[管线·Phase2] 人物合并完成:分析 {} 组,合并 {} 人,迁移 {} 个别名",
|
||||
result.groups_analyzed, result.persons_merged, result.aliases_moved);
|
||||
} else {
|
||||
println!("[管线·Phase2] 人物合并完成:无需要合并的人物");
|
||||
}
|
||||
(result.persons_merged, result.aliases_moved)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase B:后校验标签 — 用合并后的人名检查
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_clean_tags_after_parse(db: &Database) -> usize {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let all_person_names: HashSet<String> = db.get_all_person_names().await
|
||||
.into_iter().map(|(n, _)| n.to_lowercase()).collect();
|
||||
let all_aliases: HashSet<String> = db.get_all_aliases().await
|
||||
.into_iter().map(|(a, _)| a.to_lowercase()).collect();
|
||||
let person_or_alias: HashSet<String> = all_person_names.into_iter().chain(all_aliases).collect();
|
||||
|
||||
if person_or_alias.is_empty() {
|
||||
println!("[管线·PhaseB] 后校验标签完成:无人物数据,跳过");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut tag_removed = 0usize;
|
||||
for album in &albums {
|
||||
let tags_json = match album.tags.as_deref() {
|
||||
Some(t) if !t.is_empty() && t != "[]" => t, _ => continue,
|
||||
};
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json) { Ok(t) => t, Err(_) => continue, };
|
||||
|
||||
let mut album_removed = 0usize;
|
||||
let good_tags: Vec<String> = old_tags.into_iter().filter(|t| {
|
||||
if person_or_alias.contains(&t.to_lowercase()) { album_removed += 1; false } else { true }
|
||||
}).collect();
|
||||
|
||||
if album_removed == 0 { continue; }
|
||||
tag_removed += album_removed;
|
||||
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default().into_iter().filter(|pair| !person_or_alias.contains(&pair[0].to_lowercase())).collect();
|
||||
|
||||
let tags_json_new = serde_json::to_string(&good_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &words_json_new).await;
|
||||
}
|
||||
|
||||
if tag_removed > 0 {
|
||||
println!("[管线·PhaseB] 后校验标签完成:移除 {} 个人名/别名", tag_removed);
|
||||
} else {
|
||||
println!("[管线·PhaseB] 后校验标签完成:无需要修复的标签");
|
||||
}
|
||||
tag_removed
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Pipeline 一键执行(7 阶段)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PipelineResult {
|
||||
pub tags_cleaned: usize, // Phase 0
|
||||
pub person_like_tags_removed: usize, // Phase A1
|
||||
pub dirty_albums_detected: usize, // A1+A2+A3
|
||||
pub non_persons_deleted: usize, // Phase A2
|
||||
pub new_albums_found: usize, // Phase A3
|
||||
pub persons_updated: usize, // Phase 1
|
||||
pub tags_updated: usize, // Phase 1
|
||||
pub persons_merged: usize, // Phase 2
|
||||
pub aliases_moved: usize, // Phase 2
|
||||
pub tags_removed_person: usize, // Phase B
|
||||
}
|
||||
|
||||
/// 一键执行完整管线(7 阶段顺序串行)
|
||||
pub async fn run_pipeline(db: &Database) -> PipelineResult {
|
||||
let tags_cleaned = phase_clean_tags(db).await;
|
||||
let (dirty_a1, person_like_tags_removed) = phase_detect_person_in_tags(db).await;
|
||||
let (dirty_a2, non_persons_deleted) = phase_clean_persons(db).await;
|
||||
let dirty_a3 = phase_collect_new_albums(db).await;
|
||||
let new_albums_found = dirty_a3.len();
|
||||
|
||||
let mut master_dirty = DirtySet::new();
|
||||
master_dirty.extend(dirty_a1.into_inner());
|
||||
master_dirty.extend(dirty_a2.into_inner());
|
||||
master_dirty.extend(dirty_a3.into_inner());
|
||||
let dirty_albums_detected = master_dirty.len();
|
||||
|
||||
let (persons_updated, tags_updated) = phase_parse_dirty(db, &master_dirty).await;
|
||||
let (persons_merged, aliases_moved) = phase_merge_persons(db).await;
|
||||
let tags_removed_person = phase_clean_tags_after_parse(db).await;
|
||||
|
||||
PipelineResult {
|
||||
tags_cleaned, person_like_tags_removed, dirty_albums_detected,
|
||||
non_persons_deleted, new_albums_found,
|
||||
persons_updated, tags_updated, persons_merged,
|
||||
aliases_moved, tags_removed_person,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,35 @@ lazy_static! {
|
||||
lazy_static! {
|
||||
static ref WORD_RE: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap();
|
||||
static ref HAS_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
|
||||
/// 提取标签中的有效部分(核心字词,去头尾标点)
|
||||
static ref TAG_CORE_RE: Regex = Regex::new(
|
||||
r#"^[\s.,;:!?'\"‘’“”\-–—()\[\]{}【】《》〈〉、,。;:?!…~·•]+|[\s.,;:!?'\"‘’“”\-–—()\[\]{}【】《》〈〉、,。;:?!…~·•]+$"#
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
/// 判断字符串是否全为标点符号(不含任何字、字母或数字)
|
||||
pub fn is_punctuation_only(s: &str) -> bool {
|
||||
if s.is_empty() { return true; }
|
||||
// 检查是否包含任意"有效字符"(中文字、字母、数字),
|
||||
// 一个都不包含 → 纯标点/符号
|
||||
!s.chars().any(|c| {
|
||||
c.is_ascii_alphanumeric() // ASCII 字母/数字
|
||||
|| ('\u{4e00}'..='\u{9fff}').contains(&c) // CJK 统一表意文字
|
||||
|| ('\u{3400}'..='\u{4dbf}').contains(&c) // CJK 扩展 A
|
||||
|| ('\u{f900}'..='\u{faff}').contains(&c) // CJK 兼容
|
||||
})
|
||||
}
|
||||
|
||||
/// 清洗单个标签:去除头尾标点符号
|
||||
/// 例如 "性感!" → "性感","...比基尼" → "比基尼", " – 室内" → "室内"
|
||||
/// 清洗后如果只剩下标点或为空,则返回 None
|
||||
pub fn clean_tag(tag: &str) -> Option<String> {
|
||||
let cleaned = TAG_CORE_RE.replace_all(tag.trim(), "").trim().to_string();
|
||||
if cleaned.is_empty() || is_punctuation_only(&cleaned) || cleaned.len() < 2 {
|
||||
None
|
||||
} else {
|
||||
Some(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 标签词库(按分类) ──
|
||||
@@ -152,6 +181,13 @@ fn extract_tags_with_jieba(
|
||||
// 也提取中文核心名用于排除
|
||||
if let Some(cn) = title_parser::extract_cn(n) {
|
||||
v.push(cn.to_lowercase());
|
||||
// 中英文混合名的英文后缀也要排除(如 "水淼Aqua" → "aqua")
|
||||
if let Some(rest) = n.strip_prefix(&cn) {
|
||||
let suffix = rest.trim();
|
||||
if !suffix.is_empty() {
|
||||
v.push(suffix.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -163,6 +199,9 @@ fn extract_tags_with_jieba(
|
||||
|
||||
let word_lower = word.to_lowercase();
|
||||
|
||||
// 纯标点/符号跳过
|
||||
if is_punctuation_only(word) { continue; }
|
||||
|
||||
// 排除纯数字
|
||||
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
|
||||
|
||||
@@ -182,14 +221,14 @@ fn extract_tags_with_jieba(
|
||||
"n" | "ng" => "noun", // 普通名词
|
||||
"nz" => "proper", // 其它专名
|
||||
"ns" => "location", // 地名
|
||||
"nt" => "organization", // 机构团体名
|
||||
"vn" => "noun_verb", // 名动词
|
||||
"a" | "ad" | "an" => "adj", // 形容词
|
||||
"v" | "vd" | "vg" => "verb", // 动词
|
||||
"eng" => "english", // 英文词
|
||||
"eng" => "other", // 英文词(归入 other)
|
||||
"x" => "other", // 其它
|
||||
// 排除的词性
|
||||
// 排除的词性(这些不应该作为标签)
|
||||
"nr" | "nrfg" | "nrt" => continue, // 人名类跳过
|
||||
"nt" => continue, // 机构团体名(不是标签)
|
||||
"vn" => continue, // 名动词(分类模糊)
|
||||
"a" | "ad" | "an" => continue, // 形容词(不是标签)
|
||||
"v" | "vd" | "vg" => continue, // 动词(不是标签)
|
||||
"m" | "mq" => continue, // 数词
|
||||
"q" | "qv" | "qt" => continue, // 量词
|
||||
"r" | "rr" | "rz" => continue, // 代词
|
||||
@@ -207,9 +246,6 @@ fn extract_tags_with_jieba(
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// 进一步过滤:太短的英文词或太通用的词
|
||||
if category == "english" && word.len() < 3 { continue; }
|
||||
|
||||
tags.push((word.to_string(), category.to_string()));
|
||||
}
|
||||
|
||||
@@ -231,6 +267,15 @@ pub fn extract_tags_from_title(
|
||||
for part in name.split_whitespace() {
|
||||
title_lower = title_lower.replace(&part.to_lowercase(), " ");
|
||||
}
|
||||
// 中英文混合名:人名的英文后缀也从 title 中移除(如 "水淼Aqua" → "aqua")
|
||||
if let Some(cn) = title_parser::extract_cn(name) {
|
||||
if let Some(rest) = name.strip_prefix(&cn) {
|
||||
let suffix = rest.trim().to_lowercase();
|
||||
if !suffix.is_empty() {
|
||||
title_lower = title_lower.replace(&suffix, " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stop_words = get_stop_words_set();
|
||||
@@ -265,6 +310,7 @@ pub fn extract_tags_from_title(
|
||||
|
||||
// 基础过滤
|
||||
if word.len() < 2 { continue; }
|
||||
if is_punctuation_only(word) { continue; }
|
||||
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
|
||||
if stop_words.contains(word_lower.as_str()) { continue; }
|
||||
if let Some(ref pn) = person_name_lower {
|
||||
@@ -287,8 +333,7 @@ pub fn extract_tags_from_title(
|
||||
|
||||
seen.insert(word_lower.clone());
|
||||
|
||||
let tag_type = if HAS_CHINESE.is_match(word) { "chinese" } else { "english" };
|
||||
tags.push((word.to_string(), tag_type.to_string()));
|
||||
tags.push((word.to_string(), "other".to_string()));
|
||||
}
|
||||
|
||||
tags
|
||||
@@ -302,8 +347,7 @@ pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
|
||||
// 分类排序权重:预设词库分类最优先,然后是 jieba 提取的名词类
|
||||
let category_order: Vec<&str> = vec![
|
||||
"style", "outfit", "scene", "body", "mood", "topic",
|
||||
"noun", "proper", "location", "organization", "noun_verb",
|
||||
"adj", "verb", "chinese", "english", "other"
|
||||
"noun", "proper", "location", "other"
|
||||
];
|
||||
|
||||
let mut sorted_tags = tags;
|
||||
@@ -314,14 +358,38 @@ pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
|
||||
});
|
||||
|
||||
for (tag, tag_type) in sorted_tags {
|
||||
let key = tag.to_lowercase();
|
||||
// 清洗标签:去除头尾标点
|
||||
let cleaned_tag = match clean_tag(&tag) {
|
||||
Some(t) => t,
|
||||
None => continue, // 清洗后为空 → 跳过
|
||||
};
|
||||
// 最终安全过滤:坏标签一律剔除(停用词等)
|
||||
if is_bad_tag(&cleaned_tag) { continue; }
|
||||
let key = cleaned_tag.to_lowercase();
|
||||
if seen.insert(key) {
|
||||
result.push((tag, tag_type));
|
||||
result.push((cleaned_tag, tag_type));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 判断一个标签是否"坏标签"(应该在数据库中清理掉)
|
||||
/// 坏标签定义:标点符号、含头尾标点需要清洗、太短、纯数字、停用词
|
||||
/// 如果标签需要清洗(如 "性感!"),也会被标记为坏标签
|
||||
pub fn is_bad_tag(tag: &str) -> bool {
|
||||
let tag = tag.trim();
|
||||
if tag.is_empty() || tag.len() < 2 { return true; }
|
||||
if tag.chars().all(|c| c.is_ascii_digit()) { return true; }
|
||||
if is_punctuation_only(tag) { return true; }
|
||||
// 如果标签清洗后变短了,说明含有头尾标点 → 坏标签
|
||||
if let Some(cleaned) = clean_tag(tag) {
|
||||
if cleaned != tag { return true; }
|
||||
}
|
||||
let stop_words = get_stop_words_set();
|
||||
if stop_words.contains(&tag.to_lowercase().as_str()) { return true; }
|
||||
false
|
||||
}
|
||||
|
||||
/// 获取标签的中文分类名(用于前端展示)
|
||||
pub fn get_category_label(category: &str) -> &'static str {
|
||||
match category {
|
||||
@@ -334,12 +402,6 @@ pub fn get_category_label(category: &str) -> &'static str {
|
||||
"noun" => "名词",
|
||||
"proper" => "专名",
|
||||
"location" => "地名",
|
||||
"organization" => "机构",
|
||||
"noun_verb" => "名动",
|
||||
"adj" => "形容词",
|
||||
"verb" => "动词",
|
||||
"chinese" => "中文",
|
||||
"english" => "英文",
|
||||
_ => "其他",
|
||||
}
|
||||
}
|
||||
@@ -356,12 +418,6 @@ pub fn get_category_color(category: &str) -> (&'static str, &'static str) {
|
||||
"noun" => ("#eff6ff", "#1d4ed8"), // 蓝灰底 + 深蓝字
|
||||
"proper" => ("#e0e7ff", "#4338ca"), // 靛蓝底 + 深靛字
|
||||
"location"=> ("#ecfdf5", "#047857"), // 翠绿底 + 深绿字
|
||||
"organization" => ("#fef9c3", "#a16207"), // 浅黄底 + 暗黄字
|
||||
"noun_verb" => ("#fce7f3", "#be185d"), // 浅粉底 + 深粉字
|
||||
"adj" => ("#f3e8ff", "#6d28d9"), // 浅紫底 + 深紫字
|
||||
"verb" => ("#fed7aa", "#c2410c"), // 橙底 + 深橙字
|
||||
"chinese" => ("#f1f5f9", "#475569"), // 浅灰底 + 深灰字
|
||||
"english" => ("#f1f5f9", "#64748b"), // 浅灰底 + 灰字
|
||||
_ => ("#f1f5f9", "#64748b"), // 默认
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +96,14 @@ lazy_static! {
|
||||
static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
|
||||
static ref RE_ENGLISH_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap();
|
||||
static ref RE_TRAILING_PUNCT: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap();
|
||||
/// 人名模式:2-5个中文字符 + 可选英文后缀
|
||||
/// 人名模式:2-5个中文字符(纯中文,不混杂英文)
|
||||
/// 中英文混杂的情况由 split_person_name_with_aliases 处理为 主名+别名
|
||||
static ref RE_CN_NAME: Regex = Regex::new(
|
||||
r"([\u{4e00}-\u{9fff}]{2,5}(?:[A-Za-z][A-Za-z0-9_]*)?)"
|
||||
r"([\u{4e00}-\u{9fff}]{2,5})"
|
||||
).unwrap();
|
||||
/// 纯英文人名模式:2-15个英文字母,首字母可大写
|
||||
static ref RE_EN_NAME: Regex = Regex::new(
|
||||
r"([A-Za-z][A-Za-z]{1,14})"
|
||||
).unwrap();
|
||||
/// 常见人名后缀(表明前面是人名)
|
||||
static ref RE_NAME_CONTEXT: Regex = Regex::new(
|
||||
@@ -201,6 +206,7 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
// 策略C: 直接在整个标题中搜索人名模式
|
||||
// 先搜纯中文(优先级更高)
|
||||
for cap in RE_CN_NAME.captures_iter(&t) {
|
||||
let name = clean_name(&cap[1]);
|
||||
if is_likely_person_name(&name) {
|
||||
@@ -208,6 +214,16 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 再搜纯英文
|
||||
for cap in RE_EN_NAME.captures_iter(&t) {
|
||||
let name = clean_name(&cap[1]);
|
||||
// 排除停用词
|
||||
if STOP_WORDS_SET.contains(&name.to_lowercase().as_str()) { continue; }
|
||||
if is_likely_person_name(&name) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -245,13 +261,25 @@ fn extract_person_with_jieba(title: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// 从一个文本片段中提取人名
|
||||
/// 只支持纯中文(2-6字)或纯英文(2-15字),不混杂
|
||||
/// 中文优先级更高
|
||||
fn extract_name_from_segment(segment: &str) -> Option<String> {
|
||||
let seg = segment.trim();
|
||||
if seg.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 尝试直接匹配人名模式
|
||||
// 判断片段是否纯中文
|
||||
let is_pure_cn = seg.chars().all(|c| RE_CHINESE.is_match(&c.to_string()));
|
||||
// 判断片段是否纯英文
|
||||
let is_pure_en = seg.chars().all(|c| c.is_ascii_alphabetic());
|
||||
|
||||
// 策略1: 纯中文片段(2-6字)
|
||||
if is_pure_cn && seg.len() >= 2 && seg.len() <= 6 {
|
||||
return Some(clean_name(seg));
|
||||
}
|
||||
|
||||
// 策略2: 尝试匹配纯中文人名模式
|
||||
if let Some(cap) = RE_CN_NAME.captures(seg) {
|
||||
let name = clean_name(&cap[1]);
|
||||
if name.len() >= 2 {
|
||||
@@ -259,10 +287,20 @@ fn extract_name_from_segment(segment: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果整个片段是纯中文(2-5字),也认为是人名
|
||||
let is_pure_cn = seg.chars().all(|c| RE_CHINESE.is_match(&c.to_string()));
|
||||
if is_pure_cn && seg.len() >= 2 && seg.len() <= 6 {
|
||||
return Some(clean_name(seg));
|
||||
// 策略3: 纯英文片段(2-15字)
|
||||
if is_pure_en && seg.len() >= 2 && seg.len() <= 15 {
|
||||
// 过滤停用词
|
||||
if !STOP_WORDS_SET.contains(&seg.to_lowercase().as_str()) {
|
||||
return Some(clean_name(seg));
|
||||
}
|
||||
}
|
||||
|
||||
// 策略4: 尝试匹配纯英文人名模式
|
||||
if let Some(cap) = RE_EN_NAME.captures(seg) {
|
||||
let name = clean_name(&cap[1]);
|
||||
if name.len() >= 2 && !STOP_WORDS_SET.contains(&name.to_lowercase().as_str()) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
@@ -482,13 +520,18 @@ pub fn extract_person_and_tags(title: &str) -> (Option<String>, Vec<(String, Str
|
||||
}
|
||||
|
||||
/// 从 jieba 分词结果中提取人名
|
||||
/// 返回完整的原始人名(可能含英文后缀),供 split_person_name_with_aliases 拆分为 主名+别名
|
||||
/// 例如 jieba 拆出 "水淼/nr Aqua/eng" → 返回 "水淼Aqua"
|
||||
fn extract_person_from_tags(tags: &[jieba_rs::Tag]) -> Option<String> {
|
||||
let mut candidates: Vec<(String, usize)> = Vec::new();
|
||||
|
||||
for tag in tags {
|
||||
for (i, tag) in tags.iter().enumerate() {
|
||||
let word = tag.word.trim();
|
||||
if word.len() < 2 { continue; }
|
||||
|
||||
// 过滤纯标点
|
||||
if is_punctuation_only(word) { continue; }
|
||||
|
||||
let priority = match tag.tag {
|
||||
"nr" => 0, // 人名(最高优先级)
|
||||
"nrfg" => 1, // 古体人名
|
||||
@@ -499,7 +542,33 @@ fn extract_person_from_tags(tags: &[jieba_rs::Tag]) -> Option<String> {
|
||||
};
|
||||
|
||||
if is_likely_person_name(word) {
|
||||
candidates.push((word.to_string(), priority));
|
||||
// 如果是中文人名,检查后面是否紧跟英文词(如 水淼/nr Aqua/eng)
|
||||
// 拼装成完整原始名,供 split_person_name_with_aliases 拆分为 主名+别名
|
||||
let mut full_name = word.to_string();
|
||||
let is_chinese = word.chars().any(|c| RE_CHINESE.is_match(&c.to_string()));
|
||||
if is_chinese {
|
||||
let mut j = i + 1;
|
||||
while j < tags.len() {
|
||||
let next_word = tags[j].word.trim();
|
||||
let next_tag = tags[j].tag;
|
||||
if next_word.is_empty() { j += 1; continue; }
|
||||
// 英文词(eng)或纯英文字母,且不是停用词
|
||||
if next_tag == "eng" ||
|
||||
(next_word.chars().all(|c| c.is_ascii_alphabetic()) && next_word.len() >= 2 && next_word.len() <= 15) {
|
||||
let lower = next_word.to_lowercase();
|
||||
// 跳过停用词(如 photos、cosplay 等停在停用词之前)
|
||||
let stop_words: std::collections::HashSet<&str> = STOP_WORDS.iter().copied().collect();
|
||||
if !stop_words.contains(lower.as_str()) && !EXCLUDED_LOWER.contains(&lower) {
|
||||
full_name.push_str(next_word);
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push((full_name, priority));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +576,18 @@ fn extract_person_from_tags(tags: &[jieba_rs::Tag]) -> Option<String> {
|
||||
candidates.into_iter().next().map(|(name, _)| clean_name(&name))
|
||||
}
|
||||
|
||||
/// 判断字符串是否全为标点符号(不含任何字、字母或数字)
|
||||
fn is_punctuation_only(s: &str) -> bool {
|
||||
if s.is_empty() { return true; }
|
||||
// 检查是否包含任意"有效字符"(中文字、字母、数字)
|
||||
!s.chars().any(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| ('\u{4e00}'..='\u{9fff}').contains(&c)
|
||||
|| ('\u{3400}'..='\u{4dbf}').contains(&c)
|
||||
|| ('\u{f900}'..='\u{faff}').contains(&c)
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 jieba 分词结果中提取标签(排除人名)
|
||||
fn extract_tags_from_jieba_result(
|
||||
tags: &[jieba_rs::Tag],
|
||||
@@ -529,6 +610,13 @@ fn extract_tags_from_jieba_result(
|
||||
}
|
||||
if let Some(cn) = extract_cn(n) {
|
||||
v.push(cn.to_lowercase());
|
||||
// 中英文混合名的英文后缀也要排除(如 "水淼Aqua" → "aqua")
|
||||
if let Some(rest) = n.strip_prefix(&cn) {
|
||||
let suffix = rest.trim();
|
||||
if !suffix.is_empty() {
|
||||
v.push(suffix.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -540,6 +628,9 @@ fn extract_tags_from_jieba_result(
|
||||
|
||||
let word_lower = word.to_lowercase();
|
||||
|
||||
// 纯标点/符号跳过
|
||||
if is_punctuation_only(word) { continue; }
|
||||
|
||||
// 纯数字跳过
|
||||
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
|
||||
|
||||
@@ -558,14 +649,14 @@ fn extract_tags_from_jieba_result(
|
||||
"n" | "ng" => "noun",
|
||||
"nz" => "proper",
|
||||
"ns" => "location",
|
||||
"nt" => "organization",
|
||||
"vn" => "noun_verb",
|
||||
"a" | "ad" | "an" => "adj",
|
||||
"v" | "vd" | "vg" => "verb",
|
||||
"eng" => "english",
|
||||
"eng" => "other",
|
||||
"x" => "other",
|
||||
// 排除的词性
|
||||
// 排除的词性(这些不应该作为标签)
|
||||
"nr" | "nrfg" | "nrt" => continue, // 人名
|
||||
"nt" => continue, // 机构团体名(不是标签)
|
||||
"vn" => continue, // 名动词(分类模糊)
|
||||
"a" | "ad" | "an" => continue, // 形容词(不是标签)
|
||||
"v" | "vd" | "vg" => continue, // 动词(不是标签)
|
||||
"m" | "mq" => continue,
|
||||
"q" | "qv" | "qt" => continue,
|
||||
"r" | "rr" | "rz" => continue,
|
||||
@@ -583,8 +674,6 @@ fn extract_tags_from_jieba_result(
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if category == "english" && word.len() < 3 { continue; }
|
||||
|
||||
result.push((word.to_string(), category.to_string()));
|
||||
}
|
||||
|
||||
@@ -650,10 +739,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extract_person_name() {
|
||||
assert_eq!(extract_person_name_from_title("杨晨晨Yome 私房写真").unwrap(), "杨晨晨Yome");
|
||||
assert_eq!(extract_person_name_from_title("王馨瑶yanni 比基尼").unwrap(), "王馨瑶yanni");
|
||||
// 纯中文人名(中文优先级更高,英文后缀作为别名处理)
|
||||
assert_eq!(extract_person_name_from_title("杨晨晨Yome 私房写真").unwrap(), "杨晨晨");
|
||||
assert_eq!(extract_person_name_from_title("王馨瑶yanni 比基尼").unwrap(), "王馨瑶");
|
||||
assert_eq!(extract_person_name_from_title("[XiuRen] 杨晨晨 性感").unwrap(), "杨晨晨");
|
||||
assert_eq!(extract_person_name_from_title("美女模特 杨晨晨 私房").unwrap(), "杨晨晨");
|
||||
// 纯英文人名也支持
|
||||
assert!(extract_person_name_from_title("Yome sexy photos").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
119
src/main.rs
119
src/main.rs
@@ -49,86 +49,56 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
let image_processor = Arc::new(image_processor::ImageProcessor::new(config.storage.clone()));
|
||||
|
||||
// 后台任务1: 扫描导入新相册
|
||||
let config_clone = config.clone();
|
||||
let db_import = Arc::clone(&db);
|
||||
tokio::spawn(async move {
|
||||
info!("[后台任务] 开始扫描图片目录并导入新相册...");
|
||||
match services::import::import_existing_data(&config_clone, db_import, None).await {
|
||||
Ok(_) => info!("[后台任务] 相册导入完成"),
|
||||
Err(e) => error!("[后台任务] 相册导入失败: {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
// 后台任务2: 自动解析已有相册的 title(增量,针对老版本 db)
|
||||
let db_parse = Arc::clone(&db);
|
||||
tokio::spawn(async move {
|
||||
// 等待 3 秒让导入任务先开始(导入后也会触发解析,避免重复)
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
info!("[后台任务] 检查是否有未解析的相册(针对老版本 db)...");
|
||||
let (persons, tags) = engine::pipeline::process_all_albums(&db_parse).await;
|
||||
if persons > 0 || tags > 0 {
|
||||
info!("[后台任务] Title 解析完成:{} 个人名,{} 个标签", persons, tags);
|
||||
} else {
|
||||
info!("[后台任务] 所有相册已解析,无需处理");
|
||||
}
|
||||
|
||||
// 解析完成后,执行一次人物合并
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
info!("[后台任务] 执行人物合并分析...");
|
||||
let result = engine::person_merger::execute_merge(&db_parse).await;
|
||||
if result.persons_merged > 0 {
|
||||
info!("[后台任务] 人物合并完成:分析 {} 组,合并 {} 人", result.groups_analyzed, result.persons_merged);
|
||||
} else {
|
||||
info!("[后台任务] 无需要合并的人物");
|
||||
}
|
||||
});
|
||||
|
||||
// 相册内存缓存
|
||||
// 相册内存缓存(必须在管线任务之前创建)
|
||||
let albums_cache = Arc::new(tokio::sync::RwLock::new(AlbumsCache::new()));
|
||||
|
||||
// 初始加载缓存
|
||||
let initial_cache = Arc::clone(&albums_cache);
|
||||
let initial_db = Arc::clone(&db);
|
||||
// ── 启动管线(单次):导入 → 解析 → 合并 → 缓存刷新 ──
|
||||
let boot_config = config.clone();
|
||||
let boot_db = Arc::clone(&db);
|
||||
let boot_cache = Arc::clone(&albums_cache);
|
||||
tokio::spawn(async move {
|
||||
match initial_db.get_all_albums().await {
|
||||
Ok(albums) => {
|
||||
let mut cache = initial_cache.write().await;
|
||||
cache.update(albums);
|
||||
println!("✅ 初始相册缓存加载完成");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ 初始缓存加载失败: {}", e);
|
||||
}
|
||||
// ① 扫描导入新相册
|
||||
info!("[启动管线] ① 扫描导入新相册...");
|
||||
match services::import::import_existing_data(&boot_config, Arc::clone(&boot_db), None).await {
|
||||
Ok(_) => info!("[启动管线] 导入完成"),
|
||||
Err(e) => error!("[启动管线] 导入失败: {}", e),
|
||||
}
|
||||
|
||||
// ② 全量解析 + 人物合并
|
||||
info!("[启动管线] ② 全量解析 + 人物合并...");
|
||||
let result = engine::pipeline::run_pipeline(&boot_db).await;
|
||||
info!("[启动管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
|
||||
result.non_persons_deleted, result.new_albums_found,
|
||||
result.persons_updated, result.tags_updated,
|
||||
result.persons_merged, result.aliases_moved, result.tags_removed_person);
|
||||
|
||||
// ③ 刷新内存缓存
|
||||
info!("[启动管线] ③ 刷新内存缓存...");
|
||||
refresh_albums_cache(&boot_db, &boot_cache).await;
|
||||
info!("[启动管线] 启动管线执行完毕");
|
||||
});
|
||||
|
||||
// 定时任务(每30分钟):刷新缓存 + 人物合并
|
||||
let refresh_cache = Arc::clone(&albums_cache);
|
||||
let refresh_db = Arc::clone(&db);
|
||||
// ── 定时管线(每 30 分钟):解析 → 合并 → 缓存刷新 ──
|
||||
let timer_cache = Arc::clone(&albums_cache);
|
||||
let timer_db = Arc::clone(&db);
|
||||
tokio::spawn(async move {
|
||||
let mut interval = interval(Duration::from_secs(30 * 60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// 1. 执行人物合并
|
||||
info!("[定时任务] 执行人物合并分析...");
|
||||
let result = engine::person_merger::execute_merge(&refresh_db).await;
|
||||
if result.persons_merged > 0 {
|
||||
info!("[定时任务] 人物合并完成:分析 {} 组,合并 {} 人", result.groups_analyzed, result.persons_merged);
|
||||
}
|
||||
// ① 全量解析 + 人物合并(不重复 import)
|
||||
info!("[定时管线] ① 全量解析 + 人物合并...");
|
||||
let result = engine::pipeline::run_pipeline(&timer_db).await;
|
||||
info!("[定时管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
|
||||
result.non_persons_deleted, result.new_albums_found,
|
||||
result.persons_updated, result.tags_updated,
|
||||
result.persons_merged, result.aliases_moved, result.tags_removed_person);
|
||||
|
||||
// 2. 刷新缓存
|
||||
match refresh_db.get_all_albums().await {
|
||||
Ok(albums) => {
|
||||
let mut cache = refresh_cache.write().await;
|
||||
cache.update(albums);
|
||||
println!("🔄 相册缓存定时刷新完成");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ 缓存刷新失败: {}", e);
|
||||
}
|
||||
}
|
||||
// ② 刷新内存缓存
|
||||
info!("[定时管线] ② 刷新内存缓存...");
|
||||
refresh_albums_cache(&timer_db, &timer_cache).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -241,4 +211,17 @@ async fn main() -> std::io::Result<()> {
|
||||
.bind(config.server_address())?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
/// 从 DB 重新加载相册数据到内存缓存
|
||||
async fn refresh_albums_cache(db: &crate::db::Database, cache: &Arc<tokio::sync::RwLock<AlbumsCache>>) {
|
||||
match db.get_all_albums().await {
|
||||
Ok(albums) => {
|
||||
let mut c = cache.write().await;
|
||||
c.update(albums);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ 缓存刷新失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,27 @@ use crate::engine::pipeline;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// POST /api/pipeline/run — 处理所有相册元数据(提取人名+标签)
|
||||
/// POST /api/pipeline/run — 执行完整管线:清洗→检测→清理→解析→合并→校验
|
||||
pub async fn pipeline_run(db: web::Data<Arc<Database>>) -> impl Responder {
|
||||
let (persons, tags) = pipeline::process_all_albums(db.get_ref()).await;
|
||||
let result = pipeline::run_pipeline(db.get_ref()).await;
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"persons_updated": persons,
|
||||
"tags_updated": tags,
|
||||
"message": format!("处理完成:{} 个人名,{} 个标签", persons, tags)
|
||||
"tags_cleaned": result.tags_cleaned,
|
||||
"person_like_tags_removed": result.person_like_tags_removed,
|
||||
"dirty_albums_detected": result.dirty_albums_detected,
|
||||
"non_persons_deleted": result.non_persons_deleted,
|
||||
"new_albums_found": result.new_albums_found,
|
||||
"persons_updated": result.persons_updated,
|
||||
"tags_updated": result.tags_updated,
|
||||
"persons_merged": result.persons_merged,
|
||||
"aliases_moved": result.aliases_moved,
|
||||
"tags_removed_person": result.tags_removed_person,
|
||||
"message": format!(
|
||||
"管线执行完毕:清洗标点{}相册,检测人名标签{},脏集{}相册,删除非人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
|
||||
result.non_persons_deleted, result.new_albums_found,
|
||||
result.persons_updated, result.tags_updated,
|
||||
result.persons_merged, result.aliases_moved, result.tags_removed_person
|
||||
)
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user