feat: 添加 Docker 部署支持和多 OCR 提供商架构
- 添加完整的 Docker 配置 (Dockerfile, docker-compose.yml) - 修复前端硬编码端口 4000,改用相对路径 /api - 实现多 OCR 提供商架构 (Tesseract.js/Baidu/RapidOCR) - 修复 Docker 环境中图片上传路径问题 - 添加用户设置页面和 AI 分析服务 - 更新 Prisma schema 支持 AI 分析结果 - 添加部署文档和 OCR 配置指南 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
43
frontend/.dockerignore
Normal file
43
frontend/.dockerignore
Normal file
@@ -0,0 +1,43 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build output
|
||||
dist
|
||||
build
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Vite
|
||||
.vite
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
42
frontend/Dockerfile
Normal file
42
frontend/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# ========================================
|
||||
# Stage 1: Dependencies
|
||||
# ========================================
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# ========================================
|
||||
# Stage 2: Builder
|
||||
# ========================================
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build the application (skip type check for production)
|
||||
RUN npx vite build
|
||||
|
||||
# ========================================
|
||||
# Stage 3: Runner with Nginx
|
||||
# ========================================
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.docker.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
41
frontend/nginx.docker.conf
Normal file
41
frontend/nginx.docker.conf
Normal file
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# API proxy to backend
|
||||
location /api {
|
||||
proxy_pass http://backend:13057;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Static files with caching
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback - all non-file routes go to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -2013,7 +2013,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
@@ -2103,7 +2102,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
@@ -2735,7 +2733,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3381,7 +3378,6 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
@@ -4800,7 +4796,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
@@ -5428,7 +5423,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
@@ -5444,7 +5438,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -5516,7 +5509,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DocumentService } from '@/services/document.service';
|
||||
import { DocumentService, type AIAnalysisResult } from '@/services/document.service';
|
||||
import type { CreateDocumentRequest, UpdateDocumentRequest } from '@/types';
|
||||
|
||||
export function useDocuments(params?: { page?: number; limit?: number }) {
|
||||
@@ -57,3 +57,35 @@ export function useSearchDocuments() {
|
||||
mutationFn: (query: string) => DocumentService.search(query),
|
||||
});
|
||||
}
|
||||
|
||||
// AI 分析相关 hooks
|
||||
export function useDocumentAnalysis(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['document-analysis', id],
|
||||
queryFn: () => DocumentService.getAnalysis(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnalyzeDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, options }: { id: string; options?: { provider?: string; generate_summary?: boolean } }) =>
|
||||
DocumentService.analyzeDocument(id, options),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['document-analysis', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDocumentAnalysis() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => DocumentService.deleteAnalysis(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['document-analysis', id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useDocuments, useCreateDocument, useDeleteDocument } from '@/hooks/useDocuments';
|
||||
import { useDocuments, useCreateDocument, useDeleteDocument, useAnalyzeDocument, useDocumentAnalysis } from '@/hooks/useDocuments';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Input } from '@/components/Input';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Plus, Trash2, Search } from 'lucide-react';
|
||||
import { Plus, Trash2, Search, Sparkles, Tag, FolderOpen } from 'lucide-react';
|
||||
import { useSearchDocuments } from '@/hooks/useDocuments';
|
||||
import type { Document } from '@/types';
|
||||
|
||||
@@ -11,10 +11,13 @@ export default function DocumentsPage() {
|
||||
const { data: documents } = useDocuments();
|
||||
const createDocument = useCreateDocument();
|
||||
const deleteDocument = useDeleteDocument();
|
||||
const analyzeDocument = useAnalyzeDocument();
|
||||
const searchDocuments = useSearchDocuments();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Document[]>([]);
|
||||
const [analyzingDocs, setAnalyzingDocs] = useState<Set<string>>(new Set());
|
||||
const [expandedAnalyses, setExpandedAnalyses] = useState<Set<string>>(new Set());
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
@@ -56,6 +59,128 @@ export default function DocumentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async (docId: string) => {
|
||||
setAnalyzingDocs((prev) => new Set(prev).add(docId));
|
||||
try {
|
||||
await analyzeDocument.mutateAsync({ id: docId, options: { generate_summary: true } });
|
||||
setExpandedAnalyses((prev) => new Set(prev).add(docId));
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'AI 分析失败,请检查配置');
|
||||
} finally {
|
||||
setAnalyzingDocs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(docId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAnalysis = (docId: string) => {
|
||||
setExpandedAnalyses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(docId)) {
|
||||
next.delete(docId);
|
||||
} else {
|
||||
next.add(docId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// DocumentCard 组件
|
||||
const DocumentCard = ({ doc }: { doc: Document }) => {
|
||||
const { data: analysis } = useDocumentAnalysis(doc.id);
|
||||
const isAnalyzing = analyzingDocs.has(doc.id);
|
||||
const isExpanded = expandedAnalyses.has(doc.id);
|
||||
|
||||
return (
|
||||
<Card variant="bordered" className="flex flex-col">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex-1">
|
||||
{doc.title || '无标题'}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAnalyze(doc.id)}
|
||||
disabled={isAnalyzing}
|
||||
className="text-gray-400 hover:text-blue-600 disabled:opacity-50"
|
||||
title="AI 分析"
|
||||
>
|
||||
<Sparkles className={`h-4 w-4 ${isAnalyzing ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 line-clamp-3 text-sm text-gray-600 flex-1">
|
||||
{doc.content}
|
||||
</p>
|
||||
|
||||
{/* AI 分析结果 */}
|
||||
{analysis && (
|
||||
<div className="mb-3 border-t border-gray-200 pt-3">
|
||||
<button
|
||||
onClick={() => toggleAnalysis(doc.id)}
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{isExpanded ? '收起' : '查看'} AI 分析
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{analysis.suggested_tags && analysis.suggested_tags.length > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Tag className="h-4 w-4 text-gray-500 mt-0.5" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{analysis.suggested_tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.suggested_category && (
|
||||
<div className="flex items-start gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-gray-500 mt-0.5" />
|
||||
<span className="text-gray-600">
|
||||
建议分类: <span className="font-medium text-gray-900">{analysis.suggested_category}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.summary && (
|
||||
<div className="bg-gray-50 rounded p-2 text-gray-700">
|
||||
<span className="font-medium">摘要:</span> {analysis.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
由 {analysis.provider} ({analysis.model}) 分析
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(doc.created_at).toLocaleString()}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const displayDocuments = searchResults.length > 0 ? searchResults : documents;
|
||||
|
||||
return (
|
||||
@@ -129,25 +254,7 @@ export default function DocumentsPage() {
|
||||
{/* Documents List */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{displayDocuments?.map((doc) => (
|
||||
<Card key={doc.id} variant="bordered">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{doc.title || '无标题'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 line-clamp-3 text-sm text-gray-600">
|
||||
{doc.content}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(doc.created_at).toLocaleString()}
|
||||
</p>
|
||||
</Card>
|
||||
<DocumentCard key={doc.id} doc={doc} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Upload, Camera, FileText, CheckSquare, X, RefreshCw, ChevronDown, Setti
|
||||
import type { Image } from '@/types';
|
||||
import { useDeleteImage } from '@/hooks/useImages';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// 获取完整的图片 URL
|
||||
const getImageUrl = (path: string) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Settings, Save, CheckCircle, XCircle, Eye, EyeOff, Server, Globe, Datab
|
||||
|
||||
// 从环境变量或 localStorage 获取 API 地址
|
||||
const getDefaultApiUrl = () => {
|
||||
return import.meta.env.VITE_API_URL || localStorage.getItem('api_base_url') || 'http://localhost:4000';
|
||||
return import.meta.env.VITE_API_URL || localStorage.getItem('api_base_url') || '/api';
|
||||
};
|
||||
|
||||
type ApiConfig = {
|
||||
@@ -428,7 +428,7 @@ export default function SettingsPage() {
|
||||
<Input
|
||||
value={apiConfig.baseUrl}
|
||||
onChange={(e) => updateApiConfig('baseUrl', e.target.value)}
|
||||
placeholder="http://localhost:4000"
|
||||
placeholder="/api"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
后端服务器的 API 地址,修改后需要刷新页面生效
|
||||
@@ -476,7 +476,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">默认 API 地址:</span>
|
||||
<span className="font-medium">{import.meta.env.VITE_API_URL || 'http://localhost:4000'}</span>
|
||||
<span className="font-medium">{import.meta.env.VITE_API_URL || '/api'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">当前用户:</span>
|
||||
|
||||
@@ -5,6 +5,16 @@ import type {
|
||||
UpdateDocumentRequest,
|
||||
} from '@/types';
|
||||
|
||||
// AI 分析结果类型
|
||||
export interface AIAnalysisResult {
|
||||
suggested_tags: string[];
|
||||
suggested_category?: string;
|
||||
summary?: string;
|
||||
raw_response: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
class DocumentServiceClass {
|
||||
async create(data: CreateDocumentRequest): Promise<Document> {
|
||||
try {
|
||||
@@ -64,6 +74,50 @@ class DocumentServiceClass {
|
||||
const response = await apiClient.get<{ success: boolean; data: Document[] }>(`/documents/search?q=${encodeURIComponent(query)}`);
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
// AI 分析文档
|
||||
async analyzeDocument(id: string, options?: { provider?: string; generate_summary?: boolean }): Promise<AIAnalysisResult> {
|
||||
try {
|
||||
const response = await apiClient.post<{ success: boolean; data: AIAnalysisResult }>(
|
||||
`/documents/${id}/analyze`,
|
||||
options || {}
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('AI 分析失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || 'AI 分析失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文档的 AI 分析结果
|
||||
async getAnalysis(id: string): Promise<AIAnalysisResult | null> {
|
||||
try {
|
||||
const response = await apiClient.get<{ success: boolean; data: AIAnalysisResult }>(
|
||||
`/documents/${id}/analysis`
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
// 404 表示没有分析结果
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(error.response?.data?.error || '获取 AI 分析失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文档的 AI 分析结果
|
||||
async deleteAnalysis(id: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.delete(`/documents/${id}/analysis`);
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '删除 AI 分析失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DocumentService = new DocumentServiceClass();
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
UpdateOCRRequest,
|
||||
} from '@/types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000/api';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
class ImageServiceClass {
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 13056,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user