This commit is contained in:
lychang
2026-07-19 23:56:28 +08:00
parent 34bcd67682
commit 36969a4fa0
16 changed files with 1925 additions and 350 deletions

204
Cargo.lock generated
View File

@@ -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"

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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;
}
/* ============ 主内容区 ============ */

View File

@@ -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">

View File

@@ -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>` : ''}

View File

@@ -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();
}
}

View 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
View 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 → 全量解析 + 人物合并

View File

@@ -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();

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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"), // 默认
}
}

View File

@@ -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]

View File

@@ -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);
}
}
}

View File

@@ -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
)
}))
}