注册表新增数据库存储同步

This commit is contained in:
2025-10-11 11:56:19 +08:00
parent 4e34063373
commit 47c68cadf3
23 changed files with 4396 additions and 160 deletions
+3 -1
View File
@@ -18,4 +18,6 @@ export const navigationIcons = {
'cleanup-history2': 'M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z',
'loading': 'M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z',
'menu': 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z',
}
'test-connect':'M228.864 257.28a231.850667 231.850667 0 0 0-27.562667 90.282667c-5.888 59.306667 9.472 111.701333 43.733334 147.626666l56.32 59.52a58.624 58.624 0 0 0 40.96 18.261334h1.578666c14.976 0 29.44-5.76 40.277334-16.085334l28.288-26.752 38.442666 39.381334-25.6 24.192a58.538667 58.538667 0 0 0-2.218666 82.858666l56.32 59.477334c31.658667 33.408 79.189333 51.754667 133.845333 51.754666 44.458667 0 88.533333-12.16 118.528-31.914666l185.898667 185.898666a29.312 29.312 0 0 0 41.429333-41.386666l-187.733333-187.776a231.850667 231.850667 0 0 0 27.52-90.282667c5.802667-59.136-9.557333-111.786667-43.52-147.712l-56.32-59.477333a58.581333 58.581333 0 0 0-40.96-18.304h-1.877334c-14.976 0-29.44 5.76-40.277333 16.085333l-28.8 27.392-38.442667-39.253333 26.325334-24.96a58.624 58.624 0 0 0 2.176-82.816l-56.32-59.52c-31.616-33.365333-79.146667-51.754667-133.802667-51.754667-44.458667 0-88.533333 12.202667-118.528 31.957333L137.258667 82.858667a29.312 29.312 0 0 0-41.429334 41.386666L228.864 257.28z m427.349333 198.4l56.32 59.52c49.92 52.608 26.197333 156.16-5.12 185.728-16.725333 15.872-54.954667 28.501333-94.421333 28.501333-33.706667 0-68.266667-9.173333-91.306667-33.450666l-56.32-59.52 190.848-180.778667z m-104.96 18.773333l-64.853333 61.44-38.4-39.338666 64.853333-61.696 38.4 39.594666zM292.693333 269.141333c16.597333-15.872 54.784-28.501333 94.293334-28.501333 33.664 0 68.266667 9.216 91.264 33.493333l56.32 59.477334-190.677334 180.736-56.32-59.477334C237.653333 402.218667 261.376 298.666667 292.693333 269.141333z'
}
+1
View File
@@ -19,6 +19,7 @@ export const LARGE_VIEWBOX_ICONS: Record<string, string> = {
'trending-up': '0 0 1024 1024',
'trending-down': '0 0 1024 1024',
'database': '0 0 1024 1024',
'test-connect': '0 0 1024 1024',
}
/**
@@ -0,0 +1,831 @@
<template>
<div v-if="visible" class="modal-overlay" @click="handleOverlayClick">
<div class="database-config-modal" @click.stop>
<div class="modal-header">
<h3>数据库配置</h3>
<button @click="$emit('close')" class="close-btn" title="关闭">
<SvgIcon name="close" class="close-icon" />
</button>
</div>
<div class="modal-content">
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<LoadingSpinner text="处理中..." />
</div>
<!-- 错误信息 -->
<div v-else-if="error" class="error">
<ErrorMessage :error="error" @dismiss="clearError" />
</div>
<!-- 成功提示 -->
<div v-if="successMessage" class="success-message">
<div class="success-content">
<SvgIcon name="success" class="success-icon" />
<span>{{ successMessage }}</span>
</div>
</div>
<!-- 配置表单 -->
<div class="config-form">
<div class="form-section">
<h4>连接设置</h4>
<div class="form-group">
<label for="db-host">主机地址</label>
<input
id="db-host"
v-model="config.host"
type="text"
class="form-input"
placeholder="localhost"
:disabled="loading"
/>
<small class="form-help">MySQL服务器的主机地址</small>
</div>
<div class="form-group">
<label for="db-port">端口</label>
<input
id="db-port"
v-model.number="config.port"
type="number"
class="form-input"
placeholder="3306"
min="1"
max="65535"
:disabled="loading"
/>
<small class="form-help">MySQL服务器端口默认为3306</small>
</div>
<div class="form-group">
<label for="db-user">用户名</label>
<input
id="db-user"
v-model="config.user"
type="text"
class="form-input"
placeholder="root"
:disabled="loading"
/>
<small class="form-help">数据库用户名</small>
</div>
<div class="form-group">
<label for="db-password">密码</label>
<div class="password-input-group">
<input
id="db-password"
v-model="config.password"
:type="showPassword ? 'text' : 'password'"
class="form-input"
placeholder="请输入密码"
:disabled="loading"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="password-toggle"
:title="showPassword ? '隐藏密码' : '显示密码'"
>
<SvgIcon :name="showPassword ? 'eye-off' : 'eye'" class="toggle-icon" />
</button>
</div>
<small class="form-help">数据库密码</small>
</div>
<div class="form-group">
<label for="db-database">数据库名</label>
<input
id="db-database"
v-model="config.database"
type="text"
class="form-input"
placeholder="pixiv_d"
:disabled="loading"
/>
<small class="form-help">要使用的数据库名称如果不存在将自动创建</small>
</div>
</div>
<div class="form-section">
<h4>连接选项</h4>
<div class="form-group">
<label for="db-connection-limit">连接池大小</label>
<input
id="db-connection-limit"
v-model.number="config.connectionLimit"
type="number"
class="form-input"
placeholder="10"
min="1"
max="100"
:disabled="loading"
/>
<small class="form-help">连接池最大连接数建议5-20之间</small>
</div>
<div class="form-group">
<label for="db-timeout">连接超时 ()</label>
<input
id="db-timeout"
v-model.number="config.acquireTimeout"
type="number"
class="form-input"
placeholder="60"
min="5"
max="300"
:disabled="loading"
/>
<small class="form-help">获取连接的超时时间</small>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input
v-model="config.ssl"
type="checkbox"
class="form-checkbox"
:disabled="loading"
/>
启用SSL连接
</label>
<small class="form-help">是否使用SSL加密连接到数据库</small>
</div>
</div>
</div>
<!-- 连接测试结果 -->
<div v-if="testResult" class="test-result" :class="testResult.success ? 'success' : 'error'">
<div class="test-result-header">
<SvgIcon :name="testResult.success ? 'success' : 'error'" class="result-icon" />
<span class="result-title">{{ testResult.success ? '连接成功' : '连接失败' }}</span>
</div>
<div v-if="testResult.message" class="result-message">{{ testResult.message }}</div>
<div v-if="testResult.details" class="result-details">
<div v-for="(value, key) in testResult.details" :key="key" class="detail-item">
<span class="detail-key">{{ key }}:</span>
<span class="detail-value">{{ value }}</span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button
@click="testConnection"
class="btn btn-secondary"
:disabled="loading || !isConfigValid"
>
<SvgIcon name="test-connect" class="btn-icon" />
{{ loading ? '测试中...' : '测试连接' }}
</button>
<button
@click="saveConfig"
class="btn btn-primary"
:disabled="loading || !isConfigValid"
>
<SvgIcon name="save" class="btn-icon" />
{{ loading ? '保存中...' : '保存配置' }}
</button>
<button
@click="$emit('close')"
class="btn btn-outline"
:disabled="loading"
>
取消
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import SvgIcon from './SvgIcon.vue';
import LoadingSpinner from './LoadingSpinner.vue';
import ErrorMessage from './ErrorMessage.vue';
import databaseService from '@/services/database';
interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
connectionLimit: number;
acquireTimeout: number;
ssl: boolean;
}
interface TestResult {
success: boolean;
message?: string;
details?: Record<string, any>;
}
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
saved: [config: DatabaseConfig];
}>();
// 响应式数据
const loading = ref(false);
const error = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const showPassword = ref(false);
const testResult = ref<TestResult | null>(null);
// 默认配置
const defaultConfig: DatabaseConfig = {
host: 'localhost',
port: 3306,
user: 'root',
password: '',
database: 'pixiv_d',
connectionLimit: 10,
acquireTimeout: 60,
ssl: false
};
const config = ref<DatabaseConfig>({ ...defaultConfig });
// 计算属性
const isConfigValid = computed(() => {
return config.value.host.trim() !== '' &&
config.value.port > 0 &&
config.value.user.trim() !== '' &&
config.value.database.trim() !== '';
});
// 方法
const clearError = () => {
error.value = null;
};
const clearSuccess = () => {
successMessage.value = null;
};
const showSuccess = (message: string) => {
successMessage.value = message;
setTimeout(() => {
successMessage.value = null;
}, 3000);
};
const handleOverlayClick = (event: MouseEvent) => {
// 移除点击外部自动关闭功能,用户需要手动点击关闭按钮
// if (event.target === event.currentTarget) {
// emit('close');
// }
};
// 测试数据库连接
const testConnection = async () => {
if (!isConfigValid.value) {
error.value = '请填写完整的数据库配置信息';
return;
}
loading.value = true;
error.value = null;
testResult.value = null;
try {
const result = await databaseService.testConnection(config.value);
if (result.success) {
testResult.value = {
success: true,
message: '数据库连接测试成功',
details: result.data
};
showSuccess('数据库连接测试成功');
} else {
testResult.value = {
success: false,
message: result.error || '连接测试失败'
};
error.value = result.error || '连接测试失败';
}
} catch (err: any) {
const errorMessage = err.message || '连接测试失败';
testResult.value = {
success: false,
message: errorMessage
};
error.value = errorMessage;
} finally {
loading.value = false;
}
};
// 保存配置
const saveConfig = async () => {
if (!isConfigValid.value) {
error.value = '请填写完整的数据库配置信息';
return;
}
loading.value = true;
error.value = null;
try {
const result = await databaseService.saveConfig(config.value);
if (result.success) {
showSuccess('数据库配置保存成功');
emit('saved', config.value);
// 延迟关闭模态框
setTimeout(() => {
emit('close');
}, 1500);
} else {
error.value = result.error || '保存配置失败';
}
} catch (err: any) {
error.value = err.message || '保存配置失败';
} finally {
loading.value = false;
}
};
// 加载现有配置
const loadConfig = async () => {
loading.value = true;
error.value = null;
try {
const result = await databaseService.getConfig();
if (result.success && result.data) {
config.value = { ...defaultConfig, ...result.data };
}
} catch (err: any) {
console.warn('加载数据库配置失败:', err.message);
// 不显示错误,使用默认配置
} finally {
loading.value = false;
}
};
// 监听模态框显示状态
watch(() => props.visible, (visible) => {
if (visible) {
// 重置状态
error.value = null;
successMessage.value = null;
testResult.value = null;
// 加载现有配置
loadConfig();
}
});
// 清除成功消息的定时器
watch(successMessage, (message) => {
if (message) {
setTimeout(() => {
if (successMessage.value === message) {
successMessage.value = null;
}
}, 3000);
}
});
onMounted(() => {
if (props.visible) {
loadConfig();
}
});
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.database-config-modal {
background: var(--color-bg-primary);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.modal-header h3::before {
content: '🗄️';
font-size: 16px;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.close-icon {
width: 16px;
height: 16px;
}
.modal-content {
padding: 24px;
max-height: calc(90vh - 200px);
overflow-y: auto;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.error {
margin-bottom: 20px;
}
.success-message {
margin-bottom: 20px;
padding: 12px 16px;
background: var(--color-success-light, #f0f9ff);
border: 1px solid var(--color-success, #10b981);
border-radius: 8px;
color: var(--color-success-dark, #065f46);
}
.success-content {
display: flex;
align-items: center;
gap: 8px;
}
.success-icon {
width: 16px;
height: 16px;
color: var(--color-success);
}
.config-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-section {
background: var(--color-bg-secondary);
padding: 20px;
border-radius: 8px;
border: 1px solid var(--color-border);
}
.form-section h4 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.form-section h4::before {
content: '';
width: 3px;
height: 16px;
background: var(--color-primary);
border-radius: 2px;
}
.form-group {
margin-bottom: 16px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.form-input {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 14px;
outline: none;
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.form-input:disabled {
background: var(--color-bg-tertiary);
color: var(--color-text-tertiary);
cursor: not-allowed;
}
.form-input::placeholder {
color: var(--color-text-tertiary);
}
.password-input-group {
position: relative;
display: flex;
align-items: center;
}
.password-toggle {
position: absolute;
right: 8px;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.password-toggle:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.toggle-icon {
width: 14px;
height: 14px;
}
.form-help {
display: block;
margin-top: 4px;
font-size: 12px;
color: var(--color-text-tertiary);
line-height: 1.4;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 500;
color: var(--color-text-primary);
}
.form-checkbox {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.test-result {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
border: 1px solid;
}
.test-result.success {
background: var(--color-success-light, #f0f9ff);
border-color: var(--color-success, #10b981);
color: var(--color-success-dark, #065f46);
}
.test-result.error {
background: var(--color-danger-light, #fef2f2);
border-color: var(--color-danger, #ef4444);
color: var(--color-danger-dark, #991b1b);
}
.test-result-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.result-icon {
width: 16px;
height: 16px;
}
.result-title {
font-weight: 600;
font-size: 14px;
}
.result-message {
font-size: 14px;
margin-bottom: 8px;
}
.result-details {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
opacity: 0.8;
}
.detail-item {
display: flex;
gap: 8px;
}
.detail-key {
font-weight: 500;
min-width: 80px;
}
.detail-value {
flex: 1;
}
.modal-footer {
display: flex;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid var(--color-border);
justify-content: flex-end;
background: var(--color-bg-secondary);
}
.btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-icon {
width: 14px;
height: 14px;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--color-info);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-info-hover);
transform: translateY(-1px);
}
.btn-outline {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-outline:hover:not(:disabled) {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border-color: var(--color-border-hover);
}
/* 响应式设计 */
@media (max-width: 768px) {
.database-config-modal {
width: 95%;
max-height: 95vh;
}
.modal-content {
padding: 16px;
max-height: calc(95vh - 160px);
}
.modal-header,
.modal-footer {
padding: 16px;
}
.form-section {
padding: 16px;
}
.modal-footer {
flex-direction: column-reverse;
}
.btn {
width: 100%;
justify-content: center;
}
}
</style>
+474 -4
View File
@@ -60,6 +60,92 @@
<div class="registry-config">
<h4>配置选项</h4>
<div class="config-form">
<!-- 存储模式配置 -->
<div class="config-section">
<div class="config-section-title">
<SvgIcon name="database" class="section-icon" />
存储模式配置
</div>
<div class="storage-mode-section">
<div class="storage-options">
<div class="storage-option" :class="{ active: selectedStorageMode === 'json' }">
<label>
<input type="radio" v-model="selectedStorageMode" value="json" :disabled="migrationLoading" />
<div class="option-content">
<div class="option-header">
<SvgIcon name="file" class="option-icon" />
<span class="option-title">JSON文件存储</span>
<span class="option-badge basic">默认</span>
</div>
<small class="option-description">使用本地JSON文件存储注册表数据简单可靠</small>
</div>
</label>
</div>
<div class="storage-option" :class="{ active: selectedStorageMode === 'database', disabled: !databaseConnected }">
<label>
<input type="radio" v-model="selectedStorageMode" value="database" :disabled="migrationLoading || !databaseConnected" />
<div class="option-content">
<div class="option-header">
<SvgIcon name="database" class="option-icon" />
<span class="option-title">MySQL数据库存储</span>
<span class="option-badge advanced" :class="{ connected: databaseConnected }">
{{ databaseConnected ? '已连接' : '未连接' }}
</span>
</div>
<small class="option-description">使用MySQL数据库存储注册表数据支持高并发和大数据量</small>
</div>
</label>
</div>
</div>
<div class="storage-config-actions">
<div class="config-status">
<span v-if="hasStorageModeChanges" class="config-indicator unsaved">
<SvgIcon name="warning" class="indicator-icon" />
配置已修改需要保存
</span>
<span v-else class="config-indicator saved">
<SvgIcon name="check" class="indicator-icon" />
当前模式: {{ storageMode === 'json' ? 'JSON文件存储' : 'MySQL数据库存储' }}
</span>
</div>
<div class="action-buttons">
<button
@click="applyStorageModeConfig"
class="btn btn-enhanced btn-primary"
:disabled="migrationLoading || !hasStorageModeChanges || (selectedStorageMode === 'database' && !databaseConnected)"
>
<SvgIcon name="save" class="btn-icon" />
{{ migrationLoading ? '应用中...' : '应用配置' }}
</button>
<button
@click="resetStorageModeConfig"
class="btn btn-enhanced btn-secondary"
:disabled="migrationLoading || !hasStorageModeChanges"
>
<SvgIcon name="refresh" class="btn-icon" />
重置
</button>
</div>
</div>
<div class="database-actions">
<button @click="openDatabaseConfig" class="btn btn-enhanced btn-secondary" :disabled="migrationLoading">
<SvgIcon name="settings" class="btn-icon" />
数据库配置
</button>
<div v-if="migrationLoading" class="migration-status">
<LoadingSpinner text="数据迁移中..." />
</div>
</div>
</div>
</div>
<!-- 检测模式配置 -->
<div class="config-section">
<div class="config-section-title">
<SvgIcon name="settings" class="section-icon" />
@@ -75,7 +161,7 @@
<span class="option-title">注册表检测</span>
<span class="option-badge recommended">推荐</span>
</div>
<small class="option-description">优先使用JSON注册表检测作品是否已下载速度最快</small>
<small class="option-description">优先使用注册表检测作品是否已下载速度最快</small>
</div>
</label>
</div>
@@ -205,6 +291,13 @@
</div>
</div>
</div>
<!-- 数据库配置模态框 -->
<DatabaseConfigModal
:visible="showDatabaseConfig"
@close="closeDatabaseConfig"
@saved="handleDatabaseConfigSaved"
/>
</div>
</template>
@@ -213,14 +306,28 @@ import { ref, onMounted, watch, computed, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useRegistryStore } from '@/stores/registry';
import downloadService from '@/services/download';
import databaseService from '@/services/database';
import SvgIcon from './SvgIcon.vue';
import LoadingSpinner from './LoadingSpinner.vue';
import ErrorMessage from './ErrorMessage.vue';
import DatabaseConfigModal from './DatabaseConfigModal.vue';
const registryStore = useRegistryStore();
const isOpen = ref(false);
const successMessage = ref<string | null>(null);
// 数据库配置相关状态
const showDatabaseConfig = ref(false);
const databaseConnected = ref(false);
const storageMode = ref<'json' | 'database'>('json');
const selectedStorageMode = ref<'json' | 'database'>('json'); // 用户选择的存储模式
const migrationLoading = ref(false);
// 计算属性:检查存储模式是否有变更
const hasStorageModeChanges = computed(() => {
return selectedStorageMode.value !== storageMode.value;
});
// 从store中获取响应式数据
const { stats, loading, error, config } = storeToRefs(registryStore);
@@ -460,6 +567,25 @@ const updateDetectionMethod = async () => {
}
};
// 保存存储模式配置
const saveStorageModeConfig = async (mode: 'json' | 'database') => {
try {
const result = await registryStore.updateConfig({
useRegistryCheck: config.value.useRegistryCheck,
fallbackToScan: config.value.fallbackToScan,
storageMode: mode
});
if (result.success) {
console.log('存储模式配置已保存:', mode);
} else {
console.warn('保存存储模式配置失败:', result.error);
}
} catch (error) {
console.error('保存存储模式配置时出错:', error);
}
};
// 更新配置(保留原方法以防其他地方调用)
const updateConfig = async () => {
const result = await registryStore.updateConfig({
@@ -490,6 +616,98 @@ const showError = (message: string) => {
registryStore.error = message;
};
// 数据库配置相关方法
const openDatabaseConfig = () => {
showDatabaseConfig.value = true;
};
const closeDatabaseConfig = () => {
showDatabaseConfig.value = false;
};
const handleDatabaseConfigSaved = async () => {
showDatabaseConfig.value = false;
await checkDatabaseConnection();
showSuccess('数据库配置已保存');
};
const checkDatabaseConnection = async () => {
try {
const result = await databaseService.getConnectionStatus();
databaseConnected.value = result.success && (result.data?.connected || false);
} catch (error) {
console.error('检查数据库连接失败:', error);
databaseConnected.value = false;
}
};
// 应用存储模式配置
const applyStorageModeConfig = async () => {
if (selectedStorageMode.value === 'database' && !databaseConnected.value) {
showError('请先配置并连接数据库');
return;
}
if (selectedStorageMode.value === storageMode.value) {
return;
}
const confirmMessage = selectedStorageMode.value === 'database'
? '确定要切换到数据库存储模式吗?这将使用MySQL数据库存储注册表数据。'
: '确定要切换到JSON文件存储模式吗?这将使用本地JSON文件存储注册表数据。';
if (!confirm(confirmMessage)) {
return;
}
try {
migrationLoading.value = true;
if (selectedStorageMode.value === 'database') {
// 从JSON迁移到数据库
const result = await databaseService.migrateData('json-to-db');
if (result.success) {
storageMode.value = 'database';
showSuccess(`成功迁移到数据库存储,处理了 ${result.data?.recordsProcessed || 0} 条记录`);
} else {
throw new Error(result.error || '迁移到数据库失败');
}
} else {
// 从数据库迁移到JSON
const result = await databaseService.migrateData('db-to-json');
if (result.success) {
storageMode.value = 'json';
showSuccess(`成功迁移到JSON存储,处理了 ${result.data?.recordsProcessed || 0} 条记录`);
} else {
throw new Error(result.error || '迁移到JSON失败');
}
}
// 保存存储模式配置到后端
await saveStorageModeConfig(selectedStorageMode.value);
// 刷新统计信息
await refreshStats();
} catch (error) {
console.error('存储模式切换失败:', error);
showError(`存储模式切换失败: ${error instanceof Error ? error.message : '未知错误'}`);
// 恢复选择状态
selectedStorageMode.value = storageMode.value;
} finally {
migrationLoading.value = false;
}
};
// 重置存储模式配置
const resetStorageModeConfig = () => {
selectedStorageMode.value = storageMode.value;
};
const switchStorageMode = async (mode: 'json' | 'database') => {
// 保留原函数以防其他地方调用,但现在只是更新选择状态
selectedStorageMode.value = mode;
};
// 格式化日期
const formatDate = (dateString?: string): string => {
if (!dateString) return '未知';
@@ -507,18 +725,35 @@ const initDetectionMethod = () => {
}
};
// 初始化存储模式
const initStorageMode = () => {
if (config.value.storageMode) {
storageMode.value = config.value.storageMode;
selectedStorageMode.value = config.value.storageMode; // 同时更新选择状态
} else {
storageMode.value = 'json'; // 默认值
selectedStorageMode.value = 'json';
}
};
// 组件挂载时初始化
onMounted(async () => {
// 从后端获取配置并初始化检测方法
// 从后端获取配置并初始化检测方法和存储模式
try {
await registryStore.fetchConfig();
initDetectionMethod();
initStorageMode();
} catch (error) {
console.error('获取配置失败:', error);
// 如果获取配置失败,使用默认值
detectionMethod.value = 'hybrid';
storageMode.value = 'json';
selectedStorageMode.value = 'json';
}
// 检查数据库连接状态
await checkDatabaseConnection();
// 初始化时加载统计数据
refreshStats();
});
@@ -528,9 +763,10 @@ onUnmounted(() => {
stopProgressPolling();
});
// 监听配置变化,自动更新检测方法
// 监听配置变化,自动更新检测方法和存储模式
watch(config, () => {
initDetectionMethod();
initStorageMode();
}, { deep: true });
</script>
@@ -1703,7 +1939,241 @@ watch(config, () => {
font-size: 1rem;
}
/* 响应式设计 - 进度显示 */
/* 存储模式配置样式 */
.storage-mode-section {
display: flex;
flex-direction: column;
gap: var(--spacing-lg, 1rem);
}
.storage-options {
display: flex;
flex-direction: column;
gap: var(--spacing-md, 0.75rem);
}
.storage-option {
background: var(--color-bg-primary, white);
border: 2px solid var(--color-border-light, #e2e8f0);
border-radius: var(--radius-lg, 0.5rem);
padding: var(--spacing-md, 0.75rem);
transition: all var(--transition-normal);
cursor: pointer;
position: relative;
overflow: hidden;
}
.storage-option:hover {
border-color: var(--color-border-hover, #cbd5e1);
box-shadow: var(--shadow-sm);
}
.storage-option.active {
border-color: var(--color-primary, #3b82f6);
background: var(--color-primary-light, #eff6ff);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.storage-option.disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--color-bg-tertiary, #f8fafc);
}
.storage-option.disabled:hover {
border-color: var(--color-border-light, #e2e8f0);
box-shadow: none;
}
.storage-option label {
display: flex;
align-items: flex-start;
gap: var(--spacing-md, 0.75rem);
cursor: pointer;
width: 100%;
}
.storage-option.disabled label {
cursor: not-allowed;
}
.storage-option input[type="radio"] {
width: 1.25rem;
height: 1.25rem;
margin-top: 0.125rem;
accent-color: var(--color-primary);
cursor: pointer;
flex-shrink: 0;
}
.storage-option.disabled input[type="radio"] {
cursor: not-allowed;
}
.option-badge.advanced {
background: var(--color-info-light, #e0f2fe);
color: var(--color-info-text, #0c4a6e);
border: 1px solid var(--color-info-border, #7dd3fc);
}
.option-badge.advanced.connected {
background: var(--color-success-light, #dcfce7);
color: var(--color-success-text, #166534);
border: 1px solid var(--color-success-border, #bbf7d0);
}
.database-actions {
display: flex;
align-items: center;
gap: var(--spacing-md, 0.75rem);
padding: var(--spacing-md, 0.75rem);
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-md, 0.375rem);
border: 1px solid var(--color-border-light, #e2e8f0);
}
.migration-status {
display: flex;
align-items: center;
gap: var(--spacing-sm, 0.5rem);
color: var(--color-text-secondary, #6b7280);
font-size: 0.875rem;
}
/* 存储配置操作样式 */
.storage-config-actions {
display: flex;
flex-direction: column;
gap: var(--spacing-md, 0.75rem);
padding: var(--spacing-md, 0.75rem);
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-md, 0.375rem);
border: 1px solid var(--color-border-light, #e2e8f0);
}
.config-status {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm, 0.5rem);
}
.config-indicator {
display: flex;
align-items: center;
gap: var(--spacing-xs, 0.25rem);
font-size: 0.875rem;
font-weight: 500;
padding: var(--spacing-xs, 0.25rem) var(--spacing-sm, 0.5rem);
border-radius: var(--radius-sm, 0.25rem);
transition: all var(--transition-normal);
}
.config-indicator.saved {
color: var(--color-success-text, #166534);
background: var(--color-success-light, #dcfce7);
border: 1px solid var(--color-success-border, #bbf7d0);
}
.config-indicator.unsaved {
color: var(--color-warning-text, #92400e);
background: var(--color-warning-light, #fef3c7);
border: 1px solid var(--color-warning-border, #fbbf24);
animation: pulse 2s ease-in-out infinite;
}
.indicator-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.storage-config-actions .action-buttons {
display: flex;
gap: var(--spacing-sm, 0.5rem);
justify-content: center;
}
.storage-config-actions .btn {
flex: 1;
min-width: 0;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* 配置节样式增强 */
.config-section {
margin-bottom: var(--spacing-xl, 1.5rem);
background: var(--color-bg-primary, white);
border: 1px solid var(--color-border-light, #e2e8f0);
border-radius: var(--radius-lg, 0.5rem);
overflow: hidden;
position: relative;
}
.config-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
}
.config-section-title {
display: flex;
align-items: center;
gap: var(--spacing-sm, 0.5rem);
padding: var(--spacing-lg, 1rem);
background: var(--color-bg-secondary, #f8fafc);
border-bottom: 1px solid var(--color-border-light, #e2e8f0);
font-weight: 600;
color: var(--color-text-primary, #374151);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.section-icon {
width: 1rem;
height: 1rem;
color: var(--color-primary);
}
.config-options,
.storage-options {
padding: var(--spacing-lg, 1rem);
}
/* 响应式设计 - 存储配置 */
@media (max-width: 768px) {
.database-actions {
flex-direction: column;
align-items: stretch;
}
.migration-status {
justify-content: center;
}
.storage-config-actions .action-buttons {
flex-direction: column;
}
.storage-config-actions .btn {
flex: none;
}
}
/* 原有的配置选项样式保持不变 */
@media (max-width: 768px) {
.progress-stats {
grid-template-columns: repeat(2, 1fr);
+239
View File
@@ -0,0 +1,239 @@
import axios from 'axios';
interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
connectionLimit: number;
acquireTimeout: number;
ssl: boolean;
}
interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
}
interface ConnectionTestResult {
connected: boolean;
version?: string;
serverInfo?: string;
connectionId?: number;
uptime?: number;
}
interface MigrationResult {
success: boolean;
direction: 'json-to-db' | 'db-to-json';
recordsProcessed: number;
message: string;
}
class DatabaseService {
private baseURL = '/api/database';
/**
* 测试数据库连接
*/
async testConnection(config: DatabaseConfig): Promise<ApiResponse<ConnectionTestResult>> {
try {
const response = await axios.post(`${this.baseURL}/test-connection`, config);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '连接测试失败'
};
}
}
/**
* 保存数据库配置
*/
async saveConfig(config: DatabaseConfig): Promise<ApiResponse> {
try {
const response = await axios.post(`${this.baseURL}/config`, config);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '保存配置失败'
};
}
}
/**
* 获取数据库配置
*/
async getConfig(): Promise<ApiResponse<DatabaseConfig>> {
try {
const response = await axios.get(`${this.baseURL}/config`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '获取配置失败'
};
}
}
/**
* 检查数据库连接状态
*/
async getConnectionStatus(): Promise<ApiResponse<{ connected: boolean; config?: Partial<DatabaseConfig> }>> {
try {
const response = await axios.get(`${this.baseURL}/status`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '获取连接状态失败'
};
}
}
/**
* 初始化数据库表结构
*/
async initializeTables(): Promise<ApiResponse> {
try {
const response = await axios.post(`${this.baseURL}/initialize`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '初始化数据库失败'
};
}
}
/**
* 执行数据迁移
*/
async migrateData(direction: 'json-to-db' | 'db-to-json', overwrite: boolean = true): Promise<ApiResponse<MigrationResult>> {
try {
const response = await axios.post(`${this.baseURL}/migrate/${direction}`, {
overwrite
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '数据迁移失败'
};
}
}
/**
* 获取注册表统计信息(数据库版本)
*/
async getRegistryStats(): Promise<ApiResponse<{
artistCount: number;
artworkCount: number;
version: string;
created_at: string;
updated_at: string;
}>> {
try {
const response = await axios.get(`${this.baseURL}/registry/stats`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '获取统计信息失败'
};
}
}
/**
* 导出数据库注册表数据
*/
async exportRegistry(): Promise<ApiResponse<any>> {
try {
const response = await axios.get(`${this.baseURL}/registry/export`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '导出数据失败'
};
}
}
/**
* 导入数据到数据库注册表
*/
async importRegistry(data: any): Promise<ApiResponse<{
addedArtists: number;
addedArtworks: number;
skippedArtworks: number;
totalArtists: number;
totalArtworks: number;
}>> {
try {
const response = await axios.post(`${this.baseURL}/registry/import`, data);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '导入数据失败'
};
}
}
/**
* 从文件系统重建数据库注册表
*/
async rebuildRegistry(): Promise<ApiResponse<{ taskId: string }>> {
try {
const response = await axios.post(`${this.baseURL}/registry/rebuild`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '重建注册表失败'
};
}
}
/**
* 清理数据库注册表
*/
async cleanupRegistry(): Promise<ApiResponse<{
removedArtists: number;
removedArtworks: number;
remainingArtists: number;
remainingArtworks: number;
}>> {
try {
const response = await axios.post(`${this.baseURL}/registry/cleanup`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '清理注册表失败'
};
}
}
/**
* 断开数据库连接
*/
async disconnect(): Promise<ApiResponse> {
try {
const response = await axios.post(`${this.baseURL}/disconnect`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || '断开连接失败'
};
}
}
}
export default new DatabaseService();
export type { DatabaseConfig, ApiResponse, ConnectionTestResult, MigrationResult };
+51 -31
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import downloadService from '@/services/download';
import databaseService from '@/services/database';
import { getApiBaseUrl } from '@/services/api';
export interface RegistryStats {
@@ -12,6 +13,7 @@ export interface RegistryStats {
export interface RegistryConfig {
useRegistryCheck: boolean;
fallbackToScan: boolean;
storageMode?: 'json' | 'database';
}
export const useRegistryStore = defineStore('registry', () => {
@@ -19,18 +21,25 @@ export const useRegistryStore = defineStore('registry', () => {
const stats = ref<RegistryStats | null>(null);
const config = ref<RegistryConfig>({
useRegistryCheck: true,
fallbackToScan: false
fallbackToScan: false,
storageMode: 'json'
});
const loading = ref(false);
const error = ref<string | null>(null);
// 获取注册表统计信息
const fetchStats = async () => {
const fetchStats = async (useDatabase = false) => {
try {
loading.value = true;
error.value = null;
const response = await downloadService.getRegistryStats();
let response;
if (useDatabase) {
response = await databaseService.getRegistryStats();
} else {
response = await downloadService.getRegistryStats();
}
if (response.success) {
// 映射API响应数据到组件期望的格式
stats.value = {
@@ -50,27 +59,23 @@ export const useRegistryStore = defineStore('registry', () => {
};
// 导出注册表
const exportRegistry = async () => {
const exportRegistry = async (useDatabase = false) => {
try {
loading.value = true;
error.value = null;
const response = await downloadService.exportRegistry();
let response;
if (useDatabase) {
response = await databaseService.exportRegistry();
} else {
response = await downloadService.exportRegistry();
}
// 创建下载链接
const blob = new Blob([JSON.stringify(response, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `download-registry-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return { success: true };
if (response.success) {
return { success: true, data: response.data };
} else {
throw new Error(response.error || '导出注册表失败');
}
} catch (err: any) {
error.value = err.message || '导出注册表失败';
console.error('导出注册表失败:', err);
@@ -81,18 +86,21 @@ export const useRegistryStore = defineStore('registry', () => {
};
// 导入注册表
const importRegistry = async (file: File) => {
const importRegistry = async (data: any, useDatabase = false) => {
try {
loading.value = true;
error.value = null;
const text = await file.text();
const registryData = JSON.parse(text);
let response;
if (useDatabase) {
response = await databaseService.importRegistry(data);
} else {
response = await downloadService.importRegistry(data);
}
const response = await downloadService.importRegistry(registryData);
if (response.success) {
// 刷新统计信息
await fetchStats();
await fetchStats(useDatabase);
return { success: true, data: response.data };
} else {
throw new Error(response.error || '导入注册表失败');
@@ -107,15 +115,21 @@ export const useRegistryStore = defineStore('registry', () => {
};
// 重建注册表
const rebuildRegistry = async () => {
const rebuildRegistry = async (useDatabase = false) => {
try {
loading.value = true;
error.value = null;
const response = await downloadService.rebuildRegistry();
let response;
if (useDatabase) {
response = await databaseService.rebuildRegistry();
} else {
response = await downloadService.rebuildRegistry();
}
if (response.success) {
// 刷新统计信息
await fetchStats();
await fetchStats(useDatabase);
return { success: true, data: response.data };
} else {
throw new Error(response.error || '重建注册表失败');
@@ -130,15 +144,21 @@ export const useRegistryStore = defineStore('registry', () => {
};
// 清理注册表
const cleanupRegistry = async () => {
const cleanupRegistry = async (useDatabase = false) => {
try {
loading.value = true;
error.value = null;
const response = await downloadService.cleanupRegistry();
let response;
if (useDatabase) {
response = await databaseService.cleanupRegistry();
} else {
response = await downloadService.cleanupRegistry();
}
if (response.success) {
// 刷新统计信息
await fetchStats();
await fetchStats(useDatabase);
return { success: true, data: response.data };
} else {
throw new Error(response.error || '清理注册表失败');
@@ -159,7 +179,7 @@ export const useRegistryStore = defineStore('registry', () => {
const result = await response.json();
if (result.success && result.data) {
config.value = result.data;
config.value = { ...config.value, ...result.data };
return result.data;
} else {
throw new Error(result.error || '获取配置失败');
+21 -4
View File
@@ -21,6 +21,23 @@ export const useUpdateStore = defineStore('update', () => {
const isChecking = ref(false)
const lastCheckTime = ref<Date | null>(null)
// 从localStorage加载上次检查时间
const loadLastCheckTime = () => {
const stored = localStorage.getItem('pixiv-manager-last-update-check')
if (stored) {
lastCheckTime.value = new Date(stored)
}
}
// 保存检查时间到localStorage
const saveLastCheckTime = (time: Date) => {
lastCheckTime.value = time
localStorage.setItem('pixiv-manager-last-update-check', time.toISOString())
}
// 初始化时加载上次检查时间
loadLastCheckTime()
// 检查更新
const checkUpdate = async (silent = false): Promise<UpdateInfo | null> => {
if (isChecking.value) return null
@@ -33,7 +50,7 @@ export const useUpdateStore = defineStore('update', () => {
if (result.success) {
updateInfo.value = result.data
lastCheckTime.value = new Date()
saveLastCheckTime(new Date())
return result.data
} else {
if (!silent) {
@@ -53,8 +70,8 @@ export const useUpdateStore = defineStore('update', () => {
// 自动检查更新(登录后调用)
const autoCheckUpdate = async () => {
// 如果距离上次检查不足1小时,跳过
if (lastCheckTime.value && Date.now() - lastCheckTime.value.getTime() < 60 * 60 * 1000) {
// 如果距离上次检查不足24小时(1天),跳过
if (lastCheckTime.value && Date.now() - lastCheckTime.value.getTime() < 24 * 60 * 60 * 1000) {
return
}
@@ -89,4 +106,4 @@ export const useUpdateStore = defineStore('update', () => {
autoCheckUpdate,
getCurrentVersion
}
})
})