This commit is contained in:
lychang
2026-07-19 18:57:30 +08:00
parent 879598585b
commit 34fdb18919
11 changed files with 2267 additions and 1531 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,72 +8,150 @@
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-images"></i> <span>流云图库</span></h1>
<p>发现精彩瞬间,浏览精美图片</p>
</div>
<div class="search-container">
<div class="search-box">
<label for="search-input"></label><input type="text" id="search-input" placeholder="搜索图片标题...">
<button onclick="searchAlbums()"><i class="fas fa-search"></i></button>
</div>
<div class="action-buttons">
<button class="random-button" onclick="openRandomAlbum()">
<i class="fas fa-random"></i> 随机相册
</button>
<button class="favorites-button" onclick="showFavorites()">
<i class="fas fa-heart"></i> 收藏夹
</button>
</div>
</div>
<div class="stats" id="stats">
正在加载相册数据...
</div>
<div class="app-layout">
<!-- 热门人物区域 -->
<div class="discover-section" id="people-section" style="display:none;">
<div class="section-header">
<h3><i class="fas fa-users"></i> 热门人物</h3>
<button class="section-toggle" onclick="toggleSection('people')">
<i class="fas fa-chevron-up"></i>
<!-- ============ 左侧边栏(可折叠) ============ -->
<aside class="sidebar" id="sidebar">
<!-- Logo 区域 -->
<div class="sidebar-brand">
<div class="brand-icon"><i class="fas fa-images"></i></div>
<div class="brand-text">流云图库</div>
</div>
<!-- 导航菜单 -->
<nav class="sidebar-nav">
<div class="nav-item active" data-view="home" onclick="switchView('home')">
<i class="fas fa-home"></i>
<span>首页</span>
</div>
<div class="nav-item" data-view="favorites" onclick="showFavorites()">
<i class="fas fa-heart"></i>
<span>收藏夹</span>
</div>
<div class="nav-item" onclick="openRandomAlbum()">
<i class="fas fa-random"></i>
<span>随机相册</span>
</div>
</nav>
<!-- 分隔线 -->
<div class="sidebar-divider"></div>
<!-- 热门标签 -->
<div class="sidebar-section">
<div class="sidebar-section-title">
<i class="fas fa-tags"></i>
<span>热门标签</span>
</div>
<div class="sidebar-tags" id="sidebar-tags">
<!-- 动态填充 -->
</div>
</div>
<!-- 底部折叠按钮 -->
<div class="sidebar-footer">
<button class="collapse-btn" onclick="toggleSidebar()" id="collapse-btn">
<i class="fas fa-chevron-left"></i>
<span>收起侧边栏</span>
</button>
</div>
<div class="people-cloud" id="people-cloud">
<!-- 动态填充 -->
</div>
</div>
</aside>
<!-- 热门标签区域 -->
<div class="discover-section" id="tags-section" style="display:none;">
<div class="section-header">
<h3><i class="fas fa-tags"></i> 热门标签</h3>
<button class="section-toggle" onclick="toggleSection('tags')">
<i class="fas fa-chevron-up"></i>
<!-- ============ 主内容区 ============ -->
<main class="main-content">
<!-- 顶部工具栏 -->
<header class="topbar">
<!-- 移动端菜单按钮 -->
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()">
<i class="fas fa-bars"></i>
</button>
<!-- 搜索框 -->
<div class="search-bar">
<i class="fas fa-search search-icon"></i>
<label for="search-input"></label>
<input type="text" id="search-input" placeholder="搜索相册标题、人物、标签..." onkeydown="if(event.key==='Enter') searchAlbums()">
<button class="search-clear" id="search-clear" onclick="clearSearch()" style="display:none;">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 右侧快捷按钮 -->
<div class="topbar-actions">
<button class="action-btn" onclick="openRandomAlbum()" title="随机相册">
<i class="fas fa-random"></i>
</button>
<button class="action-btn" onclick="showFavorites()" title="收藏夹">
<i class="fas fa-heart"></i>
</button>
</div>
</header>
<!-- 筛选状态条 -->
<div class="filter-bar" id="filter-bar" style="display:none;">
<span class="filter-indicator">
<i class="fas fa-filter"></i>
当前筛选:
</span>
<span class="filter-pill" id="current-filter-pill"></span>
<button class="filter-remove" onclick="clearFilter()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="tag-cloud" id="tag-cloud">
<!-- 动态填充 -->
</div>
</div>
<!-- 当前筛选状态 -->
<div class="filter-status" id="filter-status" style="display:none;">
<span class="filter-label">当前筛选:</span>
<span class="filter-tag" id="current-filter"></span>
<button class="filter-clear" onclick="clearFilter()"><i class="fas fa-times"></i> 清除</button>
</div>
<div class="albums-grid" id="albums-container">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i><br>加载中...
<!-- 内容区域 -->
<div class="content-area">
<!-- 统计条 -->
<div class="stats-bar" id="stats-bar">
<div class="stat-item">
<span class="stat-icon"><i class="fas fa-folder-open"></i></span>
<span class="stat-value" id="stat-albums">0</span>
<span class="stat-label">个相册</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-icon"><i class="fas fa-image"></i></span>
<span class="stat-value" id="stat-images">0</span>
<span class="stat-label">张图片</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-icon"><i class="fas fa-user"></i></span>
<span class="stat-value" id="stat-people">0</span>
<span class="stat-label">位人物</span>
</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">
<i class="fas fa-spinner fa-spin"></i><br>加载中...
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/assets/js/app.js?v=4"></script>
<!-- 移动端遮罩 -->
<div class="mobile-overlay" id="mobile-overlay" onclick="toggleMobileSidebar()"></div>
<script src="/assets/js/app.js?v=5"></script>
</body>
</html>
</html>

View File

