待看名单支持数据库同步

This commit is contained in:
2025-10-14 08:31:40 +08:00
parent 5be8ae9520
commit 54b9abfeeb
12 changed files with 686 additions and 106 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000
+3 -3
View File
@@ -147,10 +147,10 @@ const filteredAndSortedItems = computed(() => {
otherItems = filteredItems;
}
// 对其他项目进行排序
// 对其他项目进行排序(按更新时间排序)
otherItems.sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
const dateA = new Date(a.updatedAt || a.createdAt).getTime();
const dateB = new Date(b.updatedAt || b.createdAt).getTime();
return sortOrder.value === 'desc' ? dateB - dateA : dateA - dateB;
});
@@ -33,7 +33,7 @@
<div class="form-group">
<label>URL或路由路径</label>
<input :value="url" @input="$emit('update:url', ($event.target as HTMLInputElement).value)" type="text"
class="form-input" placeholder="例如: /artist/12345?page=2 或 http://localhost:3001/artwork/98765"
class="form-input" placeholder="例如: /artist/12345?page=2 或 http://localhost:3000/artwork/98765"
@keyup.enter="handleSave">
<small class="form-help">
支持完整URL或相对路径/artist/12345/search?keyword=插画
@@ -86,8 +86,8 @@
<textarea :value="batchUrls"
@input="$emit('update:batchUrls', ($event.target as HTMLTextAreaElement).value)" class="form-textarea"
rows="8" placeholder="请输入多个URL,每行一个,例如:
http://localhost:3001/artist/72143697
http://localhost:3001/artist/103047332
http://localhost:3000/artist/72143697
http://localhost:3000/artist/103047332
/artist/113088709
/artwork/98765?page=2
@@ -49,6 +49,27 @@
@update:search-query="$emit('update:searchQuery', $event)" @clear-search="$emit('clearSearch')"
@toggle-sort="$emit('toggleSort')" />
<!-- 存储模式设置 -->
<div class="storage-config">
<div class="storage-row">
<span class="storage-label">存储模式</span>
<select v-model="selectedStorageMode" class="storage-select">
<option value="json">JSON文件存储</option>
<option value="database">MySQL数据库存储</option>
</select>
<span class="config-indicator" :class="{ unsaved: hasStorageModeChanges, saved: !hasStorageModeChanges }">
<SvgIcon :name="hasStorageModeChanges ? 'warning' : 'check'" class="indicator-icon" />
{{ hasStorageModeChanges ? '配置已修改' : `当前模式: ${storageModeText}` }}
</span>
<button class="save-config-btn" :disabled="configLoading || !hasStorageModeChanges" @click="saveStorageMode">
保存
</button>
<button class="reset-config-btn" :disabled="configLoading || !hasStorageModeChanges" @click="resetStorageMode">
重置
</button>
</div>
</div>
<!-- 内容区域 -->
<WatchlistContent :loading="loading" :error="error" :items="items" :filtered-items="filteredItems"
:search-query="searchQuery" :is-current-url="isCurrentUrl" :is-duplicate-author="isDuplicateAuthor"
@@ -58,7 +79,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useWatchlistStore } from '@/stores/watchlist';
import WatchlistControls from './WatchlistControls.vue';
import WatchlistContent from './WatchlistContent.vue';
@@ -143,6 +164,31 @@ const handleFileImport = async (event: Event) => {
target.value = '';
}
};
// 存储模式相关逻辑
const selectedStorageMode = ref<'json' | 'database'>('json');
const configLoading = computed(() => watchlistStore.configLoading);
const hasStorageModeChanges = computed(() => selectedStorageMode.value !== watchlistStore.storageMode);
const storageModeText = computed(() => watchlistStore.storageMode === 'database' ? 'MySQL数据库存储' : 'JSON文件存储');
const saveStorageMode = async () => {
if (configLoading.value || !hasStorageModeChanges.value) return;
const ok = await watchlistStore.saveStorageModeConfig(selectedStorageMode.value);
if (!ok) return;
// 成功后同步选择值并刷新列表与配置
selectedStorageMode.value = watchlistStore.storageMode;
await watchlistStore.fetchConfig();
await watchlistStore.fetchItems();
};
const resetStorageMode = () => {
selectedStorageMode.value = watchlistStore.storageMode;
};
onMounted(async () => {
await watchlistStore.fetchConfig();
selectedStorageMode.value = watchlistStore.storageMode;
});
</script>
<style scoped>
@@ -150,7 +196,7 @@ const handleFileImport = async (event: Event) => {
position: absolute;
top: 50px;
left: 0;
width: 400px;
width: 570px;
max-height: 600px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
@@ -198,6 +244,65 @@ const handleFileImport = async (event: Event) => {
gap: 12px;
}
.storage-config {
display: flex;
align-items: center;
padding: 10px 16px;
gap: 8px;
border-bottom: 1px solid var(--color-border);
}
.storage-row {
display: flex;
align-items: center;
gap: 10px;
}
.storage-label {
font-size: 12px;
color: var(--color-text-secondary);
}
.storage-select {
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.config-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.config-indicator.saved {
color: var(--color-success, #16a34a);
}
.config-indicator.unsaved {
color: var(--color-warning, #ca8a04);
}
.indicator-icon {
width: 14px;
height: 14px;
}
.save-config-btn,
.reset-config-btn {
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
cursor: pointer;
}
.save-config-btn:disabled,
.reset-config-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.item-count-text {
font-size: 12px;
color: var(--color-text-secondary);
+44 -1
View File
@@ -22,6 +22,11 @@ export interface UpdateWatchlistItemParams {
url?: string;
}
// 存储配置接口
export interface WatchlistConfig {
storageMode: 'json' | 'database';
}
class WatchlistService {
/**
* 获取所有待看项目
@@ -50,7 +55,45 @@ class WatchlistService {
async deleteItem(id: string): Promise<ApiResponse<WatchlistItem[]>> {
return apiService.delete<WatchlistItem[]>(`/api/watchlist/${id}`);
}
/**
* 导出待看名单(后端)
*/
async export(): Promise<ApiResponse<{ version: string; exportTime: string; items: WatchlistItem[] }>> {
return apiService.get<{ version: string; exportTime: string; items: WatchlistItem[] }>(
'/api/watchlist/export'
);
}
/**
* 导入待看名单(后端)
*/
async import(data: any, importMode: 'merge' | 'overwrite' = 'merge'): Promise<
ApiResponse<{ message: string; stats: { successCount: number; skipCount: number; errorCount: number; deletedCount: number }; items: WatchlistItem[] }>
> {
return apiService.post(
'/api/watchlist/import',
{
watchlistData: data,
importMode
}
);
}
/**
* 获取待看名单存储配置
*/
async getConfig(): Promise<ApiResponse<WatchlistConfig>> {
return apiService.get<WatchlistConfig>('/api/watchlist/config');
}
/**
* 更新待看名单存储配置
*/
async updateConfig(storageMode: 'json' | 'database'): Promise<ApiResponse<WatchlistConfig>> {
return apiService.put<WatchlistConfig>('/api/watchlist/config', { storageMode });
}
}
export const watchlistService = new WatchlistService();
export default watchlistService;
export default watchlistService;
+84 -89
View File
@@ -1,12 +1,14 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { watchlistService, type WatchlistItem, type AddWatchlistItemParams, type UpdateWatchlistItemParams } from '@/services/watchlist';
import { watchlistService, type WatchlistItem, type AddWatchlistItemParams, type UpdateWatchlistItemParams, type WatchlistConfig } from '@/services/watchlist';
export const useWatchlistStore = defineStore('watchlist', () => {
// 状态
const items = ref<WatchlistItem[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const configLoading = ref(false);
const storageMode = ref<'json' | 'database'>('json');
// 计算属性
const itemCount = computed(() => items.value.length);
@@ -37,6 +39,44 @@ export const useWatchlistStore = defineStore('watchlist', () => {
}
};
// 获取存储配置
const fetchConfig = async () => {
try {
configLoading.value = true;
const response = await watchlistService.getConfig();
if (response.success && response.data) {
storageMode.value = (response.data.storageMode ?? 'json') as 'json' | 'database';
return true;
} else {
throw new Error(response.error || '获取存储配置失败');
}
} catch (err) {
handleError(err, '获取存储配置失败');
return false;
} finally {
configLoading.value = false;
}
};
// 保存存储模式
const saveStorageModeConfig = async (mode: 'json' | 'database') => {
try {
configLoading.value = true;
const response = await watchlistService.updateConfig(mode);
if (response.success && response.data) {
storageMode.value = response.data.storageMode;
return true;
} else {
throw new Error(response.error || '更新存储配置失败');
}
} catch (err) {
handleError(err, '更新存储配置失败');
return false;
} finally {
configLoading.value = false;
}
};
// 添加待看项目
const addItem = async (params: AddWatchlistItemParams) => {
try {
@@ -184,106 +224,57 @@ export const useWatchlistStore = defineStore('watchlist', () => {
});
};
// 导出待看名单数据
const exportWatchlist = () => {
const exportData = {
version: '1.0',
exportTime: new Date().toISOString(),
items: items.value.map(item => ({
id: item.id,
title: item.title,
url: item.url,
createdAt: item.createdAt,
updatedAt: item.updatedAt
}))
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `watchlist-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
// 导出待看名单数据(通过后端)
const exportWatchlist = async () => {
try {
loading.value = true;
error.value = null;
const response = await watchlistService.export();
if (response.success && response.data) {
const dataStr = JSON.stringify(response.data, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `watchlist-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
return { success: true };
} else {
throw new Error(response.error || '导出待看名单失败');
}
} catch (err) {
handleError(err, '导出待看名单失败');
return { success: false };
} finally {
loading.value = false;
}
};
// 导入待看名单数据
// 导入待看名单数据(通过后端)
const importWatchlist = async (file: File, importMode: 'merge' | 'overwrite' = 'merge') => {
try {
loading.value = true;
error.value = null;
const text = await file.text();
const importData = JSON.parse(text);
// 验证数据格式
if (!importData.items || !Array.isArray(importData.items)) {
throw new Error('无效的导入文件格式');
}
// 统计导入结果
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
let deletedCount = 0;
// 如果是覆盖模式,先删除所有现有项目
if (importMode === 'overwrite') {
const allItems = items.value;
for (const item of allItems) {
try {
await deleteItem(item.id);
deletedCount++;
} catch (err) {
console.error('删除项目失败:', item, err);
}
}
}
for (const item of importData.items) {
try {
// 在重合模式下检查是否已存在
if (importMode === 'merge' && hasUrl(item.url)) {
skipCount++;
continue;
}
// 添加项目
const success = await addItem({
url: item.url,
title: item.title
});
if (success) {
successCount++;
} else {
errorCount++;
}
} catch (err) {
console.error('导入项目失败:', item, err);
errorCount++;
}
}
let message = '';
if (importMode === 'overwrite') {
message = `覆盖导入完成:删除 ${deletedCount} 项,成功添加 ${successCount} 项,失败 ${errorCount}`;
const response = await watchlistService.import(importData, importMode);
if (response.success && response.data) {
// 刷新本地列表
items.value = response.data.items || items.value;
return { success: true, message: response.data.message, stats: response.data.stats };
} else {
message = `重合导入完成:成功 ${successCount} 项,跳过 ${skipCount} 项,失败 ${errorCount}`;
throw new Error(response.error || '导入待看名单失败');
}
return {
success: true,
message,
stats: { successCount, skipCount, errorCount, deletedCount }
};
} catch (err) {
console.error('导入失败:', err);
return {
success: false,
message: err instanceof Error ? err.message : '导入失败',
stats: { successCount: 0, skipCount: 0, errorCount: 0, deletedCount: 0 }
};
handleError(err, '导入待看名单失败');
return { success: false, message: error.value || '导入待看名单失败', stats: { successCount: 0, skipCount: 0, errorCount: 0, deletedCount: 0 } };
} finally {
loading.value = false;
}
};
@@ -292,11 +283,15 @@ export const useWatchlistStore = defineStore('watchlist', () => {
items,
loading,
error,
configLoading,
storageMode,
// 计算属性
itemCount,
hasItems,
// 方法
fetchItems,
fetchConfig,
saveStorageModeConfig,
addItem,
updateItem,
deleteItem,