feat: Docker部署与CI/CD集成, 搜索栏修复, 上传目录改为data
This commit is contained in:
438
services/dataService.ts
Normal file
438
services/dataService.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
|
||||
import { MaterialDTO, MaterialType, UserDTO, UserRole, CommentDTO, SystemConfig, PaginationResult } from '../types';
|
||||
|
||||
// --- ENTERPRISE DATA LAYER ---
|
||||
// This service acts as the Facade Pattern, shielding the UI from the complexity
|
||||
// of switching between a mock environment (for dev/demo) and the real backend.
|
||||
|
||||
const STORAGE_KEY_MODE = 'NEXUS_DATA_MODE';
|
||||
|
||||
const getMode = (): 'MOCK' | 'REAL' => {
|
||||
// Default to MOCK for the browser demo environment
|
||||
return (localStorage.getItem(STORAGE_KEY_MODE) as 'MOCK' | 'REAL') || 'MOCK';
|
||||
};
|
||||
|
||||
export const setApiMode = (mode: 'MOCK' | 'REAL') => {
|
||||
localStorage.setItem(STORAGE_KEY_MODE, mode);
|
||||
// Reload to apply changes cleanly across the app
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// --- MOCK DATABASE STATE ---
|
||||
const CURRENT_USER: UserDTO = {
|
||||
id: 'u_001',
|
||||
username: 'Neo_Architect',
|
||||
avatarUrl: 'https://picsum.photos/seed/neo/200/200',
|
||||
role: UserRole.CREATOR,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(2023, 10, 15).toISOString(),
|
||||
lastLogin: new Date().toISOString()
|
||||
};
|
||||
|
||||
const ALL_USERS: UserDTO[] = [
|
||||
CURRENT_USER,
|
||||
{
|
||||
id: 'u_002',
|
||||
username: 'Visual_Drift',
|
||||
avatarUrl: 'https://picsum.photos/seed/drift/200/200',
|
||||
role: UserRole.CREATOR,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(2023, 11, 1).toISOString(),
|
||||
lastLogin: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'u_003',
|
||||
username: 'Poly_Master',
|
||||
avatarUrl: 'https://picsum.photos/seed/poly/200/200',
|
||||
role: UserRole.USER,
|
||||
status: 'FLAGGED',
|
||||
createdAt: new Date(2024, 0, 10).toISOString(),
|
||||
lastLogin: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 'u_004',
|
||||
username: 'Script_Kiddie',
|
||||
avatarUrl: 'https://picsum.photos/seed/kiddie/200/200',
|
||||
role: UserRole.USER,
|
||||
status: 'BANNED',
|
||||
createdAt: new Date(2024, 1, 20).toISOString(),
|
||||
lastLogin: new Date(2024, 2, 1).toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
const MOCK_MATERIALS: MaterialDTO[] = [
|
||||
{
|
||||
id: 'm_001',
|
||||
title: 'Cyberpunk HUD Interface React',
|
||||
description: 'A complete React component set for building sci-fi interfaces. Includes glitch effects and holographic transitions.',
|
||||
type: MaterialType.CODE,
|
||||
codeSnippet: `const CyberButton = ({ children }) => (
|
||||
<button className="border border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-black transition-all duration-300 uppercase tracking-widest px-6 py-2">
|
||||
{children}
|
||||
</button>
|
||||
);`,
|
||||
language: 'tsx',
|
||||
author: CURRENT_USER,
|
||||
stats: { views: 1204, downloads: 450, favorites: 89 },
|
||||
tags: ['react', 'ui', 'cyberpunk', 'animation'],
|
||||
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
comments: []
|
||||
},
|
||||
{
|
||||
id: 'm_002',
|
||||
title: 'Neon City Ambience Loop',
|
||||
description: '4K loop of a futuristic city rain scene. Perfect for background assets.',
|
||||
type: MaterialType.VIDEO,
|
||||
contentUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4', // Placeholder video
|
||||
author: ALL_USERS[1],
|
||||
stats: { views: 5320, downloads: 1200, favorites: 432 },
|
||||
tags: ['video', 'loop', 'background', '4k'],
|
||||
createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
|
||||
comments: [
|
||||
{
|
||||
id: 'c_1',
|
||||
content: 'The lighting on this is insane!',
|
||||
author: CURRENT_USER,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'm_003',
|
||||
title: 'Industrial 3D Assets Pack',
|
||||
description: 'Low-poly mechanical parts for game dev. GLB format + Textures.',
|
||||
type: MaterialType.ASSET_ZIP,
|
||||
contentUrl: '#',
|
||||
author: ALL_USERS[2],
|
||||
stats: { views: 800, downloads: 120, favorites: 45 },
|
||||
tags: ['3d', 'blender', 'game-assets'],
|
||||
createdAt: new Date(Date.now() - 86400000 * 1).toISOString(),
|
||||
comments: []
|
||||
}
|
||||
];
|
||||
|
||||
// Generate additional code materials for testing (total 40)
|
||||
if (MOCK_MATERIALS.length < 40) {
|
||||
const base = MOCK_MATERIALS.length;
|
||||
const types = [MaterialType.CODE, MaterialType.ASSET_ZIP, MaterialType.VIDEO];
|
||||
for (let i = base; i < 40; i++) {
|
||||
const id = `m_${(i + 1).toString().padStart(3, '0')}`;
|
||||
const author = ALL_USERS[i % ALL_USERS.length];
|
||||
const t = types[i % types.length];
|
||||
const createdAt = new Date(Date.now() - 86400000 * (i % 10)).toISOString();
|
||||
const stats = { views: Math.floor(Math.random() * 1000), downloads: Math.floor(Math.random() * 200), favorites: Math.floor(Math.random() * 50) };
|
||||
const common = {
|
||||
id,
|
||||
title: t === MaterialType.CODE ? `Test Code Snippet #${i + 1}` : t === MaterialType.ASSET_ZIP ? `Assets Pack #${i + 1}` : `Demo Video #${i + 1}`,
|
||||
description: 'Auto-generated demo material.',
|
||||
type: t,
|
||||
author,
|
||||
stats,
|
||||
tags: t === MaterialType.CODE ? ['test', 'code'] : t === MaterialType.ASSET_ZIP ? ['assets'] : ['video'],
|
||||
createdAt,
|
||||
comments: [] as any[]
|
||||
};
|
||||
if (t === MaterialType.CODE) {
|
||||
MOCK_MATERIALS.push({
|
||||
...common,
|
||||
codeSnippet: `function test${i}(){return ${i};}`,
|
||||
language: 'ts'
|
||||
});
|
||||
} else if (t === MaterialType.ASSET_ZIP) {
|
||||
MOCK_MATERIALS.push({
|
||||
...common,
|
||||
contentUrl: '#'
|
||||
});
|
||||
} else {
|
||||
MOCK_MATERIALS.push({
|
||||
...common,
|
||||
contentUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let SYSTEM_CONFIG: SystemConfig = {
|
||||
dbHost: '127.0.0.1',
|
||||
dbPort: '3306',
|
||||
dbUser: 'root',
|
||||
dbPass: 'admin123',
|
||||
dbName: 'nexus_db',
|
||||
maintenanceMode: false,
|
||||
maxUploadMB: 3
|
||||
};
|
||||
|
||||
// Helper to call backend API in REAL mode
|
||||
async function apiCall<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
try {
|
||||
const res = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
// Return error as part of response structure
|
||||
throw new Error(json.error || res.statusText || 'Request failed');
|
||||
}
|
||||
|
||||
return json.data; // Assuming ApiResponse wrapper
|
||||
} catch (e) {
|
||||
console.error("API Call Failed:", e);
|
||||
throw e; // Re-throw to be caught by caller
|
||||
}
|
||||
}
|
||||
|
||||
// --- SERVICE METHODS ---
|
||||
|
||||
export const DataService = {
|
||||
|
||||
getMode,
|
||||
setApiMode,
|
||||
|
||||
// --- CLIENT SIDE ---
|
||||
getCurrentUser: async (): Promise<UserDTO | null> => {
|
||||
if (getMode() === 'REAL') {
|
||||
try {
|
||||
return await apiCall<UserDTO>('/auth/me');
|
||||
} catch (error) {
|
||||
// User not logged in or token expired, return null
|
||||
console.log('User not authenticated');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return new Promise(resolve => setTimeout(() => resolve(CURRENT_USER), 500));
|
||||
},
|
||||
|
||||
updateUserProfile: async (userId: string, data: Partial<UserDTO>): Promise<UserDTO> => {
|
||||
if (getMode() === 'REAL') return apiCall<UserDTO>('/users/me', { method: 'PATCH', body: JSON.stringify(data) });
|
||||
|
||||
return new Promise(resolve => {
|
||||
if (CURRENT_USER.id === userId) {
|
||||
if (data.username) CURRENT_USER.username = data.username;
|
||||
if (data.avatarUrl) CURRENT_USER.avatarUrl = data.avatarUrl;
|
||||
}
|
||||
setTimeout(() => resolve({ ...CURRENT_USER }), 800);
|
||||
});
|
||||
},
|
||||
|
||||
getMaterials: async (page = 1, limit = 20, type?: MaterialType | 'ALL', search?: string): Promise<PaginationResult<MaterialDTO>> => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (type && type !== 'ALL') params.append('type', String(type));
|
||||
if (search && search.trim()) params.append('q', search.trim());
|
||||
if (getMode() === 'REAL') return apiCall<PaginationResult<MaterialDTO>>(`/materials?${params.toString()}`);
|
||||
|
||||
let source = type && type !== 'ALL' ? MOCK_MATERIALS.filter(m => m.type === type) : MOCK_MATERIALS;
|
||||
if (search && search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
source = source.filter(m => {
|
||||
const inTitle = m.title.toLowerCase().includes(q);
|
||||
const inDesc = m.description.toLowerCase().includes(q);
|
||||
const inTags = m.tags.some(t => t.toLowerCase().includes(q));
|
||||
return inTitle || inDesc || inTags;
|
||||
});
|
||||
}
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const items = source.slice(start, end);
|
||||
const total = source.length;
|
||||
const hasNext = page * limit < total;
|
||||
return new Promise(resolve => setTimeout(() => resolve({ items, total, page, limit, hasNext }), 400));
|
||||
},
|
||||
|
||||
getUserMaterials: async (page = 1, limit = 20): Promise<PaginationResult<MaterialDTO>> => {
|
||||
if (getMode() === 'REAL') return apiCall<PaginationResult<MaterialDTO>>(`/users/me/materials?page=${page}&limit=${limit}`);
|
||||
const userItems = MOCK_MATERIALS.filter(m => m.author.id === CURRENT_USER.id);
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const items = userItems.slice(start, end);
|
||||
const total = userItems.length;
|
||||
const hasNext = page * limit < total;
|
||||
return new Promise(resolve => setTimeout(() => resolve({ items, total, page, limit, hasNext }), 300));
|
||||
},
|
||||
|
||||
getUserFavorites: async (page = 1, limit = 20): Promise<PaginationResult<MaterialDTO>> => {
|
||||
if (getMode() === 'REAL') return apiCall<PaginationResult<MaterialDTO>>(`/users/me/favorites?page=${page}&limit=${limit}`);
|
||||
const favItems = MOCK_MATERIALS.filter(m => m.stats.favorites > 0);
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const items = favItems.slice(start, end);
|
||||
const total = favItems.length;
|
||||
const hasNext = page * limit < total;
|
||||
return new Promise(resolve => setTimeout(() => resolve({ items, total, page, limit, hasNext }), 300));
|
||||
},
|
||||
|
||||
getMaterialById: async (id: string): Promise<MaterialDTO | undefined> => {
|
||||
if (getMode() === 'REAL') return apiCall<MaterialDTO>(`/materials/${id}`);
|
||||
|
||||
return new Promise(resolve => {
|
||||
const item = MOCK_MATERIALS.find(m => m.id === id);
|
||||
setTimeout(() => resolve(item), 400);
|
||||
});
|
||||
},
|
||||
|
||||
createMaterial: async (data: Partial<MaterialDTO>): Promise<MaterialDTO> => {
|
||||
if (getMode() === 'REAL') return apiCall<MaterialDTO>('/materials', { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
return new Promise(resolve => {
|
||||
const newMaterial: MaterialDTO = {
|
||||
id: `m_${Date.now()}`,
|
||||
title: data.title || 'Untitled',
|
||||
description: data.description || '',
|
||||
type: data.type || MaterialType.CODE,
|
||||
codeSnippet: data.codeSnippet,
|
||||
contentUrl: data.contentUrl,
|
||||
language: data.language || 'text',
|
||||
author: CURRENT_USER,
|
||||
stats: { views: 0, downloads: 0, favorites: 0 },
|
||||
tags: data.tags || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
comments: []
|
||||
};
|
||||
MOCK_MATERIALS.unshift(newMaterial);
|
||||
setTimeout(() => resolve(newMaterial), 1000);
|
||||
});
|
||||
},
|
||||
|
||||
deleteMaterial: async (id: string): Promise<boolean> => {
|
||||
if (getMode() === 'REAL') {
|
||||
await apiCall(`/materials/${id}`, { method: 'DELETE' });
|
||||
return true;
|
||||
}
|
||||
|
||||
const idx = MOCK_MATERIALS.findIndex(m => m.id === id);
|
||||
if (idx > -1) {
|
||||
MOCK_MATERIALS.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
addComment: async (materialId: string, content: string): Promise<CommentDTO> => {
|
||||
if (getMode() === 'REAL') return apiCall<CommentDTO>(`/materials/${materialId}/comments`, { method: 'POST', body: JSON.stringify({ content }) });
|
||||
|
||||
const material = MOCK_MATERIALS.find(m => m.id === materialId);
|
||||
if (!material) throw new Error('Material not found');
|
||||
|
||||
const newComment: CommentDTO = {
|
||||
id: `c_${Date.now()}`,
|
||||
content,
|
||||
author: CURRENT_USER,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
material.comments.push(newComment);
|
||||
return newComment;
|
||||
},
|
||||
|
||||
toggleFavorite: async (materialId: string): Promise<number> => {
|
||||
if (getMode() === 'REAL') {
|
||||
const res = await apiCall<{ favorites: number }>(`/materials/${materialId}/favorite`, { method: 'POST' });
|
||||
return res.favorites;
|
||||
}
|
||||
|
||||
const material = MOCK_MATERIALS.find(m => m.id === materialId);
|
||||
if (material) {
|
||||
// Toggle logic simulation
|
||||
material.stats.favorites += 1;
|
||||
return material.stats.favorites;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
uploadZip: async (file: File, meta?: { title?: string; description?: string; tags?: string[] }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (meta?.title) fd.append('title', meta.title);
|
||||
if (meta?.description) fd.append('description', meta.description);
|
||||
if (meta?.tags) fd.append('tags', meta.tags.join(','));
|
||||
const res = await fetch('/api/v1/materials/upload-zip', { method: 'POST', body: fd });
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Upload failed (${res.status})`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `Upload failed (${res.status})`);
|
||||
return json.data as MaterialDTO;
|
||||
},
|
||||
|
||||
uploadVideo: async (file: File, meta?: { title?: string; description?: string; tags?: string[] }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (meta?.title) fd.append('title', meta.title);
|
||||
if (meta?.description) fd.append('description', meta.description);
|
||||
if (meta?.tags) fd.append('tags', meta.tags.join(','));
|
||||
const res = await fetch('/api/v1/materials/upload-video', { method: 'POST', body: fd });
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Upload failed (${res.status})`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `Upload failed (${res.status})`);
|
||||
return json.data as MaterialDTO;
|
||||
},
|
||||
|
||||
// --- ADMIN SIDE ---
|
||||
verifyAdmin: async (u: string, p: string): Promise<boolean> => {
|
||||
// Admin login is usually separate or special
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(u === 'admin' && p === 'wx1998WX');
|
||||
}, 1000);
|
||||
});
|
||||
},
|
||||
|
||||
getAllUsers: async (): Promise<UserDTO[]> => {
|
||||
if (getMode() === 'REAL') return apiCall<UserDTO[]>('/admin/users');
|
||||
return new Promise(resolve => setTimeout(() => resolve(ALL_USERS), 600));
|
||||
},
|
||||
|
||||
updateUserRole: async (userId: string, role: UserRole): Promise<UserDTO> => {
|
||||
if (getMode() === 'REAL') return apiCall<UserDTO>(`/admin/users/${userId}/role`, { method: 'POST', body: JSON.stringify({ role }) });
|
||||
const u = ALL_USERS.find(user => user.id === userId);
|
||||
if (!u) throw new Error('User not found');
|
||||
u.role = role;
|
||||
return new Promise(resolve => setTimeout(() => resolve({ ...u }), 400));
|
||||
},
|
||||
|
||||
getSystemConfig: async (): Promise<SystemConfig> => {
|
||||
if (getMode() === 'REAL') return apiCall<SystemConfig>('/admin/config');
|
||||
return SYSTEM_CONFIG;
|
||||
},
|
||||
|
||||
updateSystemConfig: async (config: SystemConfig): Promise<void> => {
|
||||
if (getMode() === 'REAL') {
|
||||
await apiCall('/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
maxUploadMB: config.maxUploadMB,
|
||||
uploadDir: 'data',
|
||||
dbHost: config.dbHost,
|
||||
dbPort: config.dbPort,
|
||||
dbUser: config.dbUser,
|
||||
dbPass: config.dbPass,
|
||||
dbName: config.dbName,
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
SYSTEM_CONFIG = config;
|
||||
return new Promise(resolve => setTimeout(resolve, 1500));
|
||||
},
|
||||
|
||||
toggleUserStatus: async (userId: string): Promise<UserDTO | undefined> => {
|
||||
if (getMode() === 'REAL') return apiCall<UserDTO>(`/admin/users/${userId}/toggle-status`, { method: 'POST' });
|
||||
|
||||
const u = ALL_USERS.find(user => user.id === userId);
|
||||
if (u) {
|
||||
u.status = u.status === 'ACTIVE' ? 'BANNED' : 'ACTIVE';
|
||||
return u;
|
||||
}
|
||||
}
|
||||
};
|
||||
59
services/geminiService.ts
Normal file
59
services/geminiService.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const apiKey = process.env.API_KEY || '';
|
||||
|
||||
// Safe initialization
|
||||
let ai: GoogleGenAI | null = null;
|
||||
if (apiKey) {
|
||||
ai = new GoogleGenAI({ apiKey });
|
||||
}
|
||||
|
||||
export const GeminiService = {
|
||||
isEnabled: !!apiKey,
|
||||
|
||||
/**
|
||||
* Generates a technical description and tags for a code snippet.
|
||||
*/
|
||||
analyzeCode: async (code: string): Promise<{ description: string; tags: string[] }> => {
|
||||
if (!ai) return { description: "AI Unavailable (Missing Key)", tags: [] };
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: `Analyze this code snippet. Return a JSON object with a 'description' (max 50 words, technical style) and 'tags' (array of strings, max 5). Code: \n\n${code}`,
|
||||
config: {
|
||||
responseMimeType: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const text = response.text;
|
||||
if (!text) return { description: "Analysis failed", tags: [] };
|
||||
|
||||
const result = JSON.parse(text);
|
||||
return {
|
||||
description: result.description || "No description generated.",
|
||||
tags: result.tags || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Gemini Error:", error);
|
||||
return { description: "Error during AI analysis.", tags: [] };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates a constructive code review or explanation.
|
||||
*/
|
||||
explainCode: async (code: string): Promise<string> => {
|
||||
if (!ai) return "AI Configuration Missing.";
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: `Explain this code snippet like a senior engineer doing a code review. Be concise, point out optimizations if any. Code: \n${code}`,
|
||||
});
|
||||
return response.text || "No explanation available.";
|
||||
} catch (e) {
|
||||
return "Failed to generate explanation.";
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user