@@ -271,6 +271,51 @@ function updateAlbumInfo() {
document.title = `${currentAlbum.title} - 图片展示服务`;
}
// 标签分类映射(与后端 tag_extractor.rs 保持一致)
const TAG_CATEGORIES = {
style: ['日系', '韩系', '森系', '复古', '甜美', '性感', '清新', '冷艳',
'优雅', '知性', '可爱', '俏皮', '温柔', '妩媚', '野性', '清纯',
'时尚', '潮流', '街头', '学院', '文艺', '古典', '民族', '异域',
'japanese', 'korean', 'retro', 'vintage', 'cute', 'sexy',
'elegant', 'fresh', 'sweet', 'wild', 'pure', 'fashion'],
outfit: ['比基尼', '泳装', '泳衣', '旗袍', '制服', '丝袜', '内衣', '文胸',
'包臀裙', '紧身裙', '超短裙', '连衣裙', '半身裙', '短裙', '长裙',
'牛仔裤', '热裤', '短裤', '休闲裤', '西装裤',
'T恤', '衬衫', '毛衣', '针织衫', '外套', '风衣', '大衣', '西装',
'吊带', '抹胸', '露背', '透视', '蕾丝', '镂空', '开叉',
'高跟鞋', '运动鞋', '凉鞋', '拖鞋', '靴子',
'bikini', 'swimsuit', 'stockings', 'lingerie', 'dress',
'skirt', 'jeans', 'shirt', 'sweater', 'coat', 'heels'],
scene: ['室内', '户外', '街拍', '浴室', '浴缸', '床上', '床照', '沙发',
'厨房', '阳台', '窗边', '楼梯', '走廊', '电梯', '车内', '驾驶座',
'海边', '沙滩', '泳池', '温泉', '森林', '草地', '花园', '公园',
'街道', '巷弄', '建筑', '天台', '屋顶', '咖啡馆', '餐厅', '酒店',
'indoor', 'outdoor', 'street', 'beach', 'pool', 'bathroom',
'bedroom', 'kitchen', 'balcony', 'car', 'cafe', 'hotel'],
body: ['美腿', '美胸', '翘臀', '玉足', '美背', '香肩', '蛮腰', '细腰',
'长腿', '酥胸', '丰臀', '白皙', '肌肤', '纹身',
'legs', 'breasts', 'ass', 'feet', 'back', 'shoulders', 'waist',
'skin', 'tattoo'],
mood: ['清纯', '诱惑', '唯美', '清新', '冷艳', '妩媚', '娇羞', '俏皮',
'温柔', '野性', '冷傲', '热情', '慵懒', '迷离', '甜美', '知性',
'pure', 'seductive', 'beautiful', 'fresh', 'cool', 'charming',
'shy', 'cute', 'gentle', 'wild', 'lazy', 'sweet'],
topic: ['写真', '私房', '街拍', '旅拍', '婚纱照', '艺术照', '生活照',
'自拍', '跟拍', '约拍', '商拍',
'photoshoot', 'portrait', 'selfie', 'travel', 'wedding'],
};
// 获取标签的分类
function getTagCategory(tag) {
const tagLower = tag.toLowerCase();
for (const [category, tags] of Object.entries(TAG_CATEGORIES)) {
if (tags.some(t => t.toLowerCase() === tagLower)) {
return category;
}
}
return 'default';
}
// 更新相册元数据(人名 + 标签)点击可搜索
function updateAlbumMeta() {
const metaContainer = document.getElementById('album-meta-tags');
@@ -283,36 +328,46 @@ function updateAlbumMeta() {
chips.push({
text: currentAlbum.person_name_cleaned,
type: 'person',
category: 'person',
query: currentAlbum.person_name_cleaned
});
}
// 添加标签
// 添加标签(支持多种格式)
if (currentAlbum.tags) {
let tagList = [];
try {
const tags = JSON.parse(currentAlbum.tags);
if (Array.isArray(tags)) {
tags.forEach(tag => {
chips.push({
text: tag,
type: 'tag',
query: tag
});
// 格式1: JSON数组 [["标签名", "分类"], ...]
const parsed = JSON.parse(currentAlbum.tags);
if (Array.isArray(parsed)) {
parsed.forEach(item => {
if (Array.isArray(item)) {
tagList.push({ text: item[0], category: item[1] || 'default' });
} else if (typeof item === 'string') {
tagList.push({ text: item, category: getTagCategory(item) });
} else if (item.tag || item.name) {
const t = item.tag || item.name;
const c = item.category || item.type || getTagCategory(t);
tagList.push({ text: t, category: c });
}
});
}
} catch (e) {
// 非JSON格式,当作逗号分隔
// 格式2: 逗号分隔字符串
currentAlbum.tags.split(',').forEach(tag => {
const t = tag.trim();
if (t) {
chips.push({
text: t,
type: 'tag',
query: t
});
}
if (t) tagList.push({ text: t, category: getTagCategory(t) });
});
}
tagList.forEach(t => {
chips.push({
text: t.text,
type: 'tag',
category: t.category,
query: t.text
});
});
}
if (chips.length === 0) {
@@ -323,7 +378,9 @@ function updateAlbumMeta() {
metaContainer.style.display = 'flex';
metaContainer.innerHTML = chips.map(chip => {
const icon = chip.type === 'person' ? 'fa-user' : 'fa-tag';
return `<span class="meta-chip meta-chip-${chip.type}" data-meta-type="${chip.type}" data-meta-query="${encodeURIComponent(chip.query)}">
// 生成 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)}">
<i class="fas ${icon}"></i> ${chip.text}
</span>`;
}).join('');

View File

@@ -14,6 +14,9 @@ let isInFavoritesPage = false; // 标记当前是否在收藏页
let currentFilter = null; // { type: 'tag'|'person', value: string }
let collapsedSections = { people: false, tags: false };
// 侧边栏状态(持久化)
let sidebarCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
// API基础URL
const API_BASE = '/api';
@@ -622,14 +625,32 @@ function updateStats(albums) {
albums = allAlbums;
}
const totalImages = albums.reduce((sum, album) => sum + album.image_count, 0);
document.getElementById('stats').innerHTML = `共发现 ${albums.length} 个相册,包含 ${totalImages} 张图片`;
// 更新统计条
const statAlbums = document.getElementById('stat-albums');
const statImages = document.getElementById('stat-images');
if (statAlbums) statAlbums.textContent = albums.length.toLocaleString();
if (statImages) statImages.textContent = totalImages.toLocaleString();
// 兼容旧版 stats
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) {
const statPeople = document.getElementById('stat-people');
if (statPeople) statPeople.textContent = data[0].total.toLocaleString();
}
}).catch(() => {});
}
// 显示错误信息
function showError(message) {
const container = document.getElementById('albums-container');
container.innerHTML = `<div class="no-results"><i class="fas fa-exclamation-triangle"></i><br>${message}</div>`;
document.getElementById('stats').innerHTML = '';
const oldStats = document.getElementById('stats');
if (oldStats) oldStats.innerHTML = '';
}
// 搜索相册函数
@@ -1237,64 +1258,92 @@ async function loadDiscoverData() {
}
}
// 渲染标签云
// 渲染标签(侧边栏列表)
function renderTagCloud(tags) {
// 新版:侧边栏标签列表
const sidebarTags = document.getElementById('sidebar-tags');
if (sidebarTags && tags && tags.length) {
sidebarTags.innerHTML = tags.slice(0, 25).map(tag => {
const count = tag.album_cnt || tag.count || 1;
const name = tag.tag || tag.name || '';
const isActive = currentFilter && currentFilter.type === 'tag' && currentFilter.value === name;
return `<div class="sidebar-tag ${isActive ? 'active' : ''}" onclick="filterByTag('${name}')">
<span class="sidebar-tag-name">${name}</span>
<span class="sidebar-tag-count">${count}</span>
</div>`;
}).join('');
}
// 兼容旧版:标签云区域
const section = document.getElementById('tags-section');
const cloud = document.getElementById('tag-cloud');
if (!section || !cloud || !tags.length) return;
section.style.display = 'block';
// 计算标签大小范围
const maxCount = Math.max(...tags.map(t => t.album_cnt || t.count || 1));
const minCount = Math.min(...tags.map(t => t.album_cnt || t.count || 1));
cloud.innerHTML = tags.map(tag => {
const count = tag.album_cnt || tag.count || 1;
const name = tag.tag || tag.name || '';
// 计算字体大小 (12px - 22px)
const ratio = maxCount === minCount ? 0.5 : (count - minCount) / (maxCount - minCount);
const fontSize = 12 + ratio * 10;
// 计算热度颜色
const hue = 210 - ratio * 50; // 蓝色到紫色
const opacity = 0.6 + ratio * 0.4;
return `<span class="tag-chip"
style="font-size:${fontSize}px; color:hsl(${hue}, 70%, 45%); opacity:${opacity};"
onclick="filterByTag('${name}')"
title="${count} 个相册">
${name} <small>(${count})</small>
</span>`;
}).join('');
if (section && cloud && tags && tags.length) {
section.style.display = 'block';
const maxCount = Math.max(...tags.map(t => t.album_cnt || t.count || 1));
const minCount = Math.min(...tags.map(t => t.album_cnt || t.count || 1));
cloud.innerHTML = tags.map(tag => {
const count = tag.album_cnt || tag.count || 1;
const name = tag.tag || tag.name || '';
const ratio = maxCount === minCount ? 0.5 : (count - minCount) / (maxCount - minCount);
const fontSize = 12 + ratio * 10;
const hue = 210 - ratio * 50;
const opacity = 0.6 + ratio * 0.4;
return `<span class="tag-chip"
style="font-size:${fontSize}px; color:hsl(${hue}, 70%, 45%); opacity:${opacity};"
onclick="filterByTag('${name}')"
title="${count} 个相册">
${name} <small>(${count})</small>
</span>`;
}).join('');
}
}
// 渲染热门人物
// 渲染热门人物(横向滚动条)
function renderPeopleCloud(people) {
// 新版:横向滚动条
const strip = document.getElementById('people-strip');
const scroll = document.getElementById('people-scroll');
if (strip && scroll && people && people.length) {
strip.style.display = 'flex';
scroll.innerHTML = people.slice(0, 20).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="person-chip-scroll ${isActive ? 'active' : ''}" onclick="filterByPerson('${name}')" title="${albumCount} 个相册">
<div class="person-chip-avatar">${name.charAt(0)}</div>
<div class="person-chip-info">
<div class="person-chip-name">${name}</div>
<div class="person-chip-count">${albumCount} 相册</div>
</div>
</div>`;
}).join('');
}
// 兼容旧版:人物卡片区域
const section = document.getElementById('people-section');
const cloud = document.getElementById('people-cloud');
if (!section || !cloud || !people.length) return;
section.style.display = 'block';
cloud.innerHTML = people.map(p => {
const name = p.name || p.display_name || '';
const albumCount = p.total_albums || 0;
const imageCount = p.total_images || 0;
return `<div class="person-chip" onclick="filterByPerson('${name}')" title="${albumCount} 个相册, ${imageCount} 张图片">
<div class="person-avatar">${name.charAt(0)}</div>
<div class="person-info">
<div class="person-name">${name}</div>
<div class="person-meta">${albumCount} 相册 · ${imageCount} 图</div>
</div>
</div>`;
}).join('');
if (section && cloud && people && people.length) {
section.style.display = 'block';
cloud.innerHTML = people.map(p => {
const name = p.name || p.display_name || '';
const albumCount = p.total_albums || 0;
const imageCount = p.total_images || 0;
return `<div class="person-chip" onclick="filterByPerson('${name}')" title="${albumCount} 个相册, ${imageCount} 张图片">
<div class="person-avatar">${name.charAt(0)}</div>
<div class="person-info">
<div class="person-name">${name}</div>
<div class="person-meta">${albumCount} 相册 · ${imageCount} 图</div>
</div>
</div>`;
}).join('');
}
}
// 按标签筛选
async function filterByTag(tag) {
currentFilter = { type: 'tag', value: tag };
showFilterStatus(`标签: ${tag}`);
renderTagCloud(await fetchTags().catch(() => []));
try {
showSkeleton(6);
@@ -1311,6 +1360,13 @@ async function filterByTag(tag) {
}
}
// 获取标签(辅助函数)
async function fetchTags() {
const resp = await fetch(`${API_BASE}/tags/popular?limit=25`);
if (!resp.ok) return [];
return resp.json();
}
// 按人物筛选
async function filterByPerson(personName) {
currentFilter = { type: 'person', value: personName };
@@ -1323,8 +1379,6 @@ async function filterByPerson(personName) {
const persons = await response.json();
if (persons && persons.length > 0) {
// 先尝试通过人物详情获取相册
const personId = persons[0].id;
try {
const detailResp = await fetch(`${API_BASE}/people/${encodeURIComponent(personName)}`);
if (detailResp.ok) {
@@ -1336,22 +1390,26 @@ async function filterByPerson(personName) {
return;
}
}
} catch (e) {
// 回退到标题搜索
}
} catch (e) { /* 回退 */ }
}
// 回退:使用标题搜索
await performServerSearch(personName);
} catch (error) {
console.error('人物筛选失败:', error);
// 回退到标题搜索
await performServerSearch(personName);
}
}
// 显示筛选状态
function showFilterStatus(text) {
// 新版筛选条
const bar = document.getElementById('filter-bar');
const pill = document.getElementById('current-filter-pill');
if (bar && pill) {
pill.textContent = text;
bar.style.display = 'flex';
}
// 兼容旧版
const status = document.getElementById('filter-status');
const label = document.getElementById('current-filter');
if (status && label) {
@@ -1363,24 +1421,104 @@ function showFilterStatus(text) {
// 清除筛选
function clearFilter() {
currentFilter = null;
// 新版筛选条
const bar = document.getElementById('filter-bar');
if (bar) bar.style.display = 'none';
// 兼容旧版
const status = document.getElementById('filter-status');
if (status) status.style.display = 'none';
// 清空搜索框
const searchInput = document.getElementById('search-input');
if (searchInput) searchInput.value = '';
if (searchInput) {
searchInput.value = '';
updateSearchClear();
}
// 重新渲染标签和人物(清除高亮)
loadDiscoverData();
// 重新加载所有相册
loadAlbums(true);
}
// 折叠/展开区域
// ============ 侧边栏交互 ============
// 初始化侧边栏状态
function initSidebar() {
const sidebar = document.getElementById('sidebar');
if (sidebar && sidebarCollapsed) {
sidebar.classList.add('collapsed');
}
}
// 切换侧边栏折叠
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
sidebarCollapsed = !sidebarCollapsed;
sidebar.classList.toggle('collapsed', sidebarCollapsed);
localStorage.setItem('sidebarCollapsed', sidebarCollapsed);
}
// 移动端切换侧边栏
function toggleMobileSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('mobile-overlay');
if (!sidebar || !overlay) return;
sidebar.classList.toggle('mobile-open');
overlay.classList.toggle('active');
}
// 切换视图(首页/收藏夹)
function switchView(view) {
// 更新导航高亮
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.view === view);
});
// 移动端关闭侧边栏
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('mobile-overlay');
if (sidebar && sidebar.classList.contains('mobile-open')) {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('active');
}
if (view === 'home') {
backToHome();
}
}
// 搜索框清除按钮
function updateSearchClear() {
const input = document.getElementById('search-input');
const clear = document.getElementById('search-clear');
if (input && clear) {
clear.style.display = input.value.length > 0 ? 'flex' : 'none';
}
}
// 清除搜索
function clearSearch() {
const input = document.getElementById('search-input');
if (input) {
input.value = '';
updateSearchClear();
if (!currentFilter) {
loadAlbums(true);
}
}
}
// 折叠/展开区域(兼容旧版)
function toggleSection(name) {
collapsedSections[name] = !collapsedSections[name];
const cloudId = name === 'people' ? 'people-cloud' : 'tag-cloud';
const cloud = document.getElementById(cloudId);
const btn = document.querySelector(`#${name}-section .section-toggle i`);
if (cloud) {
if (collapsedSections[name]) {
cloud.style.display = 'none';
@@ -1392,6 +1530,24 @@ function toggleSection(name) {
}
}
// ============ 初始化 ============
document.addEventListener('DOMContentLoaded', function() {
initSidebar();
// 搜索框输入事件
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', updateSearchClear);
}
// 键盘快捷键:ESC 关闭筛选/搜索
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (currentFilter) clearFilter();
}
});
});
// 导出函数供全局使用
window.searchAlbums = searchAlbums;
window.openRandomAlbum = openRandomAlbum;
@@ -1406,4 +1562,8 @@ window.removeFavoriteFromPreview = removeFavoriteFromPreview;
window.filterByTag = filterByTag;
window.filterByPerson = filterByPerson;
window.clearFilter = clearFilter;
window.toggleSection = toggleSection;
window.toggleSection = toggleSection;
window.toggleSidebar = toggleSidebar;
window.toggleMobileSidebar = toggleMobileSidebar;
window.switchView = switchView;
window.clearSearch = clearSearch;

