update
This commit is contained in:
1016
assets/css/album.css
1016
assets/css/album.css
File diff suppressed because it is too large
Load Diff
1686
assets/css/style.css
1686
assets/css/style.css
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
276
assets/js/app.js
276
assets/js/app.js
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
21
src/main.rs
21
src/main.rs
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user