View File

@@ -171,6 +171,42 @@ impl Database {
sqlx::query("DELETE FROM person_albums WHERE person_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. 移动相册关联
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) SELECT ?, album_id FROM person_albums WHERE person_id = ?")
.bind(dst_id).bind(src_id).execute(&self.pool).await.ok();
// 2. 移动别名
sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) SELECT ?, alias_type, alias_name FROM person_aliases WHERE person_id = ?")
.bind(dst_id).bind(src_id).execute(&self.pool).await.ok();
// 3. 把 src 的原名也作为别名加入 dst
if let Some(src_person) = self.get_person_by_id(src_id).await {
// 原始名作为别名
self.insert_person_alias(dst_id, "merged_original", &src_person.name).await;
// 如果有 display_name 也作为别名
if let Some(dn) = &src_person.display_name {
self.insert_person_alias(dst_id, "merged_display", dn).await;
}
}
// 4. 删除 src 的关联和别名
self.delete_person_albums(src_id).await;
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(src_id).execute(&self.pool).await.ok();
// 5. 删除 src 人物
sqlx::query("DELETE FROM persons WHERE id = ?").bind(src_id).execute(&self.pool).await.ok();
// 6. 更新 dst 的统计数据
self.update_person_counts(dst_id).await;
}
pub async fn get_person_by_id(&self, id: i64) -> Option<Person> {
sqlx::query_as::<_, Person>("SELECT * FROM persons WHERE id = ?")
.bind(id).fetch_optional(&self.pool).await.ok().flatten()
}
pub async fn update_album_person(&self, album_id: &str, person_name_original: &str, person_name_cleaned: &str) {
sqlx::query("UPDATE albums SET person_name_original = ?, person_name_cleaned = ?, processed_at = datetime('now') WHERE id = ?")
.bind(person_name_original).bind(person_name_cleaned).bind(album_id).execute(&self.pool).await.ok();

View File

@@ -269,6 +269,45 @@ pub async fn analyze_groups(db: &Database) -> HashMap<String, Vec<(i64, String,
groups
}
/// 合并结果统计
#[derive(Debug, Clone, Default)]
pub struct MergeResult {
pub groups_analyzed: usize,
pub persons_merged: usize,
pub aliases_moved: usize,
}
/// 执行合并:扫描所有可合并的人物组,合并到相册数最多的那个
/// 返回合并统计
pub async fn execute_merge(db: &Database) -> MergeResult {
let groups = analyze_groups(db).await;
let mut result = MergeResult::default();
result.groups_analyzed = groups.len();
for (_core, items) in &groups {
if items.len() < 2 {
continue;
}
// 第一个(相册数最多)作为目标
let dst_id = items[0].0;
let dst_name = &items[0].1;
// 其余的合并到目标
for (src_id, src_name, _, _) in &items[1..] {
println!("[合并] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
db.merge_person_into(*src_id, dst_id).await;
result.persons_merged += 1;
}
}
if result.persons_merged > 0 {
println!("[合并] 完成:分析 {} 组,合并 {} 个人物", result.groups_analyzed, result.persons_merged);
}
result
}
/// 统计匹配分析
#[derive(Debug, Clone)]
pub struct MatchStats {

View File

@@ -32,13 +32,22 @@ pub async fn process_all_albums(db: &Database) -> (usize, usize) {
};
if let Some(person_name) = person_name {
let (cleaned, _aliases) = title_parser::clean_person_name(&person_name);
let final_name = if cleaned.is_empty() { person_name.clone() } else { cleaned };
// 拆分中文主名 + 别名
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 pid = db.insert_person(&final_name, None).await;
db.insert_person_album(pid, &album.id).await;
// 保存所有别名
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;
}
}
db.update_person_counts(pid).await;
person_count += 1;
}

View File

@@ -1,4 +1,13 @@
//! 标签提取器 — 从标题中提取标签词
//! 标签提取器 — 从标题中提取分类明确、格式美观的标签
//!
//! 标签分类体系:
//! - style: 风格标签(日系/韩系/森系/复古/甜美/性感等)
//! - outfit: 服装标签(比基尼/泳装/旗袍/制服/丝袜/内衣等)
//! - scene: 场景标签(室内/户外/街拍/浴室/床上/车内等)
//! - body: 身体标签(美腿/美胸/翘臀/玉足等)
//! - mood: 情绪标签(清纯/诱惑/唯美/清新/冷艳等)
//! - topic: 主题标签(写真/私房/街拍/旅拍等)
//! - other: 其他关键词
use regex::Regex;
use lazy_static::lazy_static;
@@ -9,16 +18,111 @@ lazy_static! {
static ref HAS_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
}
/// 停用词
// ── 标签词库(按分类) ──
/// 风格标签
const STYLE_TAGS: &[&str] = &[
"日系", "韩系", "森系", "复古", "甜美", "性感", "清新", "冷艳",
"优雅", "知性", "可爱", "俏皮", "温柔", "妩媚", "野性", "清纯",
"时尚", "潮流", "街头", "学院", "文艺", "古典", "民族", "异域",
"japanese", "korean", "retro", "vintage", "cute", "sexy",
"elegant", "fresh", "sweet", "wild", "pure", "fashion",
];
/// 服装标签
const OUTFIT_TAGS: &[&str] = &[
"比基尼", "泳装", "泳衣", "旗袍", "制服", "丝袜", "内衣", "文胸",
"包臀裙", "紧身裙", "超短裙", "连衣裙", "半身裙", "短裙", "长裙",
"牛仔裤", "热裤", "短裤", "休闲裤", "西装裤",
"T恤", "衬衫", "毛衣", "针织衫", "外套", "风衣", "大衣", "西装",
"吊带", "抹胸", "露背", "透视", "蕾丝", "镂空", "开叉",
"高跟鞋", "运动鞋", "凉鞋", "拖鞋", "靴子",
"bikini", "swimsuit", "stockings", "lingerie", "dress",
"skirt", "jeans", "shirt", "sweater", "coat", "heels",
];
/// 场景标签
const SCENE_TAGS: &[&str] = &[
"室内", "户外", "街拍", "浴室", "浴缸", "床上", "床照", "沙发",
"厨房", "阳台", "窗边", "楼梯", "走廊", "电梯", "车内", "驾驶座",
"海边", "沙滩", "泳池", "温泉", "森林", "草地", "花园", "公园",
"街道", "巷弄", "建筑", "天台", "屋顶", "咖啡馆", "餐厅", "酒店",
"indoor", "outdoor", "street", "beach", "pool", "bathroom",
"bedroom", "kitchen", "balcony", "car", "cafe", "hotel",
];
/// 身体标签
const BODY_TAGS: &[&str] = &[
"美腿", "美胸", "翘臀", "玉足", "美背", "香肩", "蛮腰", "细腰",
"长腿", "酥胸", "丰臀", "白皙", "肌肤", "纹身",
"legs", "breasts", "ass", "feet", "back", "shoulders", "waist",
"skin", "tattoo",
];
/// 情绪标签
const MOOD_TAGS: &[&str] = &[
"清纯", "诱惑", "唯美", "清新", "冷艳", "妩媚", "娇羞", "俏皮",
"温柔", "野性", "冷傲", "热情", "慵懒", "迷离", "甜美", "知性",
"pure", "seductive", "beautiful", "fresh", "cool", "charming",
"shy", "cute", "gentle", "wild", "lazy", "sweet",
];
/// 主题标签
const TOPIC_TAGS: &[&str] = &[
"写真", "私房", "街拍", "旅拍", "婚纱照", "艺术照", "生活照",
"自拍", "跟拍", "约拍", "商拍",
"photoshoot", "portrait", "selfie", "travel", "wedding",
];
/// 停用词(完全排除,不做标签)
const STOP_WORDS: &[&str] = &[
"photos", "photo", "pictures", "picture", "images", "image", "video", "videos",
"cosplay", "cos", "and", "the", "of", "in", "a", "an", "is", "to", "for",
"part", "vol", "collection", "special", "extra", "bonus",
"new", "old", "selfie", "official", "original", "preview", "patreon",
"秀人", "网", "写真", "图", "套", "合集", "系列", "作品",
"with", "without", "set", "gallery", "album", "page", "issue",
"model", "idol", "star", "actor", "actress", "singer",
"秀人", "网", "图", "套", "合集", "系列", "作品", "美女", "模特",
"女神", "小姐姐", "性感", "高清", "原版", "无水印", "番号", "编号",
"site", "web", "online", "digital", "exclusive", "premium",
"classic", "photography", "shoot", "shot",
];
/// 从标题中提取标签
/// 全局停用词(标签提取时也排除)
fn get_stop_words_set() -> HashSet<&'static str> {
let mut s = HashSet::new();
for w in STOP_WORDS { s.insert(*w); }
s
}
/// 所有分类词库(用于快速匹配分类)
fn get_tag_categories() -> Vec<(&'static [&'static str], &'static str)> {
vec![
(STYLE_TAGS, "style"),
(OUTFIT_TAGS, "outfit"),
(SCENE_TAGS, "scene"),
(BODY_TAGS, "body"),
(MOOD_TAGS, "mood"),
(TOPIC_TAGS, "topic"),
]
}
/// 判断一个词属于哪个分类
fn classify_tag(word: &str) -> &'static str {
let word_lower = word.to_lowercase();
for (tags, category) in get_tag_categories() {
for tag in tags {
if tag.to_lowercase() == word_lower {
return category;
}
}
}
"other"
}
// ── 核心函数 ──
/// 从标题中提取分类标签
pub fn extract_tags_from_title(
title: &str,
person_name: Option<&str>,
@@ -29,19 +133,55 @@ pub fn extract_tags_from_title(
// 移除人名
if let Some(name) = person_name {
title_lower = title_lower.replace(&name.to_lowercase(), " ");
// 也移除英文名部分
for part in name.split_whitespace() {
title_lower = title_lower.replace(&part.to_lowercase(), " ");
}
}
let stop_words: HashSet<&str> = STOP_WORDS.iter().copied().collect();
let stop_words = get_stop_words_set();
let person_name_lower = person_name.map(|n| n.to_lowercase());
let mut seen = HashSet::new();
// 第一步:优先匹配分类词库中的词(保证高质量标签)
for (tag_list, category) in get_tag_categories() {
for tag in tag_list {
let tag_lower = tag.to_lowercase();
if title_lower.contains(&tag_lower) && !seen.contains(&tag_lower) {
seen.insert(tag_lower);
tags.push((tag.to_string(), category.to_string()));
}
}
}
// 第二步:分词提取其他关键词
for cap in WORD_RE.find_iter(&title_lower) {
let word = cap.as_str().trim();
let word_lower = word.to_lowercase();
// 基础过滤
if word.len() < 2 { continue; }
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
if stop_words.contains(&word.to_lowercase().as_str()) { continue; }
if stop_words.contains(word_lower.as_str()) { continue; }
if let Some(ref pn) = person_name_lower {
if word.to_lowercase().contains(pn.as_str()) { continue; }
if word_lower.contains(pn.as_str()) { continue; }
}
if seen.contains(&word_lower) { continue; }
// 已经在分类词库中匹配过的跳过
let mut already_matched = false;
for (tag_list, _) in get_tag_categories() {
for tag in tag_list {
if tag.to_lowercase() == word_lower {
already_matched = true;
break;
}
}
if already_matched { break; }
}
if already_matched { continue; }
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()));
@@ -50,11 +190,24 @@ pub fn extract_tags_from_title(
tags
}
/// 标签去重并排序
/// 标签去重并排序(分类标签优先)
pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for (tag, tag_type) in tags {
// 分类排序权重
let category_order: Vec<&str> = vec![
"style", "outfit", "scene", "body", "mood", "topic", "chinese", "english", "other"
];
let mut sorted_tags = tags;
sorted_tags.sort_by(|a, b| {
let idx_a = category_order.iter().position(|&c| c == a.1).unwrap_or(999);
let idx_b = category_order.iter().position(|&c| c == b.1).unwrap_or(999);
idx_a.cmp(&idx_b)
});
for (tag, tag_type) in sorted_tags {
let key = tag.to_lowercase();
if seen.insert(key) {
result.push((tag, tag_type));
@@ -63,6 +216,36 @@ pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
result
}
/// 获取标签的中文分类名(用于前端展示)
pub fn get_category_label(category: &str) -> &'static str {
match category {
"style" => "风格",
"outfit" => "服装",
"scene" => "场景",
"body" => "身体",
"mood" => "情绪",
"topic" => "主题",
"chinese" => "中文",
"english" => "英文",
_ => "其他",
}
}
/// 获取标签分类的颜色(用于前端标签着色)
pub fn get_category_color(category: &str) -> (&'static str, &'static str) {
match category {
"style" => ("#f0f9ff", "#0284c7"), // 浅蓝底 + 深蓝字
"outfit" => ("#fdf2f8", "#db2777"), // 浅粉底 + 深粉字
"scene" => ("#f0fdf4", "#15803d"), // 浅绿底 + 深绿字
"body" => ("#fef3c7", "#b45309"), // 浅黄底 + 深黄字
"mood" => ("#faf5ff", "#7c3aed"), // 浅紫底 + 深紫字
"topic" => ("#f0f9ff", "#0369a1"), // 浅蓝底 + 蓝字
"chinese" => ("#f1f5f9", "#475569"), // 浅灰底 + 深灰字
"english" => ("#f1f5f9", "#64748b"), // 浅灰底 + 灰字
_ => ("#f1f5f9", "#64748b"), // 默认
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -74,8 +257,25 @@ mod tests {
Some("杨晨晨Yome"),
);
let names: Vec<&str> = tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(names.contains(&"Night"));
assert!(names.contains(&"Dress"));
assert!(!names.contains(&"photos"));
assert!(!names.contains(&"杨晨晨"));
}
}
#[test]
fn test_classify_tag() {
assert_eq!(classify_tag("日系"), "style");
assert_eq!(classify_tag("比基尼"), "outfit");
assert_eq!(classify_tag("浴室"), "scene");
assert_eq!(classify_tag("美腿"), "body");
assert_eq!(classify_tag("清纯"), "mood");
assert_eq!(classify_tag("写真"), "topic");
assert_eq!(classify_tag("unknown"), "other");
}
#[test]
fn test_category_colors() {
let (bg, fg) = get_category_color("style");
assert_eq!(bg, "#f0f9ff");
assert_eq!(fg, "#0284c7");
}
}

View File

@@ -53,6 +53,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个中文字符 + 可选英文后缀
static ref RE_CN_NAME: Regex = Regex::new(
r"([\u{4e00}-\u{9fff}]{2,5}(?:[A-Za-z][A-Za-z0-9_]*)?)"
).unwrap();
/// 常见人名后缀(表明前面是人名)
static ref RE_NAME_CONTEXT: Regex = Regex::new(
r"(?i)(私房|写真|内衣|比基尼|泳装|旗袍|制服|街拍|室内|户外|浴室|浴室|浴缸|床上|床照|内衣|透视|包臀裙|紧身裙|超短裙|丝袜|美腿|美臀|美胸|翘臀|性感|清纯|甜美|气质|可爱|女神|嫩模|模特)"
).unwrap();
/// 停用词集合
static ref STOP_WORDS_SET: HashSet<&'static str> = {
@@ -73,48 +81,115 @@ lazy_static! {
// ── 人名提取 ──
/// 从标题中提取模特/人物名称
/// 对应 Python tag_index.extract_person_name_from_title
/// 核心策略:
/// 1. 清理标题(移除[工作室]、编号、N photos等)
/// 2. 尝试用分隔符(–—-)切分,第一部分常含人名
/// 3. 扫描匹配"2-5中文+可选英文"模式的词
/// 4. 结合上下文(后面是否跟"写真/私房/性感"等关键词)
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
let title = title.trim();
if title.is_empty() {
return None;
}
// 清理末尾的 "N photos" 等
let title = RE_PHOTOS_SUFFIX.replace(title, "").trim().to_string();
// 步骤1: 基础清理
let mut t = RE_PHOTOS_SUFFIX.replace(title, "").trim().to_string();
// 1. cosplay 格式: "Name cosplay Character – Series"
if let Some(caps) = RE_COSPLAY_BY.captures(&title) {
return Some(caps[3].trim().to_string());
// 步骤2: cosplay 格式优先匹配
if let Some(caps) = RE_COSPLAY_BY.captures(&t) {
return Some(clean_name(caps[3].trim()));
}
if let Some(caps) = RE_COSPLAY.captures(&title) {
return Some(caps[1].trim().to_string());
if let Some(caps) = RE_COSPLAY.captures(&t) {
return Some(clean_name(caps[1].trim()));
}
// 2. 中文摄影工作室格式
let title = RE_BRACKET_PREFIX.replace(&title, "").trim().to_string();
// 步骤3: 移除 [工作室] 前缀
t = RE_BRACKET_PREFIX.replace(&t, "").trim().to_string();
// 移除模特称呼前缀
let mut title = title;
// 步骤4: 移除模特称呼前缀
for prefix in MODEL_TITLE_PREFIX {
if title.starts_with(prefix) {
title = title[prefix.len()..].trim().to_string();
if t.starts_with(prefix) {
t = t[prefix.len()..].trim().to_string();
break;
}
}
// 移除编号后缀
let title = RE_NUMBER_SUFFIX.replace(&title, "").trim().to_string();
// 步骤5: 移除编号后缀
t = RE_NUMBER_SUFFIX.replace(&t, "").trim().to_string();
// 用 `–—\-` 分割,取第一部分作为人名
let parts: Vec<&str> = RE_SPLIT_DASH.split(&title).collect();
let name = parts[0].trim().to_string();
// 步骤6: 用分隔符切分,检查每一部分
let parts: Vec<&str> = RE_SPLIT_DASH.split(&t).collect();
if name.len() >= 2 {
Some(name)
} else {
None
// 策略A: 检查第一部分(分隔符前的内容最可能包含人名)
if let Some(first) = parts.first() {
if let Some(name) = extract_name_from_segment(first) {
if !is_excluded_name(&name) {
return Some(name);
}
}
}
// 策略B: 如果第一部分没找到,扫描所有部分
// 优先选择后面跟有关键词的部分
for i in 0..parts.len() {
let part = parts[i];
// 检查这一部分后面是否跟有"写真/私房"等关键词
let has_context_after = if i + 1 < parts.len() {
RE_NAME_CONTEXT.is_match(parts[i + 1])
} else {
false
};
if let Some(name) = extract_name_from_segment(part) {
if !is_excluded_name(&name) {
if has_context_after {
return Some(name); // 有关键词上下文,优先返回
}
}
}
}
// 策略C: 直接在整个标题中搜索人名模式
for cap in RE_CN_NAME.captures_iter(&t) {
let name = clean_name(&cap[1]);
if !is_excluded_name(&name) {
return Some(name);
}
}
None
}
/// 从一个文本片段中提取人名
fn extract_name_from_segment(segment: &str) -> Option<String> {
let seg = segment.trim();
if seg.is_empty() {
return None;
}
// 尝试直接匹配人名模式
if let Some(cap) = RE_CN_NAME.captures(seg) {
let name = clean_name(&cap[1]);
if name.len() >= 2 {
return Some(name);
}
}
// 如果整个片段是纯中文(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));
}
None
}
/// 清理人名:移除前后空白和标点
fn clean_name(name: &str) -> String {
let name = name.trim();
// 移除末尾标点
let name = RE_TRAILING_PUNCT.replace(name, "").trim().to_string();
name
}
/// 提取中文核心(开头的连续中文字符)
@@ -204,6 +279,57 @@ pub fn get_person_key(name: &str) -> String {
.collect()
}
/// 拆分人名为「中文主名 + 别名列表」
/// 规则:
/// - 如果有中文部分:主名 = 中文部分,别名 = 英文后缀 + 原始全名 + 括号内容
/// - 如果只有英文:主名 = 英文全名,别名 = 原始全名
///
/// 示例:
/// - 杨晨晨Yome -> 主名"杨晨晨", 别名["Yome", "杨晨晨Yome"]
/// - 杨晨晨Sugar -> 主名"杨晨晨", 别名["Sugar", "杨晨晨Sugar"]
/// - Yome -> 主名"Yome", 别名["Yome"]
pub fn split_person_name_with_aliases(raw_name: &str) -> (String, Vec<(String, String)>) {
let (cleaned, mut aliases) = clean_person_name(raw_name);
let mut primary_name = cleaned.clone();
// 尝试提取中文主名
if let Some(cn_part) = extract_cn(&cleaned) {
if !cn_part.is_empty() && cn_part != cleaned {
primary_name = cn_part.clone();
// 提取英文后缀作为别名
if let Some(rest) = cleaned.strip_prefix(&cn_part) {
let english_suffix = rest.trim().to_string();
if !english_suffix.is_empty() {
// 避免重复
let already_has = aliases.iter().any(|(_, a)| a == &english_suffix);
if !already_has {
aliases.push(("english".to_string(), english_suffix));
}
}
}
}
}
// 原始完整名也作为别名(用于搜索匹配)
if !raw_name.trim().is_empty() && raw_name.trim() != primary_name {
let already_has = aliases.iter().any(|(_, a)| a == raw_name.trim());
if !already_has {
aliases.push(("original".to_string(), raw_name.trim().to_string()));
}
}
// cleaned 名也作为别名(如果和主名不同)
if cleaned != primary_name {
let already_has = aliases.iter().any(|(_, a)| a == &cleaned);
if !already_has {
aliases.push(("cleaned".to_string(), cleaned));
}
}
(primary_name, aliases)
}
// ── 标签提取 ──
/// 从标题中提取标签(排除人名部分)
@@ -282,4 +408,28 @@ mod tests {
assert_eq!(cleaned, "美女模特 杨晨晨Yome");
assert!(!aliases.is_empty());
}
#[test]
fn test_split_person_name_with_aliases() {
// 中文 + 英文后缀
let (name, aliases) = split_person_name_with_aliases("杨晨晨Yome");
assert_eq!(name, "杨晨晨");
let alias_names: Vec<&str> = aliases.iter().map(|(_, a)| a.as_str()).collect();
assert!(alias_names.contains(&"Yome"));
assert!(alias_names.contains(&"杨晨晨Yome"));
// 同一人,不同英文名
let (name2, aliases2) = split_person_name_with_aliases("杨晨晨Sugar");
assert_eq!(name2, "杨晨晨");
let alias_names2: Vec<&str> = aliases2.iter().map(|(_, a)| a.as_str()).collect();
assert!(alias_names2.contains(&"Sugar"));
// 只有中文
let (name3, _) = split_person_name_with_aliases("杨晨晨");
assert_eq!(name3, "杨晨晨");
// 只有英文
let (name4, _) = split_person_name_with_aliases("Yome");
assert_eq!(name4, "Yome");
}
}

View File

@@ -72,6 +72,16 @@ async fn main() -> std::io::Result<()> {
} 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!("[后台任务] 无需要合并的人物");
}
});
// 相册内存缓存
@@ -93,13 +103,22 @@ async fn main() -> std::io::Result<()> {
}
});
// 定时刷新缓存(每30分钟)
// 定时任务(每30分钟):刷新缓存 + 人物合并
let refresh_cache = Arc::clone(&albums_cache);
let refresh_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);
}
// 2. 刷新缓存
match refresh_db.get_all_albums().await {
Ok(albums) => {
let mut cache = refresh_cache.write().await;