Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353d4639b4 | ||
|
|
dfbb61f75f | ||
|
|
c1d7f61a45 | ||
|
|
c200159afd | ||
|
|
ebc97e03a4 | ||
|
|
b088f68fd7 | ||
|
|
1ceb7cb156 | ||
|
|
e571b19f4a | ||
|
|
bcd2b756ed | ||
|
|
e8010b1e79 | ||
|
|
7c2eeda232 | ||
|
|
7e4605d2b7 | ||
|
|
7d78a1fc58 | ||
|
|
324298c9d7 | ||
|
|
2d1c670715 | ||
|
|
450d5dd239 | ||
|
|
f0f483a9d1 | ||
|
|
00abef23df | ||
|
|
29dc8459f7 | ||
|
|
afcace4fa3 | ||
|
|
2ca30178ed | ||
|
|
40ec3d406b | ||
|
|
b801867611 | ||
|
|
3a72752598 | ||
|
|
b2af06397a | ||
|
|
838cc01190 | ||
|
|
adedb9ea41 | ||
|
|
e5fc0e1705 | ||
|
|
e2f6853c2c | ||
|
|
9bf2a30f8d | ||
|
|
ad151bbae7 | ||
|
|
8865149aab | ||
|
|
879481df9f | ||
|
|
2c58978c78 | ||
|
|
3c17bd5081 | ||
|
|
adcb288064 | ||
|
|
016b4074d8 | ||
|
|
5e2d5dfabb | ||
|
|
9759ab2698 | ||
|
|
82e33fb0e0 | ||
|
|
9dab4f929c | ||
|
|
bef132f82f | ||
|
|
07570bd14a | ||
|
|
d68db09810 | ||
|
|
573d4ab452 | ||
|
|
436acf3cca | ||
|
|
2c4fd5a9d4 | ||
|
|
cf6e71839b | ||
|
|
99ecb0fa9f | ||
|
|
bd310e8d18 | ||
|
|
27a9a82a37 | ||
|
|
00e7490912 | ||
|
|
23e0c97822 | ||
|
|
2556bbfa1b | ||
|
|
f05cd8b820 | ||
|
|
d16db803ba | ||
|
|
1b491357ab | ||
|
|
61a2e95278 | ||
|
|
426dc783df | ||
|
|
575e4868be | ||
|
|
41ead01e02 | ||
|
|
4e2ecaa69e | ||
|
|
632edaf081 | ||
|
|
d56dff2a2f | ||
|
|
696cec2fb5 | ||
|
|
729b19bc12 | ||
|
|
6f1f42ba7a | ||
|
|
68863eb44e | ||
|
|
679195b590 | ||
|
|
b070125f51 | ||
|
|
d513512b87 | ||
|
|
30038d5103 | ||
|
|
d2ed1b1c76 | ||
|
|
5530c141a2 | ||
|
|
cb6fd6f5af | ||
|
|
1800909661 | ||
|
|
c0eaac1daa | ||
|
|
79ebe69221 | ||
|
|
e5fe31f6ba | ||
|
|
05945099a1 | ||
|
|
399a76da8e | ||
|
|
659b1a1943 | ||
|
|
fb1d53d97a | ||
|
|
79813ff52c | ||
|
|
1b571387df | ||
|
|
e1bf404455 | ||
|
|
18e226ad47 | ||
|
|
182a1bb674 | ||
|
|
0c87a287a3 | ||
|
|
edcbd0dc58 | ||
|
|
5cf7b51686 | ||
|
|
531e8d63c1 | ||
|
|
dc7a92f040 | ||
|
|
11552d6124 | ||
|
|
7b4783bb3e | ||
|
|
98c7977a19 | ||
|
|
c9e0720764 | ||
|
|
87b744c9c7 | ||
|
|
275c3d6bb3 | ||
|
|
ef619c635e | ||
|
|
d55c8cc1e5 | ||
|
|
9d0015808c | ||
|
|
8600d2d4a3 | ||
|
|
846682ac4a | ||
|
|
f7105a62cc | ||
|
|
0628344dcf | ||
|
|
d25db9367c | ||
|
|
1d29e50852 | ||
|
|
853ad67db5 | ||
|
|
851e3fc2dc | ||
|
|
74770cd99d | ||
|
|
ce0735d775 | ||
|
|
805f4594d4 | ||
|
|
7a0defc3d4 | ||
|
|
fe6957bf83 | ||
|
|
049291c109 | ||
|
|
53673d0f9b | ||
|
|
9d55f521a0 | ||
|
|
35c03ef35e | ||
|
|
bb6c586d99 | ||
|
|
20c95e73b3 |
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
|
||||
import { track } from '../analytics';
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
type SseFrame = {
|
||||
content?: string;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
||||
function getThreadId(): string {
|
||||
const KEY = 'chat_thread_id';
|
||||
let id = sessionStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
sessionStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** خواندن فریمهای SSE از پاسخ fetch (استریم توکنبهتوکن) */
|
||||
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('پاسخ استریمی ندارد');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
|
||||
} catch {
|
||||
// فریم ناقص — نادیده بگیر
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const scrollEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const assistantIndexRef = useRef<number | null>(null);
|
||||
|
||||
// پیشنهاد پرسشها فقط یک بار برای هر بازشدن اول
|
||||
useEffect(() => {
|
||||
if (!open || suggestions.length > 0) return;
|
||||
fetch('/chat/suggestions')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
|
||||
.catch(() => setSuggestions([]));
|
||||
}, [open, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [messages, open]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
const question = text.trim();
|
||||
if (!question || isLoading) return;
|
||||
track('chat-question')
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
setMessages((prev) => {
|
||||
const next: ChatMessage[] = [
|
||||
...prev,
|
||||
{ role: 'user', content: question },
|
||||
{ role: 'assistant', content: '', streaming: true },
|
||||
];
|
||||
assistantIndexRef.current = next.length - 1;
|
||||
return next;
|
||||
});
|
||||
|
||||
const patchAssistant = (patch: Partial<ChatMessage>) => {
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current;
|
||||
if (idx === null || !prev[idx]) return prev;
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...patch };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
|
||||
const response = await fetch(`/chat/stream?${params.toString()}`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
let accumulated = '';
|
||||
await readSse(response, (frame) => {
|
||||
if (frame.error) {
|
||||
patchAssistant({ content: frame.error, streaming: false, error: true });
|
||||
return;
|
||||
}
|
||||
if (frame.content) {
|
||||
accumulated += frame.content;
|
||||
patchAssistant({ content: accumulated });
|
||||
}
|
||||
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||
});
|
||||
patchAssistant({ streaming: false });
|
||||
} catch (err) {
|
||||
patchAssistant({
|
||||
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
||||
streaming: false,
|
||||
error: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دکمه شناور — گوشه راستپایین (دکمه تماس سمت چپ است) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((v) => {
|
||||
if (!v) track('chat-open');
|
||||
return !v;
|
||||
});
|
||||
}}
|
||||
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
|
||||
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* پنل گفتگو */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
|
||||
{/* سربرگ */}
|
||||
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
|
||||
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="بستن"
|
||||
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* پیامها */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="mt-6 text-center">
|
||||
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
|
||||
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
|
||||
سلام! هر سؤالی درباره خدمات، پروژهها و مقالات
|
||||
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
|
||||
دارید بپرسید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
|
||||
msg.role === 'user'
|
||||
? 'rounded-bl-md bg-copper text-char'
|
||||
: msg.error
|
||||
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
|
||||
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
|
||||
}`}>
|
||||
{msg.content || (msg.streaming ? '…' : '')}
|
||||
{msg.streaming && msg.content && (
|
||||
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={scrollEndRef} />
|
||||
</div>
|
||||
|
||||
{/* پیشنهاد پرسشها */}
|
||||
{suggestions.length > 0 && messages.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
|
||||
{suggestions.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
onClick={() => sendMessage(q)}
|
||||
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ورودی */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendMessage(input);
|
||||
}}
|
||||
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="پرسش خود را بنویسید…"
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
aria-label="ارسال"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
|
||||
<SendIcon className="h-4 w-4 -scale-x-100" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"""سرویس چتبات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from . import content as content_mod
|
||||
from . import pipeline
|
||||
from . import vectorstore
|
||||
from .config import settings
|
||||
from .indexing import INDEX, sync_index
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||
)
|
||||
log = logging.getLogger("chatbot")
|
||||
|
||||
|
||||
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
|
||||
|
||||
_refresh_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
|
||||
global _refresh_task
|
||||
_refresh_task = asyncio.create_task(_refresh_loop())
|
||||
yield
|
||||
_refresh_task.cancel()
|
||||
|
||||
|
||||
async def _refresh_loop() -> None:
|
||||
interval = settings.index_refresh_seconds
|
||||
if interval <= 0:
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
# همگامسازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد میشوند
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.warning("Background index refresh failed: %s", e)
|
||||
|
||||
|
||||
api = FastAPI(
|
||||
title="Aramesh Site Chatbot",
|
||||
version="1.0.0",
|
||||
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ریرنکر BGE",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
api.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
|
||||
|
||||
_hits: dict[str, deque] = defaultdict(deque)
|
||||
_last_sweep = 0.0
|
||||
|
||||
|
||||
def _rate_limited(ip: str) -> bool:
|
||||
global _last_sweep
|
||||
now = time.time()
|
||||
# پاکسازی دورهای کلیدهای بیمصرف تا دیکشنری بینهایت بزرگ نشود
|
||||
if now - _last_sweep > settings.rate_limit_window:
|
||||
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
|
||||
for k in stale:
|
||||
_hits.pop(k, None)
|
||||
_last_sweep = now
|
||||
window = _hits[ip]
|
||||
while window and now - window[0] > settings.rate_limit_window:
|
||||
window.popleft()
|
||||
if len(window) >= settings.rate_limit_messages:
|
||||
return True
|
||||
window.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request | None) -> str:
|
||||
if request is None:
|
||||
return "?"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
|
||||
|
||||
|
||||
# --- کمکیهای SSE ---
|
||||
|
||||
def _sse(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _chunk_text(chunk) -> str:
|
||||
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخشها)."""
|
||||
content = getattr(chunk, "content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
return ""
|
||||
|
||||
|
||||
async def _stream_answer(message: str, thread_id: str):
|
||||
if not INDEX.ready:
|
||||
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
|
||||
return
|
||||
if pipeline.get_llm() is None:
|
||||
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
|
||||
return
|
||||
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
try:
|
||||
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
||||
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
||||
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
||||
text = _chunk_text(event["data"]["chunk"])
|
||||
if text:
|
||||
yield _sse({"content": text, "done": False})
|
||||
yield _sse({"content": "", "done": True})
|
||||
except Exception as e:
|
||||
log.exception("Streaming failed")
|
||||
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
||||
|
||||
|
||||
# --- مسیرها ---
|
||||
|
||||
@api.get("/chat/healthz")
|
||||
async def healthz():
|
||||
return {
|
||||
"status": "ok",
|
||||
"index_ready": INDEX.ready,
|
||||
"documents": len(INDEX.docs),
|
||||
"vectors": await asyncio.to_thread(vectorstore.vector_count),
|
||||
"llm_configured": bool(settings.llm_base_url),
|
||||
"reranker_enabled": settings.reranker_enabled,
|
||||
}
|
||||
|
||||
|
||||
@api.get("/chat/suggestions")
|
||||
async def suggestions():
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/eval")
|
||||
async def eval_question(message: str = Query(...)):
|
||||
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده، با thread تازه."""
|
||||
if not INDEX.ready:
|
||||
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
|
||||
if pipeline.get_llm() is None:
|
||||
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
|
||||
|
||||
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
final_state = await pipeline.graph.ainvoke(inputs, config=config)
|
||||
|
||||
answer = ""
|
||||
for msg in reversed(final_state.get("messages", [])):
|
||||
if isinstance(msg, AIMessage):
|
||||
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||
break
|
||||
return {
|
||||
"question": message,
|
||||
"contextualized_query": final_state.get("contextualized_query") or message,
|
||||
"answer": answer,
|
||||
"contexts": final_state.get("documents", []),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
@api.post("/chat/reindex")
|
||||
async def reindex(request: Request):
|
||||
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
||||
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
|
||||
raise HTTPException(status_code=403, detail="invalid reindex token")
|
||||
stats = await asyncio.to_thread(sync_index, True)
|
||||
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
|
||||
|
||||
|
||||
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval میسازد
|
||||
|
||||
@api.get("/chat/{scope}/stream")
|
||||
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
return await stream_chat(message=message, thread_id=thread_id, request=request)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/eval")
|
||||
async def eval_scoped(scope: str, message: str = Query(...)):
|
||||
return await eval_question(message=message)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/suggestions")
|
||||
async def suggestions_scoped(scope: str):
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/stream")
|
||||
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
"""استریم توکنبهتوکن پاسخ با SSE."""
|
||||
if _rate_limited(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="تعداد پیامهای شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
|
||||
|
||||
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
async def generate():
|
||||
async for chunk in _stream_answer(message, thread_id):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# --- پیشنهاد پرسشها (کششده) ---
|
||||
|
||||
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
|
||||
|
||||
|
||||
def _get_suggestions() -> list[str]:
|
||||
global _suggestions_cache
|
||||
now = time.time()
|
||||
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
|
||||
return _suggestions_cache[1]
|
||||
|
||||
questions: list[str] = []
|
||||
if INDEX.ready:
|
||||
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
|
||||
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
|
||||
model = pipeline.get_llm()
|
||||
if model and (faqs or titles):
|
||||
material = "\n".join([d["text"][:200] for d in faqs] + titles)
|
||||
try:
|
||||
prompt = (
|
||||
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
|
||||
"احتمالاً میپرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
|
||||
'فقط آرایه JSON رشتهای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
|
||||
f"محتوا:\n{material[:2000]}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)])
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
if text.startswith("```"):
|
||||
text = text.split("```")[1]
|
||||
text = text[4:] if text.startswith("json") else text
|
||||
import re
|
||||
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||
if match:
|
||||
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
|
||||
except Exception as e:
|
||||
log.warning("Suggestion generation failed: %s", e)
|
||||
if not questions and faqs:
|
||||
# جایگزین: خود پرسشهای متداول
|
||||
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
|
||||
|
||||
_suggestions_cache = (now, questions)
|
||||
return questions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(api, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
|
||||
|
||||
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
||||
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
||||
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .persian import normalize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# URL پایه صفحات — چتبات مسیر نسبی میدهد؛ فرانتاند روی همان دامنه است
|
||||
_ARTICLE_URL = "/blog/{slug}"
|
||||
_PROJECT_URL = "/projects/{slug}"
|
||||
|
||||
|
||||
def _psycopg_dsn(database_url: str) -> str:
|
||||
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
||||
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
|
||||
cur = conn.execute(sql)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def tiptap_blocks(node: Any) -> list[str]:
|
||||
"""متن بلوکبهبلوک از سند TipTap (یا هر JSON تو در تو) بیرون میکشد."""
|
||||
if not isinstance(node, dict):
|
||||
return []
|
||||
ntype = node.get("type")
|
||||
if ntype in ("paragraph", "heading", "codeBlock"):
|
||||
text = _inline_text(node).strip()
|
||||
return [text] if text else []
|
||||
blocks: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
blocks.extend(tiptap_blocks(child))
|
||||
return blocks
|
||||
|
||||
|
||||
def _inline_text(node: dict) -> str:
|
||||
parts: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
ctype = child.get("type")
|
||||
if ctype == "text":
|
||||
parts.append(child.get("text", ""))
|
||||
elif ctype == "hardBreak":
|
||||
parts.append(" ")
|
||||
elif ctype == "image":
|
||||
alt = (child.get("attrs") or {}).get("alt") or ""
|
||||
if alt:
|
||||
parts.append(f"[تصویر: {alt}]")
|
||||
else:
|
||||
parts.append(_inline_text(child))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def body_to_text(body: Any) -> str:
|
||||
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
|
||||
if not body:
|
||||
return ""
|
||||
if isinstance(body, list):
|
||||
# فرمت قدیمی: آرایه رشتهای پاراگرافها
|
||||
return "\n".join(str(p).strip() for p in body if str(p).strip())
|
||||
blocks = tiptap_blocks(body)
|
||||
return "\n".join(b for b in blocks if b)
|
||||
|
||||
|
||||
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return {"text": text, "type": dtype, "title": title, "url": url}
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return normalize(str(value or "")).strip()
|
||||
|
||||
|
||||
def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
docs: list[dict] = []
|
||||
|
||||
# --- برند و اطلاعات تماس (رکورد یگانه) ---
|
||||
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
if brand_rows:
|
||||
b = brand_rows[0]
|
||||
contact = "\n".join(
|
||||
filter(None, [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
])
|
||||
)
|
||||
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- دفاتر ---
|
||||
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
||||
d = _doc(
|
||||
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
|
||||
"office", f"دفتر {_clean(row['city'])}", "/contact",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- خدمات ---
|
||||
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'):
|
||||
d = _doc(
|
||||
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
"service", _clean(row["title"]), "/services",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پرسشهای متداول ---
|
||||
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
|
||||
d = _doc(
|
||||
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
|
||||
"faq", _clean(row["question"]), "/faq",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پروژهها ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
|
||||
):
|
||||
meta = "\n".join(filter(None, [
|
||||
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
||||
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
||||
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
||||
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
|
||||
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
||||
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
|
||||
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
||||
]))
|
||||
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
|
||||
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مقالهها (فقط منتشرشده) ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
|
||||
):
|
||||
text = "\n".join(filter(None, [
|
||||
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
||||
_clean(row["excerpt"]),
|
||||
body_to_text(row["body"]),
|
||||
]))
|
||||
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مدلهای سرمایهگذاری ---
|
||||
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
|
||||
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||
d = _doc(
|
||||
"\n".join(filter(None, [
|
||||
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
f"نکات: {points}" if points else "",
|
||||
])),
|
||||
"investment", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مراحل همکاری ---
|
||||
for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'):
|
||||
d = _doc(
|
||||
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"process", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
|
||||
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
|
||||
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
|
||||
d = _doc(
|
||||
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
|
||||
"team", _clean(row["name"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
|
||||
d = _doc(
|
||||
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
||||
"testimonial", _clean(row["name"]), "/",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
|
||||
d = _doc(
|
||||
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"timeline", _clean(row["title"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
|
||||
if story_rows and story_rows[0]:
|
||||
s = story_rows[0]
|
||||
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
|
||||
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
|
||||
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
log.info("Fetched %d documents from site database", len(docs))
|
||||
return docs
|
||||
|
||||
|
||||
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
||||
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
||||
|
||||
هر قطعه یک doc_id پایدار میگیرد («type:ترتیب در همان url:شماره قطعه») تا
|
||||
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
||||
"""
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
from .vectorstore import chunk_content_hash
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=overlap,
|
||||
separators=["\n", "۔", ".", "،", " ", ""],
|
||||
)
|
||||
|
||||
# شماره ردیف هر سند در میان سندهای همtype و هم-url (برای FAQs که url یکسان دارند)
|
||||
occurrence: dict[str, int] = {}
|
||||
|
||||
out: list[dict] = []
|
||||
for doc in docs:
|
||||
key = f"{doc['type']}|{doc['url']}"
|
||||
occ = occurrence.get(key, 0)
|
||||
occurrence[key] = occ + 1
|
||||
|
||||
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
||||
for i, piece in enumerate(pieces, 1):
|
||||
chunk = {
|
||||
"doc_id": f"{doc['type']}:{occ}:{i}",
|
||||
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
|
||||
"type": doc["type"],
|
||||
"title": doc["title"],
|
||||
"url": doc["url"],
|
||||
}
|
||||
chunk["content_hash"] = chunk_content_hash(chunk)
|
||||
out.append(chunk)
|
||||
|
||||
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
|
||||
return out
|
||||
|
||||
|
||||
def content_fingerprint(docs: list[dict]) -> str:
|
||||
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام میشود."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# --- زمینه همیشهتازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
|
||||
_site_context_cache: tuple[float, str] | None = None
|
||||
|
||||
|
||||
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
||||
"""نام برند، تماس و فهرست خدمات — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
||||
global _site_context_cache
|
||||
import time as _time
|
||||
|
||||
now = _time.time()
|
||||
if _site_context_cache and now - _site_context_cache[0] < ttl:
|
||||
return _site_context_cache[1]
|
||||
try:
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
||||
lines: list[str] = []
|
||||
if brand:
|
||||
b = brand[0]
|
||||
lines += [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
]
|
||||
if services:
|
||||
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
||||
ctx = "\n".join(lines)
|
||||
_site_context_cache = (now, ctx)
|
||||
return ctx
|
||||
except Exception as e:
|
||||
log.warning("Site context fetch failed: %s", e)
|
||||
return _site_context_cache[1] if _site_context_cache else ""
|
||||
@@ -0,0 +1,130 @@
|
||||
# گزارش ارزیابی چتبات — 2026-10-01 21:05
|
||||
|
||||
- مجموع پرسشها: 21 — درست: 17 — خطا: 0
|
||||
- بازبینی دستی: 4 — خطای فنی: 0
|
||||
- میانگین زمان پاسخ: 9.1s
|
||||
|
||||
## [pass] پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟
|
||||
*موضوع: projects — 8.2s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5*
|
||||
|
||||
پروژه «آرامش - میرزا طاهر 5» در اصفهان، وحدت، شهریار (محلهی زهران) ساخته شده و متراژ زیربنای آن ۸۱۰۰ مترمربع است.
|
||||
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
|
||||
|
||||
## [pass] پروژه آرامش - بابک کی و کجا تحویل شده؟
|
||||
*موضوع: projects — 10.4s — منابع: معرفی صفحه اصلی — /، آخرین مقالات — /blog، آرامش - بابک — /projects/آرامش---بابک، پروژهها — /projects، تحویل و پشتیبانی — /investment*
|
||||
|
||||
پروژه «آرامش - بابک» طبق اطلاعات سایت در سال ۱۴۰۵ تکمیل شده و در اصفهان، شهید رضایی، بین باغ مسجد و ارشاد (محله برزان) قرار دارد.
|
||||
جزئیات کامل در صفحه /projects/آرامش---بابک در دسترس است.
|
||||
|
||||
## [pass] وضعیت پروژه میرزاطاهر ۶ چطور است؟
|
||||
*موضوع: projects — 8.7s — منابع: آرامش - میرزاطاهر 6 — /projects/آرامش---میرزاطاهر-6، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، کوثر- میرزاطاهر — /projects/کوثر--میرزاطاهر، نیکان میرزاطاهر — /projects/نیکان-میرزاطاهر، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر*
|
||||
|
||||
پروژه «آرامش - میرزاطاهر ۶» در اصفهان، میرزاطاهر شرقی (محله زهران) قرار دارد و وضعیت آن **در حال ساخت** است؛ عرصه نیز **در حال اخذ مجوز** است.
|
||||
جزئیات کامل در صفحه /projects/آرامش---میرزاطاهر-6 در دسترس است.
|
||||
|
||||
## [pass] کدام پروژه شما در محله گورتان اصفهان است؟
|
||||
*موضوع: projects — 8.2s — منابع: آرامش - وحدت — /projects/آرامش---وحدت، سالهای توسعه و تجربه — /about، آرامش - آتشگاه — /projects/آرامش---آتشگاه*
|
||||
|
||||
پروژه «آرامش - وحدت» در اصفهان، آتشگاه، محلهی گورتان واقع شده و وضعیت آن تکمیلشده است. این پروژه کاربری مسکونی-تجاری دارد و شامل ۸ واحد مسکونی و ۱ واحد تجاری است.
|
||||
|
||||
جزئیات کامل در صفحه /projects/آرامش---وحدت موجود است.
|
||||
|
||||
## [pass] پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟
|
||||
*موضوع: projects — 9.0s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2*
|
||||
|
||||
پروژه «آرامش - میرزا طاهر 5» دارای **۲۹ واحد مسکونی** است.
|
||||
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
|
||||
|
||||
## [pass] گروه ساختمانی آرامش چند سال سابقه دارد؟
|
||||
*موضوع: stats — 10.3s — منابع: آخرین مقالات — /blog، شرکت آرامش از چه سالی فعالیت میکند؟ — /faq، درباره ما — /about، تجربه بیش از دو دهه — /about، گروه ساختمانی آرامش — /about*
|
||||
|
||||
گروه ساختمانی آرامش بیش از ۲۳ سال سابقه فعالیت در حوزه ساختوساز دارد و فعالیت مستمر خود را از سال ۱۳۸۱ آغاز کرده است.
|
||||
|
||||
## [pass] تا امروز چند پروژه تحویل دادهاید؟
|
||||
*موضوع: stats — 8.7s — منابع: پروژهها — /projects، آمار و افتخارات — /، تعهدی فراتر از تحویل ساختمان — /about، تحویل و پشتیبانی — /investment*
|
||||
|
||||
طبق اطلاعات ثبتشده در سایت، گروه ساختمانی آرامش تاکنون **۶۴ پروژه تحویلداده** است.
|
||||
|
||||
## [pass] مجموع متراژ ساختوساز شما چقدر است؟
|
||||
*موضوع: stats — 9.2s — منابع: آمار و افتخارات — /، شروع مسیر ساختوساز — /about، سرمایهگذاری — /investment، درباره ما — /about، گروه ساختمانی آرامش — /about*
|
||||
|
||||
مجموع زیربنای ساختوساز گروه ساختمانی آرامش ۱۱۱ هزار مترمربع است.
|
||||
|
||||
## [pass] چند خانواده در پروژههای شما ساکن هستند؟
|
||||
*موضوع: stats — 7.2s — منابع: آمار و افتخارات — /*
|
||||
|
||||
بر اساس آمار ثبتشده، بیش از ۵۰۰ خانواده در پروژههای گروه ساختمانی آرامش ساکن هستند.
|
||||
|
||||
## [pass] شماره تماس شما چیست؟
|
||||
*موضوع: contact — 6.6s — منابع: اطلاعات تماس — /contact*
|
||||
|
||||
شماره تماس گروه ساختمانی آرامش: **۰۹۱۳۳۱۰۷۸۰۹**
|
||||
جزئیات بیشتر در صفحه «تماس با ما» موجود است.
|
||||
|
||||
## [pass] دفتر تهران کجاست؟
|
||||
*موضوع: offices — 7.6s — منابع: دفتر تهران — /contact*
|
||||
|
||||
دفتر تهران گروه ساختمانی آرامش در خیابان ولیعصر، برج آرامش، طبقه هجدهم واقع شده است.
|
||||
جزئیات بیشتر در صفحه /contact در دسترس است.
|
||||
|
||||
## [pass] آدرس ایمیل مجموعه چیست؟
|
||||
*موضوع: contact — 8.7s — منابع: اطلاعات تماس — /contact، دفتر نوشهر — /contact، دفتر اصفهان — /contact، تماس با ما — /contact*
|
||||
|
||||
ایمیل مجموعه: arameshsakhtemani@gmail.com
|
||||
|
||||
## [pass] دفاتر شما در چه شهرهایی است؟
|
||||
*موضوع: offices — 9.2s — منابع: ساختمان اداری — /services، ساختمان تجاری — /services*
|
||||
|
||||
دفاتر گروه ساختمانی آرامش در سه شهر زیر فعال هستند:
|
||||
|
||||
- **تهران:** خیابان ولیعصر، برج آرامش، طبقه هجدهم
|
||||
- **اصفهان:** خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم
|
||||
- **نوشهر:** بلوار دریا، دفتر پروژه دریاکنار
|
||||
|
||||
## [pass] گروه آرامش چه سالی تأسیس شده؟
|
||||
*موضوع: faqs — 18.5s — منابع: شرکت آرامش از چه سالی فعالیت میکند؟ — /faq، آخرین مقالات — /blog، ورود به صنعت ساختمان — /about*
|
||||
|
||||
بر اساس اطلاعات موجود، شرکت ساختمانی آرامش از سال ۱۳۸۱ فعالیت خود را آغاز کرده است. همچنین در صفحه «درباره ما» سابقه ورود مجموعه به صنعت ساختمان از سال ۱۳۷۷ با تأسیس شرکت پیمانکاری ذکر شده است. جزئیات بیشتر در صفحات /faq و /about در دسترس است.
|
||||
|
||||
## [manual] زمین دارم، ساخت را از کجا شروع کنم؟
|
||||
*موضوع: faqs — 7.9s — منابع: اگر زمین داشته باشیم، از کجا باید برای ساخت شروع کنیم؟ — /faq*
|
||||
|
||||
اولین قدم، بررسی زمین و شرایط پروژه است. موقعیت، ضوابط و تراکم، شرایط فنی و ظرفیت اقتصادی زمین بررسی میشود تا پیش از ورود به طراحی و اجرا، مسیر مناسب پروژه مشخص شود. جزئیات کامل در صفحه /faq موجود است.
|
||||
|
||||
## [manual] شرایط مشارکت در ساخت چگونه است؟
|
||||
*موضوع: investment — 8.5s — منابع: آیا امکان مشارکت در ساخت هم دارید؟ — /faq، مشارکت در ساخت — /investment، وبلاگ — /blog، تحویل و پشتیبانی — /investment*
|
||||
|
||||
شرایط مشارکت در ساخت به این صورت است که مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند و اجرا، دریافت مجوزها و فروش بر عهده ماست. ابتدا ملک بهصورت کارشناسی بررسی میشود، سپس قرارداد شفاف با ضمانت اجرا تنظیم میگردد و در طول پروژه گزارش پیشرفت ارائه میشود. جزئیات کامل در صفحه /investment قابل مشاهده است.
|
||||
|
||||
## [pass] مدل صندوق سرمایهگذاری چطور کار میکند؟
|
||||
*موضوع: investment — 8.1s — منابع: صندوق سرمایهگذاری — /investment، مدلهای همکاری — /investment، سرمایهگذاری ساختمانی — /services، سرمایهگذاری — /investment*
|
||||
|
||||
صندوق سرمایهگذاری به این صورت کار میکند که سرمایهگذاران واحدهایی از پروژههای آرامش را خریداری میکنند و بازدهی آنها در پایان دوره محاسبه میشود. حداقل دوره سرمایهگذاری ۲۴ ماه است، گزارش ارزشگذاری بهصورت فصلی ارائه میشود و امکان انتقال واحد سرمایه نیز وجود دارد.
|
||||
|
||||
جزئیات کامل در صفحه /investment در دسترس است.
|
||||
|
||||
## [pass] پیشخرید واحد یعنی چه؟
|
||||
*موضوع: investment — 8.0s — منابع: پیشخرید واحد — /investment*
|
||||
|
||||
پیشخرید واحد یعنی خرید واحد مسکونی، اداری یا تجاری در مراحل مختلف ساخت. از جمله نکات آن میتوان به امکان شخصیسازی پلان، تضمین زمان تحویل و پرداخت توافقی اشاره کرد. جزئیات کامل در صفحه /investment.
|
||||
|
||||
## [manual] خدمات گروه آرامش چیست؟
|
||||
*موضوع: services — 7.7s — منابع: خدمات شرکت آرامش شامل چه مواردی است؟ — /faq، آخرین مقالات — /blog، آیا امکان مشارکت در ساخت هم دارید؟ — /faq، همراهی از صفر تا صد — /about*
|
||||
|
||||
خدمات گروه ساختمانی آرامش شامل طراحی معماری، ساخت ساختمانهای مسکونی، تجاری و اداری، طراحی و ویلاسازی، طراحی فضاهای عمومی و رفاهی، بازسازی، مدیریت پروژه و سرمایهگذاری ساختمانی است. ما خدمات کامل صفر تا صد را از طراحی تا تحویل کلید پوشش میدهیم. جزئیات بیشتر در صفحه /faq قابل مشاهده است.
|
||||
|
||||
## [manual] نظرات مشتریان قبلی درباره شما چیست؟
|
||||
*موضوع: testimonials — 11.2s — منابع: همراهی با تغییر نیازهای بازار مسکن — /about، نظرات مشتریان — /، سوالات متداول — /faq، سوالات متداول — /faq*
|
||||
|
||||
بر اساس نظرات ثبتشده در سایت، مشتریان قبلی رضایت خود را اینگونه اعلام کردهاند:
|
||||
|
||||
- مهندس سارا کیانی، سرمایهگذار پروژه پارسه: گزارشهای ماهانه آنقدر دقیق بود که نیازی به نگرانی درباره سرمایه نبود و پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.
|
||||
- دکتر امیرحسین راد، ساکن برج الهیه: کیفیت اجرا در جزئیاتی مانند عایق صوتی پنجرهها و نور غیرمستقیم راهروها دیده میشود.
|
||||
- مریم دلاور، مدیر مجموعه ویلاهای دریاکنار: همکاری با تیم طراحی حرفهای بود و ایدهها به زبان معماری ترجمه شد.
|
||||
|
||||
جزئیات بیشتر در صفحه اصلی سایت (/) در بخش نظرات مشتریان آمده است.
|
||||
|
||||
## [fallback-expected] هزینه ساخت هر متر مربع چقدر است؟
|
||||
*موضوع: canary — 9.7s — منابع: آمار و افتخارات — /، اجرا و کنترل کیفیت — /investment، مدیریت پروژه — /services، شفافیت کامل — /about*
|
||||
|
||||
متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید.
|
||||
@@ -0,0 +1,191 @@
|
||||
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
|
||||
|
||||
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفهجویی در تأخیر)
|
||||
۲) بازیابی ترکیبی با چند نوعواژه (اختیاری)
|
||||
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import json as _json
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
from . import content as content_mod
|
||||
from .config import settings
|
||||
from .retrieval import retrieve_final
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_MESSAGE = (
|
||||
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||
)
|
||||
LLM_UNAVAILABLE = (
|
||||
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
|
||||
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چتبات تنظیم کنید."
|
||||
)
|
||||
|
||||
_llm = None
|
||||
|
||||
|
||||
def get_llm() -> ChatOpenAI | None:
|
||||
"""LLM تولید — اگر پیکربندی نشده باشد None برمیگرداند."""
|
||||
global _llm
|
||||
if _llm is None and settings.llm_base_url:
|
||||
_llm = ChatOpenAI(
|
||||
model=settings.llm_model,
|
||||
base_url=settings.llm_base_url,
|
||||
api_key=settings.llm_api_key or "not-needed",
|
||||
temperature=0,
|
||||
streaming=True,
|
||||
)
|
||||
return _llm
|
||||
|
||||
|
||||
class GraphState(TypedDict):
|
||||
messages: Annotated[List, add_messages]
|
||||
documents: List[str]
|
||||
contextualized_query: Optional[str]
|
||||
|
||||
|
||||
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
|
||||
|
||||
def contextualize_query(question: str, history: list) -> str:
|
||||
"""ضمیرها و اشارههای پرسش را با تاریخچه گفتگو مشخص میکند («اون دومی» → موضوع واقعی)."""
|
||||
model = get_llm()
|
||||
if model is None or not history:
|
||||
return question
|
||||
try:
|
||||
history_text = "\n".join(
|
||||
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
|
||||
for m in history[-6:]
|
||||
)
|
||||
prompt = (
|
||||
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
|
||||
"- ضمیرها و اشارهها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
|
||||
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
|
||||
"- فقط متن پرسش بازنویسیشده را برگردان؛ هیچ توضیحی ننویس.\n\n"
|
||||
f"تاریخچه گفتگو:\n{history_text}\n\n"
|
||||
f"پرسش کاربر: {question}\n\n"
|
||||
"پرسش مستقل:"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
|
||||
if not rewritten or len(rewritten) > len(question) * 4:
|
||||
return question
|
||||
if rewritten != question:
|
||||
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
|
||||
return rewritten
|
||||
except Exception as e:
|
||||
log.warning("Contextualization failed: %s", e)
|
||||
return question
|
||||
|
||||
|
||||
def expand_query(query: str) -> list[str]:
|
||||
"""گسترش پرسش به چند نوعواژه (اختیاری — پیشفرض خاموش برای پیکره کوچک)."""
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return [query]
|
||||
try:
|
||||
prompt = (
|
||||
"برای جستوجو در محتوای سایت یک گروه ساختمانی، ۳ نوعواژه جایگزین برای این پرسش بساز: "
|
||||
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
|
||||
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
|
||||
f"پرسش: {query}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
|
||||
if match:
|
||||
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
|
||||
if variants:
|
||||
log.info("Query variants: %s", [query] + variants)
|
||||
return [query] + variants
|
||||
except Exception as e:
|
||||
log.warning("Query expansion failed: %s", e)
|
||||
return [query]
|
||||
|
||||
|
||||
# --- گام ۲: بازیابی ---
|
||||
|
||||
def retrieve_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return {"documents": []}
|
||||
question = messages[-1].content
|
||||
history = messages[:-1]
|
||||
|
||||
query = contextualize_query(question, history) if settings.contextualize_enabled else question
|
||||
variants = expand_query(query) if settings.query_expansion else [query]
|
||||
|
||||
docs = retrieve_final(query, variants)
|
||||
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
|
||||
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
|
||||
return {
|
||||
"documents": doc_texts,
|
||||
"contextualized_query": query if query != question else None,
|
||||
}
|
||||
|
||||
|
||||
# --- گام ۳: تولید پاسخ ---
|
||||
|
||||
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
|
||||
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
|
||||
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
|
||||
reminder = (
|
||||
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
|
||||
if contextualized_query else ""
|
||||
)
|
||||
return (
|
||||
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ میدهی.\n"
|
||||
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
|
||||
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
|
||||
f"--- اسناد بازیابیشده مرتبط با پرسش ---\n{docs_block}\n"
|
||||
f"{reminder}"
|
||||
"--- قوانین پاسخ ---\n"
|
||||
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ بههیچوجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
|
||||
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفهای و محترمانه داشته باشد.\n"
|
||||
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
|
||||
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
|
||||
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در دادهها آمده است."
|
||||
)
|
||||
|
||||
|
||||
def generate_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
question = messages[-1].content if messages else ""
|
||||
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
|
||||
|
||||
documents = state.get("documents") or []
|
||||
system = build_system_prompt(documents, state.get("contextualized_query"))
|
||||
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
|
||||
|
||||
history = messages[:-1]
|
||||
reply = model.invoke(
|
||||
[SystemMessage(content=system), *history, HumanMessage(content=question)],
|
||||
config={"tags": ["generate"]},
|
||||
)
|
||||
return {"messages": [reply]}
|
||||
|
||||
|
||||
# --- گراف ---
|
||||
|
||||
memory = MemorySaver()
|
||||
workflow = StateGraph(GraphState)
|
||||
workflow.add_node("retrieve", retrieve_node)
|
||||
workflow.add_node("generate", generate_node)
|
||||
workflow.set_entry_point("retrieve")
|
||||
workflow.add_edge("retrieve", "generate")
|
||||
workflow.add_edge("generate", END)
|
||||
graph = workflow.compile(checkpointer=memory)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""تستهای خالص (بدون مدل/دیتابیس) برای ماژولهای چتبات — اجرا: python test_pure.py"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
|
||||
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
|
||||
from chatbot.persian import normalize, tokenize
|
||||
|
||||
docs = [
|
||||
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
|
||||
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
|
||||
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
|
||||
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
|
||||
]
|
||||
chunks = chunk_documents(docs)
|
||||
ids = [c["doc_id"] for c in chunks]
|
||||
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
||||
|
||||
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
||||
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
|
||||
|
||||
art = [c for c in chunks if c["type"] == "article"]
|
||||
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
|
||||
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
||||
|
||||
c = chunks[0]
|
||||
assert c["content_hash"] == chunk_content_hash(c)
|
||||
c2 = dict(c, title="عنوان عوض شد")
|
||||
# hash فیلد کپیشده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
|
||||
assert chunk_content_hash(c2) != c["content_hash"]
|
||||
|
||||
chunks2 = chunk_documents(docs)
|
||||
assert [x["doc_id"] for x in chunks2] == ids
|
||||
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
|
||||
|
||||
settings_dsn = chatbot_dsn()
|
||||
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
|
||||
assert "+psycopg" not in settings_dsn
|
||||
|
||||
# نرمالسازی و توکنسازی فارسی
|
||||
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
|
||||
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
|
||||
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
|
||||
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
|
||||
|
||||
# استخراج متن TipTap و فرمت قدیمی
|
||||
tiptap_doc = {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
|
||||
{"type": "paragraph", "content": [
|
||||
{"type": "text", "text": "این برج در تهران "},
|
||||
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
|
||||
{"type": "hardBreak"},
|
||||
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
|
||||
]},
|
||||
{"type": "bulletList", "content": [
|
||||
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
|
||||
]},
|
||||
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
|
||||
],
|
||||
}
|
||||
blocks = tiptap_blocks(tiptap_doc)
|
||||
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
|
||||
assert body_to_text(["الف", "ب"]) == "الف\nب"
|
||||
assert body_to_text(None) == ""
|
||||
|
||||
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
|
||||
@@ -0,0 +1,217 @@
|
||||
"""ذخیرهساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
|
||||
|
||||
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
|
||||
نگهداری میشوند تا دیتابیس سایت فقطخواندنی بماند. همگامسازی افزایشی است:
|
||||
فقط سطرهای جدید/تغییریافته امبد میشوند (کلید: doc_id پایدار + hash محتوا).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _strip_driver(dsn: str) -> str:
|
||||
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def chatbot_dsn() -> str:
|
||||
"""DSN دیتابیس اختصاصی چتبات — از DATABASE_URL با تعویض نام دیتابیس ساخته میشود."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
|
||||
|
||||
|
||||
def _admin_dsn() -> str:
|
||||
"""DSN دیتابیس postgres برای ساخت دیتابیس چتبات (فقط یکبار)."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path="/postgres"))
|
||||
|
||||
|
||||
def ensure_schema() -> None:
|
||||
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا میشود."""
|
||||
import psycopg
|
||||
from psycopg import errors as pg_errors
|
||||
|
||||
try:
|
||||
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
try:
|
||||
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
|
||||
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
|
||||
except pg_errors.DuplicateDatabase:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS chatbot_chunks (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
embedding vector({settings.embedding_dim}) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
|
||||
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||
"""همگامسازی افزایشی قطعات با جدول pgvector.
|
||||
|
||||
- سطرهایی که دیگر وجود ندارند حذف میشوند
|
||||
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert میشوند
|
||||
- force=True همه سطرها را از نو امبد میکند
|
||||
خروجی: آمار عملیات برای لاگ.
|
||||
"""
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
model = settings.embedding_model
|
||||
wanted = {c["doc_id"]: c for c in chunks}
|
||||
|
||||
# row_factory لازم است: پایینتر row["model"]/row["doc_id"] خوانده میشود و
|
||||
# پیشفرض psycopg3 تاپل برمیگرداند (خطای tuple indices must be integers).
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||
row_factory=dict_row) as conn:
|
||||
existing: dict[str, str] = {}
|
||||
if not force:
|
||||
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
||||
if row["model"] == model:
|
||||
existing[row["doc_id"]] = row["content_hash"]
|
||||
else:
|
||||
conn.execute("DELETE FROM chatbot_chunks")
|
||||
|
||||
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
|
||||
if to_delete:
|
||||
conn.execute(
|
||||
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
|
||||
[to_delete],
|
||||
)
|
||||
|
||||
to_upsert = [
|
||||
c for c in chunks
|
||||
if existing.get(c["doc_id"]) != c["content_hash"]
|
||||
]
|
||||
inserted = updated = 0
|
||||
|
||||
if to_upsert:
|
||||
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
|
||||
for chunk, vec in zip(to_upsert, vectors):
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s::vector)
|
||||
ON CONFLICT (doc_id) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
model = EXCLUDED.model,
|
||||
content = EXCLUDED.content,
|
||||
embedding = EXCLUDED.embedding
|
||||
""",
|
||||
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
|
||||
)
|
||||
if chunk["doc_id"] in existing:
|
||||
updated += 1
|
||||
else:
|
||||
inserted += 1
|
||||
|
||||
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
|
||||
"unchanged": len(wanted) - len(to_upsert)}
|
||||
log.info("Vector sync: %s", stats)
|
||||
return stats
|
||||
|
||||
|
||||
def fetch_all_chunks() -> list[dict]:
|
||||
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||
row_factory=dict_row) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"doc_id": r["doc_id"],
|
||||
"content_hash": r["content_hash"],
|
||||
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def vector_count() -> int:
|
||||
"""تعداد بردارهای ذخیرهشده — برای healthz (خطا → ۰)."""
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||
|
||||
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
|
||||
"""
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row as _dict_row
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
vec = get_embeddings().embed_query(query)
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
|
||||
FROM chatbot_chunks
|
||||
ORDER BY embedding <=> %s::vector
|
||||
LIMIT %s
|
||||
""",
|
||||
(literal, literal, top_k),
|
||||
).fetchall()
|
||||
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
|
||||
except Exception as e:
|
||||
log.warning("Dense search failed (%s) — falling back to keyword only", e)
|
||||
return []
|
||||
|
||||
|
||||
def _row_to_doc(row: Any) -> dict:
|
||||
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"text": content.get("text", ""),
|
||||
"type": content.get("type", ""),
|
||||
"title": content.get("title", ""),
|
||||
"url": content.get("url", ""),
|
||||
}
|
||||
|
||||
|
||||
def chunk_content_hash(chunk: dict) -> str:
|
||||
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید بهروز شود."""
|
||||
payload = json.dumps(
|
||||
{k: chunk[k] for k in ("text", "type", "title", "url")},
|
||||
ensure_ascii=False, sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
@@ -1,4 +1,5 @@
|
||||
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
|
||||
# راه سادهتر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی میسازد.
|
||||
# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
|
||||
|
||||
# --- رمزهای اصلی (اجباری) ---
|
||||
@@ -14,10 +15,57 @@ ADMIN_PASSWORD=change-me-admin-password
|
||||
# پورت ورود Traefik در توسعه (پیشفرض 5173)
|
||||
TRAEFIK_HTTP_PORT=5173
|
||||
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
|
||||
# در تولید این مقدار هم پایهٔ پروب بیروننگر مانیتورینگ است.
|
||||
SITE_URL=
|
||||
# آدرسهای مجاز CORS (لیست JSON) — پیشفرض فقط لوکالهاست
|
||||
# CORS_ORIGINS=["http://localhost:5173"]
|
||||
|
||||
# --- بیلد فرانتاند (در زمان npm run build روی هاست خوانده میشود — stack.sh پاس میدهد) ---
|
||||
# اگر API روی مبدأ دیگری است (مثل توسعه ویندوز با پورت 19001) تنظیم کنید؛ هممبدأ یعنی خالی
|
||||
VITE_API_URL=
|
||||
# اسکریپت ردیاب Umami — خالی یعنی آنالیتیکس خاموش؛ در تولید مثل:
|
||||
# VITE_UMAMI_SRC=https://analytics.aramesh.example/script.js
|
||||
VITE_UMAMI_SRC=
|
||||
# شناسهٔ وبسایت از داشبورد Umami (اولین بار: وبسایت را در analytics.localhost بسازید)
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
# --- چتبات (دستیار هوشمند) ---
|
||||
# LLM سازگار با OpenAI — اجباری برای پاسخگویی چتبات؛ خالی یعنی سرویس بالا میآید اما پاسخ نمیدهد
|
||||
LLM_BASE_URL=
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=openai/gpt-oss-120b
|
||||
# توکن محافظت از POST /chat/reindex — خالی یعنی فقط از شبکه داخلی
|
||||
CHAT_REINDEX_TOKEN=
|
||||
|
||||
# --- مانیتورینگ (docker-compose.monitoring.yml) ---
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=change-me-grafana-password
|
||||
# هشدارها به بله — توکن بات (همان شکل تلگرام؛ از BotFather بله) و شناسه چت/گروه
|
||||
# شناسه چت: به بات یک پیام بدهید سپس:
|
||||
# curl "https://tapi.bale.ai/bot<توکن>/getUpdates"
|
||||
ALERT_BOT_TOKEN=
|
||||
ALERT_CHAT_ID=0
|
||||
|
||||
# --- عملیات ---
|
||||
# نگهداری پشتیبانهای شبانه (روز) — scripts/backup.sh
|
||||
RETENTION_DAYS=14
|
||||
# انتشار پشتیبان خارج از سرور (rclone remote مثل storage:aramesh-backups) — خالی یعنی خاموش
|
||||
OFFSITE_RCLONE_REMOTE=
|
||||
|
||||
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
||||
# DOMAIN=aramesh.example
|
||||
# ACME_EMAIL=admin@aramesh.example
|
||||
# دامنه دوم (در صورت وجود) — مسیرها و گواهی TLS برای هر دو صادر میشود
|
||||
# DOMAIN_ALIAS=aramesh.example.ir
|
||||
|
||||
# --- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ---
|
||||
# «on» یعنی سایت آنلاین است اما پشت گیت ورود و بدون ایندکس؛ «off» یعنی انتشار عمومی.
|
||||
# این مقادیر را دستی ویرایش نکنید — اسکریپت preview-mode.sh هماهنگشان میکند:
|
||||
# bash scripts/preview-mode.sh on|off|status → bash scripts/stack.sh deploy-prod
|
||||
# PREVIEW_MODE=on
|
||||
# هش htpasswd کاربر مشترک پیشنمایش — اولین «on» خودش میسازد
|
||||
# PREVIEW_AUTH_USERS=preview:$apr1$...
|
||||
# فهرست میانافزارهای فعال؛ «on» پرش میکند و «off» خالیش میکند
|
||||
# PREVIEW_MIDDLEWARES=preview-auth@docker,preview-noindex@docker
|
||||
# در انتشار، دامنه دوم را به اصلی ریدایرکت میکند تا محتوای تکراری ساخته نشود
|
||||
# ALIAS_REDIRECT_MIDDLEWARES=to-primary@docker
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# اسکریپتهای شل باید در لینوکس هم LF باشند — CRLF در bash میشکند
|
||||
*.sh text eol=lf
|
||||
*.yml text
|
||||
*.yaml text
|
||||
*.json text
|
||||
Dockerfile text
|
||||
*.py text
|
||||
@@ -0,0 +1,47 @@
|
||||
name: CI
|
||||
|
||||
# گیت سرور self-hosted شما (Gitea Actions / Forgejo) همین سینتکس را اجرا میکند؛
|
||||
# روی GitHub هم بیتغییر کار میکند.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install backend deps
|
||||
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: pytest -q
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Frontend typecheck/build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build images
|
||||
run: docker compose -f docker-compose.yml build backend frontend chatbot
|
||||
@@ -9,6 +9,12 @@ env/
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
|
||||
monitoring/alertmanager/alertmanager.rendered.yml
|
||||
|
||||
# تنظیمات محلی هر ماشین
|
||||
docker-compose.override.yml
|
||||
|
||||
# پشتیبانگیری محلی
|
||||
backups/
|
||||
|
||||
@@ -22,6 +28,7 @@ dist/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.zcode/
|
||||
|
||||
# Database
|
||||
*.sqlite3
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
مونوریپوی سایت گروه ساختمانی آرامش شامل:
|
||||
|
||||
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
|
||||
- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
|
||||
- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
|
||||
- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami بهصورت لایههای compose
|
||||
|
||||
## معماری
|
||||
|
||||
```
|
||||
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند)
|
||||
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
|
||||
Traefik (تنها نقطه ورود — 80/443 در تولید)
|
||||
├── / → Caddy (فایلهای استاتیک SPA)
|
||||
├── /api → FastAPI (بکاند) + متریک /metrics فقط داخلی
|
||||
├── /media → MinIO (باکت aramesh-media)
|
||||
├── /chat → چتبات (SSE استریم پاسخ)
|
||||
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
|
||||
└── analytics.* → Umami (آنالیتیکس محصول)
|
||||
```
|
||||
|
||||
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
|
||||
@@ -19,107 +24,213 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
|
||||
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
|
||||
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
|
||||
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
|
||||
- شبکههای compose تفکیک شدهاند: `edge` (سرویسهای پشت Traefik) و `internal`
|
||||
(دیتابیس/ذخیرهسازی) — فرانتاند هیچ راه مستقیمی به دیتابیس ندارد
|
||||
- **لاگها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
|
||||
Loki میفرستد تا در Grafana جستوجو شوند
|
||||
|
||||
## ساختار
|
||||
|
||||
```
|
||||
├── frontend/ # سایت React
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
|
||||
│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── requirements.txt
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
|
||||
├── docker-compose.yml # اجرای کامل با یک دستور
|
||||
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
|
||||
├── frontend/ # سایت React (بیلد روی هاست — ایمیج فقط سرو میکند)
|
||||
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
|
||||
│ ├── Caddyfile # سرو استاتیک + کش immutable برای /assets/*
|
||||
│ └── Dockerfile # Caddy — بدون root
|
||||
├── backend/ # بکاند FastAPI
|
||||
│ ├── app/ # کد منبع (آپلود: app/api/admin.py — سئو: app/seo_pages.py)
|
||||
│ ├── migrations/ # مهاجرتهای نسخهدار Alembic
|
||||
│ ├── tests/ # تستهای API (pytest)
|
||||
│ ├── alembic.ini
|
||||
│ └── Dockerfile # بدون root — خاموشی تمیز با exec
|
||||
├── chatbot/ # دستیار هوشمند (RAG + pgvector)
|
||||
├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
|
||||
├── scripts/
|
||||
│ ├── stack.sh # ابزار اصلی عملیات — deploy/logs/health/backup/restore/update
|
||||
│ ├── backup.sh # پشتیبان کامل (DB + تصاویر) با چکسام و گزینه انتشار بیرونی
|
||||
│ ├── restore.sh # بازگردانی پشتیبان دیتابیس
|
||||
│ ├── gen-env.sh # ساخت .env با رمزهای قوی تصادفی
|
||||
│ ├── audit.sh # ممیزی آسیبپذیری وابستگیها و ایمیجها
|
||||
│ ├── migrate-media.py # انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||
│ └── ci.sh # تست + بیلد ایمیج (محلی یا هر CI)
|
||||
├── docker-compose.yml # استک اصلی (شامل چتبات و Umami)
|
||||
├── docker-compose.monitoring.yml # لایه مانیتورینگ
|
||||
├── docker-compose.prod.yml # لایه تولید: TLS، دامنه، سختسازی پورتها
|
||||
└── docker-compose.override.yml # تنظیمات محلی این ماشین (در گیت نیست)
|
||||
```
|
||||
|
||||
## اجرا با Docker (سادهترین راه)
|
||||
## اجرا با Docker
|
||||
|
||||
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
|
||||
`dist/` را سرو میکند)، پس اولین بار باید یکبار `npm run build` بزنید:
|
||||
`dist/` را سرو میکند) — `scripts/stack.sh` همین را به یک دستور تبدیل میکند:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
docker compose up -d --build
|
||||
cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
|
||||
cd frontend && npm install && cd ..
|
||||
bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
|
||||
```
|
||||
|
||||
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
|
||||
|
||||
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
|
||||
- سایت: http://localhost:18081 (پورت از `TRAEFIK_HTTP_PORT` در `.env`) — ورود مدیریت: `/admin/login`
|
||||
- دستورهای عملیات: `bash scripts/stack.sh` — `logs`، `ps`، `health`، `backup`، `restore`، `update`، `seed-force`
|
||||
- مستندات API: http://localhost:19001/docs (با `docker-compose.override.yml` محلی)
|
||||
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
|
||||
- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
|
||||
ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد، خواندن عمومی را
|
||||
فعال میکند و تصاویر داخل `seed-media/` را در باکت میریزد — پس یک کلون تازه هم
|
||||
همان تصاویر سایت را دارد
|
||||
- دیتابیس PostgreSQL (تصویر pgvector)، آبجکتاستور MinIO و کش مدلهای چتبات هر یک
|
||||
در کانتینر جدا با volume دائمی
|
||||
- بکاند هنگام شروع، جداول پایه را میسازد، **مهاجرتهای Alembic** را اعمال میکند و
|
||||
**فقط جدولهای خالی** را seed میکند — ارتقای نسخه دادههای فعلی را از بین نمیبرد
|
||||
- `minio-setup` باکت `aramesh-media` را میسازد و فقط **وقتی باکت خالی است** تصاویر
|
||||
`seed-media/` را میریزد — `db-init` هم دیتابیسهای `umami` و `aramesh_chatbot` را
|
||||
روی همان نمونهٔ Postgres میسازد
|
||||
|
||||
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
|
||||
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
|
||||
>
|
||||
> پایهٔ API فرانتاند در زمان بیلد از `VITE_API_URL` در `.env` خوانده میشود —
|
||||
> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
|
||||
|
||||
## انتشار واقعی (TLS)
|
||||
|
||||
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
|
||||
پیشنیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
|
||||
است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
|
||||
`SITE_URL=https://example.com`:
|
||||
|
||||
```bash
|
||||
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
bash scripts/stack.sh deploy-prod
|
||||
```
|
||||
|
||||
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
|
||||
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
|
||||
- **پورتهای مدیریتی MinIO (۹۰۰۱ کنسول و ۱۹۰۰۰ API) در تولید فقط روی `127.0.0.1`
|
||||
بسته میشوند** — دسترسی از راه دور فقط با تونل SSH:
|
||||
`ssh -L 9001:127.0.0.1:9001 server`
|
||||
- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
|
||||
|
||||
## اجرای محلی (توسعه)
|
||||
### پیشنمایش خصوصی (کار روی سایت، قبل از انتشار عمومی)
|
||||
|
||||
### بکاند
|
||||
سایت روی اینترنت بالا میآید و تیم با همان پنل مدیریت محتوا را ویرایش میکند
|
||||
(ویرایش درجای ایمیل/تلفن/متنها و نوشتن مقاله)، اما **نه عموم به آن دسترسی دارند و
|
||||
نه گوگل چیزی ایندکس میکند**. دو لایه محافظت:
|
||||
|
||||
1. **گیت ورود Traefik (Basic Auth)** — همه مسیرها (سایت، API، تصاویر و چت) پشت یک
|
||||
نام کاربری/رمز مشترکاند؛ بدون آن فقط ۴۰۱ برمیگردد
|
||||
2. **noindex** — هدر `X-Robots-Tag` روی همه پاسخها + `robots.txt` مسدودکننده +
|
||||
نقشه سایت خالی؛ حتی اگر گیت برداشته شود، گوگل چیزی ذخیره نمیکند
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
|
||||
python -m app.seed # ساخت جداول + محتوای اولیه
|
||||
uvicorn app.main:app --reload
|
||||
bash scripts/preview-mode.sh on # فعالسازی (بار اول نام کاربری/رمز میسازد و چاپ میکند)
|
||||
bash scripts/stack.sh deploy-prod # اعمال
|
||||
bash scripts/preview-mode.sh status # وضعیت فعلی
|
||||
```
|
||||
|
||||
### فرانتاند
|
||||
- دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو میشوند و هر دو گیت دارند
|
||||
- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید
|
||||
- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) میماند — فقط یکبار چاپ میشود
|
||||
|
||||
### انتشار عمومی (برداشتن پیشنمایش)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس
|
||||
bash scripts/stack.sh deploy-prod
|
||||
```
|
||||
|
||||
در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
|
||||
پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
|
||||
`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
|
||||
بعد از این مرحله:
|
||||
|
||||
1. دامنه دوم بهصورت ۳۰۱ دائمی به دامنه اصلی ریدایرکت میشود (جلوگیری از محتوای تکراری)
|
||||
2. در Search Console دامنه را تأیید و `https://DOMAIN/sitemap.xml` را ثبت کنید
|
||||
3. اگر پیشتر چیزی ایندکس شده بود، حذفش را از همان کنسول درخواست کنید
|
||||
|
||||
### چکلیست رفتن به تولید
|
||||
|
||||
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
|
||||
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
||||
3. `DOMAIN`، `DOMAIN_ALIAS` (دامنه دوم)، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
||||
4. `bash scripts/preview-mode.sh on` — اولین انتشار بهصورت پیشنمایش خصوصی میرود؛
|
||||
محتوا را روی سایت آنلاین نهایی کنید و بعد `preview-mode.sh off` + deploy مجدد
|
||||
5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||
6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
|
||||
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
|
||||
7. یک تمرین بازگردانی: `bash scripts/restore.sh backups/db-<زمان>.sql.gz` روی محیط آزمایشی
|
||||
8. مانیتورینگ (بخش پایین) و سپس چتبات/آنالیتیکس را با متغیرهای LLM و Umami فعال کنید
|
||||
|
||||
## مانیتورینگ (Prometheus + Grafana + Loki)
|
||||
|
||||
```bash
|
||||
bash scripts/stack.sh monitoring
|
||||
# در تولید (سه فایل با هم):
|
||||
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
- داشبورد: `grafana.localhost` (توسعه) یا `grafana.example.com` (تولید — برچسبهای
|
||||
روتر آن را در `docker-compose.override.yml` سرور بگذارید؛ نمونه در انتهای
|
||||
`docker-compose.prod.yml`) — کاربر/رمز از `GRAFANA_ADMIN_*` در `.env`
|
||||
- دو داشبورد آمادهٔ «زیرساخت» و «اپلیکیشن» بهصورت code-provisioned سوار میشوند
|
||||
- **هشدارها به بله**: در `.env` مقدار `ALERT_BOT_TOKEN` (بات بله — همان شکل API تلگرام،
|
||||
از BotFather بله) و `ALERT_CHAT_ID` را بگذارید (شناسه چت: به بات یک پیام بدهید سپس
|
||||
`curl "https://tapi.bale.ai/bot<توکن>/getUpdates"`) — قواعد آماده در
|
||||
`monitoring/prometheus/alerts.yml`:
|
||||
پایینبودن هر سرویس، شکست بررسی عمق سلامت، پرشدن دیسک، نرخ خطای 5xx، انقضای گواهی TLS،
|
||||
**کهنهشدن پشتیبان** (سنجهٔ `backup_success_time` از `scripts/backup.sh`) و...
|
||||
- **لاگها در Grafana**: Promtail لاگ همهٔ کانتینرهای استک را به Loki میفرستد —
|
||||
برای خطاهای بکاند از داشبورد «اپلیکیشن» پنل لاگ را ببینید
|
||||
- **سلامت واقعی**: `/api/health` اتصال DB و MinIO را واقعاً میآزماید (503 در خرابی)؛
|
||||
پروب blackbox هم همین را از بیرون و نشانی `SITE_URL` را عمومی چک میکند
|
||||
- مصرف منابع مجموع لایه حدود ۱ تا ۱.۵ گیگابایت RAM است؛ حذف هر سرویس بقیه را نمیشکند
|
||||
|
||||
## آنالیتیکس محصول (Umami)
|
||||
|
||||
استک شامل سرویس Umami است؛ داشبورد: `analytics.localhost` (توسعه) یا
|
||||
`analytics.example.com` (تولید). بدون کوکی — بدون نیاز به بنر رضایت.
|
||||
|
||||
راهاندازی (یکبار):
|
||||
|
||||
1. استک را بالا بیاورید و به `analytics.localhost` وارد شوید (پیشفرض `admin`/`umami`
|
||||
— بلافاصله رمز را عوض کنید)
|
||||
2. یک وبسایت با دامنهٔ سایت بسازید؛ «شناسه وبسایت» را در `.env` بگذارید:
|
||||
`VITE_UMAMI_WEBSITE_ID=...` و آدرس اسکریپت: `VITE_UMAMI_SRC=https://analytics.example.com/script.js`
|
||||
3. `bash scripts/stack.sh deploy` — اسکریپت ردیاب در `index.html` تزریق میشود
|
||||
|
||||
رخدادهای سفارشی که خودکار ارسال میشوند: ارسال فرم تماس (`contact-submit`)، عضویت
|
||||
خبرنامه (`newsletter-signup`)، کلیک تلفن (`phone-click`)، باز کردن چتبات
|
||||
(`chat-open`)، پرسش از چتبات (`chat-question`)، تغییر زبان (`lang-switch`) —
|
||||
سرچشمه: `frontend/src/analytics.ts`.
|
||||
|
||||
## چتبات (دستیار هوشمند)
|
||||
|
||||
- RAG روی محتوای همان دیتابیس سایت: امبدینگ MiniLM چندزبانه + BM25 + رنکر — بردارها
|
||||
در دیتابیس جدا `aramesh_chatbot` (pgvector) روی همان نمونهٔ Postgres
|
||||
- مسیرها: `GET /chat/healthz`، `GET /chat/suggestions`، `GET /chat/stream` (SSE) —
|
||||
رابط کاربری: ویجت شناور پایین صفحه (`ChatWidget.tsx`)
|
||||
- راهاندازی: `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` در `.env` (هر LLM سازگار با
|
||||
OpenAI) — بدون آن سرویس بالا میآید اما پاسخ نمیدهد
|
||||
- اولین اجرا مدلها را دانلود میکند (~۵۰۰MB در حجم `chatbot_hf`) — کند است، طبیعی است
|
||||
- ایندکس هر ۱۰ دقیقه خودش با تغییرهای محتوا همگام میشود؛ همگامسازی فوری با
|
||||
`POST /chat/reindex` و هدر `x-reindex-token` (از `CHAT_REINDEX_TOKEN`)
|
||||
|
||||
## امکانات بکاند
|
||||
|
||||
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
|
||||
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
|
||||
و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` — هر دو با
|
||||
محدودسازی نرخ (تماس: ۵ در ۱۵ دقیقه، خبرنامه: ۳ در ساعت برای هر IP) + دامنهٔ پنهان
|
||||
(honeypot) ضدربات + میانافزار محدودسازی سیلاب روی Traefik
|
||||
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (مقادیر از `ADMIN_*` در `.env` —
|
||||
هیچ رمز پیشفرضی در کد وجود ندارد) و سپس CRUD کامل همه محتواها در `/api/admin/...`
|
||||
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
|
||||
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
|
||||
(IP واقعی کلاینت با `--proxy-headers` از پشت Traefik درست خوانده میشود)
|
||||
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد
|
||||
ذخیره و URL نسبی `/media/...` برمیگردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
|
||||
فشرده میشود (Pillow) تا سرعت صفحه حفظ شود
|
||||
- **خروجی CSV** از نوار مدیریت: پیامهای تماس و مشترکان خبرنامه (با BOM برای Excel)
|
||||
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
|
||||
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
|
||||
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
|
||||
- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
|
||||
- **مهاجرتهای اسکیما**: Alembic (`backend/migrations/`) — هر تغییر اسکیمای جدید باید
|
||||
revision شود: `cd backend && alembic revision --autogenerate -m "..."` و پس از بازبینی
|
||||
commit؛ در استارت کانتینر خودکار `upgrade head` اجرا میشود
|
||||
- **تستها**: `cd backend && .venv/Scripts/activate && pytest tests` (۲۴ تست — شامل
|
||||
honeypot، محدودسازی نرخ و پاکسازی)
|
||||
|
||||
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
|
||||
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
|
||||
> بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
|
||||
|
||||
## ویرایش زنده سایت (دوقلوی دیجیتال)
|
||||
|
||||
@@ -157,7 +268,9 @@ npm run dev
|
||||
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
|
||||
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
|
||||
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
|
||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند. در زمان ذخیره، آدرسهای
|
||||
`href/src` سند در سمت سرور پاکسازی میشوند (`app/sanitize.py`) تا اسکیمهای خطرناک
|
||||
مثل `javascript:` هرگز ذخیره نشوند.
|
||||
|
||||
## سئو
|
||||
|
||||
@@ -179,24 +292,39 @@ npm run dev
|
||||
دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
|
||||
همیشگی را میبینند
|
||||
|
||||
## پشتیبانگیری
|
||||
## پشتیبانگیری و بازگردانی
|
||||
|
||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
|
||||
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
|
||||
|
||||
```bash
|
||||
bash scripts/backup.sh # در backups/ — نگهداری پیشفرض ۱۴ روز
|
||||
bash scripts/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
|
||||
docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
|
||||
```
|
||||
|
||||
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
|
||||
- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
||||
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
|
||||
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده + فایل `.sha256`) و `backups/images-<زمان>/`
|
||||
- اسکریپت پشتیبان، خروجی خالی/شکسته را خطا میگیرد، سنجهٔ تازگی برای هشدار Grafana
|
||||
مینویسد و اگر `OFFSITE_RCLONE_REMOTE` در `.env` تنظیم باشد، پشتیبان را به حافظهٔ
|
||||
دیگری هم کپی میکند (rclone)
|
||||
- **بازگردانی**: `bash scripts/stack.sh restore backups/db-<زمان>.sql.gz`
|
||||
(احتیاط: محتوای فعلی جایگزین میشود — قبل از انتشار واقعی یکبار تمرین کنید!)
|
||||
- زمانبندی پشتیبان کامل با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
|
||||
|
||||
## تست و CI
|
||||
|
||||
- `bash scripts/ci.sh` — تستهای pytest + بیلد ایمیجها + ممیزی وابستگیها
|
||||
- `bash scripts/audit.sh` — pip-audit / npm audit / trivy (هر کدام نصب بود)
|
||||
- `.github/workflows/ci.yml` — همین مراحل؛ سینتکس GitHub Actions روی Gitea Actions/
|
||||
Forgejo هم اجرا میشود
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
|
||||
با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
|
||||
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
|
||||
`${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید؛ هیچ رمز پیشفرضی در کد نیست
|
||||
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
|
||||
- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
|
||||
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
|
||||
- توکنهای JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج میکند)
|
||||
- همهٔ کانتینرها **بدون root** اجرا میشوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
|
||||
باریک است و لاگها سقف دارند
|
||||
- فرمهای عمومی ضدرباتاند (honeypot + محدودسازی نرخ در بکاند + سقف سیلاب در Traefik)
|
||||
- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترساند (تونل SSH)
|
||||
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
|
||||
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# کانتکست بیلد باریک — .venv و کشهای تست محلی جایی در تصویر ندارند
|
||||
.venv/
|
||||
venv/
|
||||
tests/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -1,3 +1,4 @@
|
||||
# بکاند FastAPI — تصویر باریک و بدون root
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -8,9 +9,23 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# FFmpeg — نرمالسازی رسانه به WebP برای ورودیهایی که Pillow نمیخواند
|
||||
# (ویدیو → WebP متحرک، فرمتهای تصویری خاص). مسیر Pillow به آن وابسته نیست.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY app ./app
|
||||
COPY alembic.ini ./
|
||||
COPY migrations ./migrations
|
||||
|
||||
# کاربر بدون دسترسی root — برنامه چیزی روی دیسک محلی نمینویسد
|
||||
RUN useradd --system --create-home appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# ساخت جداول و seed (بدون حذف دادههای موجود) و سپس اجرای سرور
|
||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
# ساخت جداول پایه، مهاجرتهای Alembic و seed (بدون حذف دادههای موجود) و سپس
|
||||
# اجرای سرور با exec — تا SIGTERM (docker stop) مستقیم به uvicorn برسد و خاموشی
|
||||
# تمیز انجام شود؛ --proxy-headers برای دیدن IP واقعی کلاینت پشت Traefik
|
||||
CMD ["sh", "-c", "python -m app.db_init && alembic upgrade head && python -m app.seed && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
|
||||
# آدرس دیتابیس از DATABASE_URL (محیط) یا تنظیمات برنامه خوانده میشود — env.py
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,16 +1,31 @@
|
||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||
|
||||
import csv
|
||||
import ipaddress
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from PIL import Image, ImageOps
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from .. import models, schemas
|
||||
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
|
||||
from ..auth import create_access_token, get_current_admin, get_user_from_token, hash_password, oauth2_scheme, verify_password
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..rate_limit import clear as clear_rate_limit
|
||||
@@ -104,7 +119,46 @@ from ..schemas import (
|
||||
auth_router = APIRouter()
|
||||
|
||||
# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
|
||||
router = APIRouter(dependencies=[Depends(get_current_admin)])
|
||||
async def admin_access_guard(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> models.AdminUser:
|
||||
"""نقشگارد — 'admin' همهکاره؛ 'creator' فقط مقالهنویسی: ساخت پیشنویس،
|
||||
خواندن/ویرایش پیشنویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
|
||||
user = get_user_from_token(token, db)
|
||||
if user.role == "admin":
|
||||
return user
|
||||
if user.role != "creator":
|
||||
raise HTTPException(status_code=403, detail="نقش ناشناخته")
|
||||
|
||||
path = request.url.path.rstrip("/")
|
||||
method = request.method
|
||||
|
||||
# آپلود تصویر مقالهها — مجاز
|
||||
if path in ("/api/admin/upload", "/api/admin/import-image") and method == "POST":
|
||||
return user
|
||||
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیشنویس است و نویسندهاش ثبت میشود)
|
||||
if path == "/api/admin/articles" and method in ("GET", "POST"):
|
||||
return user
|
||||
# خواندن/ویرایش پیشنویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک میشود
|
||||
m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
|
||||
if m and method in ("GET", "PUT", "PATCH"):
|
||||
article = db.get(models.Article, int(m.group(1)))
|
||||
if article is not None and article.author_id == user.id:
|
||||
return user
|
||||
# ۴۰۴ نه ۴۰۳ — مقالههای بقیه برای نویسنده اصلاً وجود ندارند
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[
|
||||
Depends(get_current_admin),
|
||||
Depends(admin_access_guard),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@auth_router.post("/auth/login", response_model=TokenOut)
|
||||
@@ -118,7 +172,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
||||
detail="نام کاربری یا رمز عبور اشتباه است",
|
||||
)
|
||||
clear_rate_limit(request)
|
||||
return TokenOut(access_token=create_access_token(user.username))
|
||||
return TokenOut(access_token=create_access_token(user.username, user.role), role=user.role)
|
||||
|
||||
|
||||
@auth_router.post("/auth/change-password")
|
||||
@@ -135,13 +189,21 @@ def change_password(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
|
||||
def register_crud(
|
||||
path: str, model, out_schema, create_schema, patch_schema=None,
|
||||
creator_scoped: bool = False,
|
||||
) -> None:
|
||||
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
||||
حذف و ترتیببندی) روی روتر مدیریت."""
|
||||
حذف و ترتیببندی) روی روتر مدیریت.
|
||||
|
||||
creator_scoped=True (فقط مقالهها): نقش creator فقط رکوردهای خودش را میبیند،
|
||||
ساختش همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود."""
|
||||
|
||||
@router.get(f"/{path}", response_model=list[out_schema])
|
||||
def list_items(db: Session = Depends(get_db)):
|
||||
def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
query = db.query(model)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
query = query.filter(model.author_id == admin.id)
|
||||
if hasattr(model, "position"):
|
||||
query = query.order_by(model.position, model.id)
|
||||
else:
|
||||
@@ -149,8 +211,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return query.all()
|
||||
|
||||
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
||||
def create_item(payload: create_schema, db: Session = Depends(get_db)):
|
||||
def create_item(payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
item = model(**payload.model_dump())
|
||||
if creator_scoped:
|
||||
# نویسنده ثبت میشود؛ ساخت توسط creator همیشه پیشنویس است
|
||||
item.author_id = admin.id
|
||||
if admin.role == "creator":
|
||||
item.status = "draft"
|
||||
if hasattr(model, "position"):
|
||||
# آیتم جدید در انتهای لیست قرار میگیرد
|
||||
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
||||
@@ -164,19 +231,26 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return item
|
||||
|
||||
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def get_item(item_id: int, db: Session = Depends(get_db)):
|
||||
def get_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
||||
# مقالهٔ بقیه برای creator وجود ندارد
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
return item
|
||||
|
||||
@router.put(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db)):
|
||||
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(item, key, value)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
@@ -188,13 +262,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
if patch_schema is not None:
|
||||
|
||||
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
|
||||
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
||||
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(item, key, value)
|
||||
if creator_scoped and admin.role == "creator":
|
||||
item.status = "draft" # نویسنده حق انتشار ندارد
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
@@ -204,7 +282,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return item
|
||||
|
||||
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
|
||||
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
if creator_scoped and admin.role == "creator":
|
||||
raise HTTPException(status_code=403, detail="ترتیب مقالهها فقط توسط مدیر تغییر میکند")
|
||||
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
||||
if not hasattr(model, "position"):
|
||||
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
||||
@@ -218,7 +298,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
|
||||
return db.query(model).order_by(model.position, model.id).all()
|
||||
|
||||
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db)):
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
||||
if creator_scoped and admin.role == "creator":
|
||||
raise HTTPException(status_code=403, detail="حذف مقاله فقط توسط مدیر انجام میشود")
|
||||
item = db.get(model, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
||||
@@ -235,7 +317,7 @@ register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOu
|
||||
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
||||
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
||||
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
|
||||
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch, creator_scoped=True)
|
||||
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
||||
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
||||
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
||||
@@ -254,29 +336,144 @@ register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
||||
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
||||
|
||||
|
||||
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
||||
ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/avif": "avif",
|
||||
}
|
||||
# رسانه آپلودی به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP «تکفریمی» نرمال میشود:
|
||||
# هیچ آپلودی هرگز متحرک ذخیره نمیشود — منبع متحرک (gif/apng/webp متحرک/ویدیو) از فریم اول
|
||||
# پوستر میگیرد؛ انیمیشن در کارتها و اسلایدرها سوسو میزند و مطلوب نیست.
|
||||
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
||||
MAX_DIMENSION = 2000 # سقف ضلع تصویر — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
||||
WEBP_QUALITY = 82
|
||||
WEBP_CONTENT_TYPE = "image/webp"
|
||||
|
||||
SUPPORTED_MEDIA_DETAIL = "فرمت پشتیبانی نمیشود — فقط تصویر یا ویدیویی که بتوان به WebP تبدیل کرد مجاز است"
|
||||
|
||||
|
||||
def _to_webp_mode(img: Image.Image) -> Image.Image:
|
||||
"""ذخیرهٔ WebP در Pillow فقط RGB/RGBA/L را میپذیرد — مدهای دیگر با حفظ شفافیت تبدیل میشوند."""
|
||||
if img.mode in ("RGB", "RGBA", "L"):
|
||||
return img
|
||||
has_alpha = img.mode in ("LA", "PA") or (img.mode == "P" and "transparency" in img.info)
|
||||
return img.convert("RGBA" if has_alpha else "RGB")
|
||||
|
||||
|
||||
def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
|
||||
"""فریم اول → WebP تکفریمی؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت میشود."""
|
||||
if getattr(img, "n_frames", 1) > 1:
|
||||
img.seek(0)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
if max(img.size) > MAX_DIMENSION:
|
||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
||||
img = _to_webp_mode(img)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "WEBP", quality=WEBP_QUALITY, method=4)
|
||||
return buf.getvalue(), *img.size
|
||||
|
||||
|
||||
def _ffprobe_video(path: str) -> dict | None:
|
||||
"""استریم ویدیوی اول با ffprobe — None یعنی ویدیویی در فایل نیست (رسانهٔ تصویری نیست)."""
|
||||
ffprobe = shutil.which("ffprobe")
|
||||
if ffprobe is None:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffprobe, "-v", "error", "-print_format", "json", "-show_streams", path],
|
||||
capture_output=True, timeout=20,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
streams = json.loads(proc.stdout or "{}").get("streams") or []
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return next((s for s in streams if s.get("codec_type") == "video"), None)
|
||||
|
||||
|
||||
def _run_ffmpeg(args: list[str]) -> None:
|
||||
try:
|
||||
proc = subprocess.run(args, capture_output=True, timeout=60)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HTTPException(status_code=415, detail="تبدیل رسانه بیش از حد طول کشید") from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL) from exc
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||
|
||||
|
||||
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||
"""مسیر FFmpeg — ورودیهایی که Pillow نمیخواند: ویدیو و فرمتهای تصویری خاص.
|
||||
خروجی همیشه تکفریم (پوستر) است — ویدیوی متحرک در صفحه لوپ میشود و سوسو میزند."""
|
||||
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = os.path.join(tmp, "src")
|
||||
dst = os.path.join(tmp, "out.webp")
|
||||
with open(src, "wb") as fh:
|
||||
fh.write(data)
|
||||
if _ffprobe_video(src) is None:
|
||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||
ff = shutil.which("ffmpeg") or "ffmpeg"
|
||||
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, "-frames:v", "1",
|
||||
"-vf", f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos",
|
||||
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
|
||||
with open(dst, "rb") as fh:
|
||||
out = fh.read()
|
||||
# ffprobe روی WebP عرض/ارتفاع صفر برمیگرداند — ابعاد از خود خروجی با Pillow
|
||||
try:
|
||||
width, height = Image.open(io.BytesIO(out)).size
|
||||
except Exception:
|
||||
width = height = None
|
||||
return out, width, height
|
||||
|
||||
|
||||
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||
"""هر ورودی قابلرمزگشایی → WebP تکفریمی. اول Pillow (دقیقترین برای EXIF/شفافیت)؛
|
||||
اگر رمزگشایی نشد، FFmpeg (پوستر ویدیو و فرمتهای خاص). هیچکدام نشد → ۴۱۵."""
|
||||
try:
|
||||
return _webp_still(Image.open(io.BytesIO(data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
return _ffmpeg_webp(data)
|
||||
|
||||
|
||||
def _readable_key(name: str, ext: str) -> str:
|
||||
"""کلید ذخیرهسازی خوانا از نام دلخواه/نام فایل — «۱۴۰۳/elahieh-tower-a1b2c3.jpg».
|
||||
|
||||
نامگذاریها ایمن میشوند: نویسههای ناامن و جداکننده مسیر حذف، فاصلهها خط تیره،
|
||||
طول محدود؛ خالی بودن نام → شناسه تصادفی مثل قبل. پسوند ۶ نویسهای تضمین یکتایی است.
|
||||
"""
|
||||
slug = re.sub(r"[^\w-ۿ\- ]+", "", name, flags=re.UNICODE).strip()
|
||||
slug = re.sub(r"[\s_]+", "-", slug).strip("-")[:80]
|
||||
unique = uuid.uuid4().hex[:6]
|
||||
base = f"{slug}-{unique}" if slug else uuid.uuid4().hex
|
||||
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
||||
|
||||
|
||||
def _store_image(data: bytes, name: str) -> str:
|
||||
"""ذخیرهٔ WebP در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||
key = _readable_key(name, "webp")
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
)
|
||||
try:
|
||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=WEBP_CONTENT_TYPE)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||
return f"{settings.media_public_base}/{key}"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(""),
|
||||
_admin: models.AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
content_type = (file.content_type or "").lower()
|
||||
ext = ALLOWED_IMAGE_TYPES.get(content_type)
|
||||
if ext is None:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="فرمت فایل پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
||||
)
|
||||
|
||||
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||
data = await file.read()
|
||||
if len(data) == 0:
|
||||
@@ -287,29 +484,127 @@ async def upload_media(
|
||||
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
||||
# هر ورودی قابلرمزگشایی (تصویر رایج/خاص، متحرک، ویدیو) → WebP نرمال — روی ترد،
|
||||
# تا رمزگذاری فایل حجیم رویدادلوپ و بقیهٔ درخواستها را قفل نکند
|
||||
data, width, height = await run_in_threadpool(_normalize_media, data)
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
)
|
||||
try:
|
||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
||||
url = _store_image(data, display_name)
|
||||
|
||||
return {
|
||||
"url": f"{settings.media_public_base}/{key}",
|
||||
"key": key,
|
||||
"url": url,
|
||||
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"size": len(data),
|
||||
"content_type": content_type,
|
||||
"content_type": WEBP_CONTENT_TYPE,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
# ---------- درونریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
|
||||
|
||||
|
||||
class ImportImageIn(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
def _assert_public_http(url: str) -> None:
|
||||
"""فقط http/https به مقاصد عمومی — جلوگیری از SSRF به شبکه داخلی."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="نشانی تصویر معتبر نیست")
|
||||
try:
|
||||
infos = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror:
|
||||
raise HTTPException(status_code=400, detail="میزبان تصویر پیدا نشد")
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if not ip.is_global:
|
||||
raise HTTPException(status_code=400, detail="نشانی تصویر به میزبان داخلی اشاره میکند")
|
||||
|
||||
|
||||
@router.post("/import-image")
|
||||
async def import_image(
|
||||
payload: ImportImageIn,
|
||||
_admin: models.AdminUser = Depends(get_current_admin),
|
||||
):
|
||||
"""دانلود تصویر از نشانی خارجی، بهینهسازی و ذخیره در Object Storage خودِ سایت —
|
||||
تصاویر چسباندهشده از Outline/مستندات خارجی به میزبانی سایت منتقل میشوند."""
|
||||
_assert_public_http(payload.url)
|
||||
|
||||
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||
request = urllib.request.Request(
|
||||
payload.url, headers={"User-Agent": "ArameshSite/1.0 (+image import)"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
data = response.read(max_bytes + 1)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
||||
if len(data) == 0:
|
||||
raise HTTPException(status_code=400, detail="تصویر خالی است")
|
||||
if len(data) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||
)
|
||||
|
||||
# هدر Content-Type دروغ میگوید — خود بدنه رمزگشایی و به WebP نرمال میشود (روی ترد)
|
||||
data, width, height = await run_in_threadpool(_normalize_media, data)
|
||||
|
||||
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
||||
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
media_url = _store_image(data, url_name)
|
||||
|
||||
return {
|
||||
"url": media_url,
|
||||
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
||||
"size": len(data),
|
||||
"content_type": WEBP_CONTENT_TYPE,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
# ---------- خروجی CSV (برای تیم فروش) ----------
|
||||
|
||||
def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return Response(
|
||||
# BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
|
||||
content="\ufeff" + buf.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/messages/export.csv")
|
||||
def export_messages(db: Session = Depends(get_db)):
|
||||
rows = [
|
||||
[m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
|
||||
for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
|
||||
]
|
||||
return _csv_response(
|
||||
"contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscribers/export.csv")
|
||||
def export_subscribers(db: Session = Depends(get_db)):
|
||||
rows = [
|
||||
[s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
|
||||
for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
|
||||
]
|
||||
return _csv_response(
|
||||
"newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
|
||||
)
|
||||
|
||||
|
||||
# پیامهای تماس
|
||||
@router.get("/messages", response_model=list[ContactMessageOut])
|
||||
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه."""
|
||||
"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه.
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
هر فرم یک دامنهٔ پنهان (honeypot) دارد: رباتها همهٔ فیلدها را پُر میکنند؛
|
||||
در آن حالت پاسخ موفقِ قلابی برمیگردد و چیزی ذخیره نمیشود.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..database import get_db
|
||||
from ..rate_limit import contact_limiter, newsletter_limiter
|
||||
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _fake_contact(payload: ContactIn) -> ContactMessageOut:
|
||||
"""پاسخ موفق بدون ذخیره — تا ربات متوجه تله نشود."""
|
||||
return ContactMessageOut(
|
||||
id=0,
|
||||
name=payload.name.strip(),
|
||||
phone=payload.phone.strip(),
|
||||
note="",
|
||||
is_read=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _fake_subscriber(payload: NewsletterIn) -> SubscriberOut:
|
||||
return SubscriberOut(
|
||||
id=0,
|
||||
email=str(payload.email).lower(),
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/contact", response_model=ContactMessageOut, status_code=201)
|
||||
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
|
||||
def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(get_db)):
|
||||
contact_limiter.hit(request)
|
||||
if payload.website:
|
||||
return _fake_contact(payload)
|
||||
message = models.ContactMessage(
|
||||
name=payload.name.strip(),
|
||||
phone=payload.phone.strip(),
|
||||
@@ -24,7 +55,11 @@ def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
|
||||
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)):
|
||||
def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
|
||||
newsletter_limiter.hit(request)
|
||||
if payload.website:
|
||||
return _fake_subscriber(payload)
|
||||
|
||||
email = str(payload.email).lower()
|
||||
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..schemas import (
|
||||
@@ -22,9 +27,44 @@ from ..schemas import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
def live():
|
||||
"""زندهبودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمیشود."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "app": "Aramesh Building Group API"}
|
||||
def health(db: Session = Depends(get_db)):
|
||||
"""بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده میشود؛
|
||||
در صورت خرابی هر وابستگی، 503 با جزئیات برمیگردد."""
|
||||
checks: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
except Exception:
|
||||
checks["database"] = "error"
|
||||
|
||||
try:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
region_name="us-east-1",
|
||||
config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
|
||||
)
|
||||
s3.head_bucket(Bucket=settings.s3_bucket)
|
||||
checks["storage"] = "ok"
|
||||
except Exception:
|
||||
checks["storage"] = "error"
|
||||
|
||||
if any(state != "ok" for state in checks.values()):
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "degraded", "checks": checks},
|
||||
)
|
||||
return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
|
||||
|
||||
|
||||
@router.get("/brand", response_model=BrandOut)
|
||||
|
||||
@@ -21,17 +21,14 @@ def verify_password(password: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(username: str) -> str:
|
||||
def create_access_token(username: str, role: str = "admin") -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": username, "exp": expire}
|
||||
payload = {"sub": username, "role": role, "exp": expire}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
||||
def get_user_from_token(token: str, db: Session) -> AdminUser:
|
||||
"""decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقشگارد"""
|
||||
credentials_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="احراز هویت نامعتبر است",
|
||||
@@ -50,3 +47,11 @@ def get_current_admin(
|
||||
if user is None:
|
||||
raise credentials_error
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""وابستگی محافظتشده — فقط با توکن JWT معتبر دسترسی میدهد."""
|
||||
return get_user_from_token(token, db)
|
||||
|
||||
@@ -13,7 +13,8 @@ class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
|
||||
secret_key: str = "dev-secret-change-me-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 1440
|
||||
# ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاهتر ریسک نشت را کم میکند
|
||||
access_token_expire_minutes: int = 480
|
||||
cors_origins: list[str] = ["*"]
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "aramesh123"
|
||||
@@ -25,12 +26,17 @@ class Settings(BaseSettings):
|
||||
s3_bucket: str = "aramesh-media"
|
||||
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
|
||||
media_public_base: str = "/media"
|
||||
media_max_upload_mb: int = 10
|
||||
# رندرهای معماری تا ~۳۰ مگابایت — لبه (Traefik api-bodylimit = ۴۰MB) باید همیشه بزرگتر باشد
|
||||
media_max_upload_mb: int = 35
|
||||
|
||||
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
|
||||
site_url: str = ""
|
||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||
frontend_dist: str = "../frontend/dist"
|
||||
# پیشنمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکسشدنی.
|
||||
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
|
||||
# هدر noindex روی همه پاسخها + robots.txt مسدودکننده.
|
||||
preview_mode: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""ساخت جداول پایه (baseline) — پیش از مهاجرتهای Alembic.
|
||||
|
||||
روی دیتابیس تازه، همهٔ جدولها از مدلهای فعلی ساخته میشوند؛ روی دیتابیس موجود
|
||||
بیاثر است (create_all جدولهای موجود را دست نمیزند). از اینجا به بعد تغییرات
|
||||
اسکیما فقط با revisionهای Alembic اعمال میشوند.
|
||||
"""
|
||||
|
||||
from . import models # noqa: F401 — ثبت مدلها
|
||||
from .database import Base, engine
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -1,15 +1,17 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
|
||||
from .api import admin, forms, public
|
||||
from .config import settings
|
||||
from .database import Base, engine
|
||||
from .migrations import run_all as run_migrations
|
||||
from .seo_pages import router as seo_router
|
||||
|
||||
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال میشوند
|
||||
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
|
||||
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام میدهد.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
app = FastAPI(
|
||||
title="Aramesh Building Group API",
|
||||
@@ -29,6 +31,27 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
|
||||
|
||||
# پیشنمایش خصوصی — لایه دوم جلوگیری از ایندکس (لایه اول، گیت ورود Traefik است).
|
||||
# روی همه پاسخها هدر noindex مینشیند تا حتی در صورت حذف گیت، گوگل چیزی ذخیره نکند.
|
||||
# همیشه ثبت میشود و پرچم در زمان هر درخواست خوانده میشود (خاموشکردن انتشار = فقط تغییر .env).
|
||||
@app.middleware("http")
|
||||
async def _preview_noindex(request, call_next):
|
||||
response = await call_next(request)
|
||||
if settings.preview_mode:
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||
return response
|
||||
|
||||
# پاسخهای API هرگز کش مرورگر/CDN نشوند — دادهٔ پنل بعد از هر ذخیره فوراً fresh بماند
|
||||
@app.middleware("http")
|
||||
async def _api_no_store(request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/api"):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
||||
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
||||
@@ -37,6 +60,12 @@ app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت
|
||||
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
|
||||
app.include_router(seo_router, tags=["سئو"])
|
||||
|
||||
# متریکهای Prometheus — فقط Prometheus در شبکه داخلی اسکرپ میکند
|
||||
# (Traefik مسیر /metrics را نمیشناسد، پس از بیرون در دسترس نیست)
|
||||
from prometheus_fastapi_instrumentator import Instrumentator # noqa: E402
|
||||
|
||||
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
|
||||
@@ -27,8 +27,16 @@ EN_COLUMNS: list[tuple[str, str, str]] = [
|
||||
("projects", "year_en", "VARCHAR(64)"),
|
||||
("projects", "area_en", "VARCHAR(64)"),
|
||||
("projects", "floors_en", "VARCHAR(64)"),
|
||||
("projects", "units_en", "VARCHAR(64)"),
|
||||
("projects", "land_area", "VARCHAR(64)"),
|
||||
("projects", "pilot_floors", "VARCHAR(32)"),
|
||||
("projects", "units_residential", "VARCHAR(32)"),
|
||||
("projects", "units_commercial", "VARCHAR(32)"),
|
||||
("projects", "units_office", "VARCHAR(32)"),
|
||||
("projects", "client_en", "VARCHAR(128)"),
|
||||
("projects", "project_type", "VARCHAR(128)"),
|
||||
("projects", "project_type_en", "VARCHAR(128)"),
|
||||
("projects", "usage", "VARCHAR(128)"),
|
||||
("projects", "usage_en", "VARCHAR(128)"),
|
||||
("projects", "summary_en", "TEXT"),
|
||||
("projects", "body_en", "JSON"),
|
||||
("articles", "title_en", "VARCHAR(256)"),
|
||||
@@ -145,6 +153,35 @@ def run_ddl_migrations() -> None:
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||
)
|
||||
# نام نویسنده و توضیحات سئوی اختصاصی مقاله — فیلدهایی که پنل قبلاً نمایش میداد ولی در دیتابیس نبودند
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author VARCHAR(128) NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description TEXT NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description_en TEXT NOT NULL DEFAULT ''")
|
||||
)
|
||||
# نقشها — 'admin' همهکاره، 'creator' فقط نویسندهٔ محتوا
|
||||
conn.execute(
|
||||
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
|
||||
)
|
||||
# نویسندهٔ مقاله — نقش creator فقط مقالههای خودش را میبیند/ویرایش میکند
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author_id INTEGER")
|
||||
)
|
||||
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(
|
||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''")
|
||||
)
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON"))
|
||||
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON"))
|
||||
# سطرهای موجود ستون تازه را NULL میگیرند — آرایه خالی یعنی «بدون پرسشوپاسخ»
|
||||
conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL"))
|
||||
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
||||
for table, column, col_type in EN_COLUMNS:
|
||||
if col_type == "JSON":
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -20,6 +21,8 @@ class AdminUser(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
# 'admin' همهکاره — 'creator' فقط نویسندهٔ محتوا (پیشنویس مقالههای خودش)
|
||||
role: Mapped[str] = mapped_column(String(16), default='admin')
|
||||
|
||||
|
||||
class Brand(Base):
|
||||
@@ -29,6 +32,7 @@ class Brand(Base):
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
logo: Mapped[str] = mapped_column(Text, default="")
|
||||
logo_en: Mapped[str] = mapped_column(Text, default="")
|
||||
hero: Mapped[str] = mapped_column(Text, default="")
|
||||
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
||||
phone: Mapped[str] = mapped_column(String(64), default="")
|
||||
@@ -71,10 +75,18 @@ class Project(Base):
|
||||
area_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors: Mapped[str] = mapped_column(String(64), default="")
|
||||
floors_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
units: Mapped[str] = mapped_column(String(64), default="")
|
||||
units_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
land_area: Mapped[str] = mapped_column(String(64), default="")
|
||||
pilot_floors: Mapped[str] = mapped_column(String(32), default="")
|
||||
units_residential: Mapped[str] = mapped_column(String(32), default="")
|
||||
units_commercial: Mapped[str] = mapped_column(String(32), default="")
|
||||
units_office: Mapped[str] = mapped_column(String(32), default="")
|
||||
client: Mapped[str] = mapped_column(String(128), default="")
|
||||
client_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
# مشخصات تکمیلی پروژه — به درخواست تیم: نوع پروژه و کاربری
|
||||
project_type: Mapped[str] = mapped_column(String(128), default="")
|
||||
project_type_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
usage: Mapped[str] = mapped_column(String(128), default="")
|
||||
usage_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
summary_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
@@ -95,16 +107,37 @@ class Article(Base):
|
||||
category: Mapped[str] = mapped_column(String(64), default="")
|
||||
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
# متن جایگزین و عنوان تصویر شاخص — سئو و دسترسپذیری
|
||||
image_alt: Mapped[str] = mapped_column(String(256), default="")
|
||||
image_title: Mapped[str] = mapped_column(String(256), default="")
|
||||
read: Mapped[str] = mapped_column(String(32), default="")
|
||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقالههای ادمین NULL
|
||||
# نام نویسنده (رشتهٔ آزاد) — سربرگ مقاله و اسکیمای Article (نوع Person برای سیگنال E-E-A-T)
|
||||
author: Mapped[str] = mapped_column(String(128), default="")
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
|
||||
# توضیحات سئوی اختصاصی مقاله — در تگ description و اسکیما، در اولویت بر چکیده
|
||||
meta_description: Mapped[str] = mapped_column(Text, default="")
|
||||
meta_description_en: Mapped[str] = mapped_column(Text, default="")
|
||||
# نشانی انگلیسی مقاله — مسیر /en/blog/...؛ خالی یعنی همان اسلاگ فارسی
|
||||
slug_en: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
# کلمه کلیدی هدف نسخه انگلیسی — چکلیست سئو
|
||||
seo_keyphrase_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
# نام نویسنده نسخه انگلیسی
|
||||
author_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
# هشتگها/برچسبها — هر زبان جدا
|
||||
tags: Mapped[list] = mapped_column(JSON, default=list)
|
||||
tags_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class Testimonial(Base):
|
||||
|
||||
@@ -1,38 +1,74 @@
|
||||
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
|
||||
"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درونحافظهای.
|
||||
|
||||
پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
|
||||
شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
|
||||
برای ورود ادمین (بر پایه تلاش ناموفق) و فرمهای عمومی (بر پایه هر ارسال)
|
||||
استفاده میشود. شمارندهها در حافظهٔ همین پروسهاند — با ریاستارت پاک میشوند؛
|
||||
به همین دلیل uvicorn تککارگر نگه داشته شده است.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_failed: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده میشود
|
||||
(uvicorn با --proxy-headers هم client.host را درست میکند)."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _prune(key: str, now: float) -> list[float]:
|
||||
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
return recent
|
||||
class RateLimiter:
|
||||
"""پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی میشوند."""
|
||||
|
||||
def __init__(self, max_events: int, window_seconds: int, message: str):
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self.message = message
|
||||
self._events: dict[str, list[float]] = {}
|
||||
|
||||
def _recent(self, key: str, now: float) -> list[float]:
|
||||
recent = [t for t in self._events.get(key, []) if now - t < self.window_seconds]
|
||||
self._events[key] = recent
|
||||
return recent
|
||||
|
||||
def _reject(self, recent: list[float]) -> None:
|
||||
now = time.monotonic()
|
||||
retry_after = max(1, int(self.window_seconds - (now - recent[0])))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=self.message,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
def hit(self, request: Request) -> None:
|
||||
"""یک رویداد ثبت میکند؛ اگر سهمیه کلید پر باشد 429 برمیگرداند."""
|
||||
key = client_ip(request)
|
||||
now = time.monotonic()
|
||||
recent = self._recent(key, now)
|
||||
if len(recent) >= self.max_events:
|
||||
self._reject(recent)
|
||||
self._events.setdefault(key, []).append(now)
|
||||
|
||||
def clear(self, request: Request) -> None:
|
||||
self._events.pop(client_ip(request), None)
|
||||
|
||||
|
||||
# ورود ادمین: ۵ تلاش ناموفق در ۱۵ دقیقه — فقط شکستها شمرده میشوند
|
||||
_failed: dict[str, list[float]] = {}
|
||||
LOGIN_MAX_FAILED_ATTEMPTS = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
|
||||
|
||||
def ensure_allowed(request: Request) -> None:
|
||||
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
|
||||
key = client_ip(request)
|
||||
recent = _prune(key, time.monotonic())
|
||||
if len(recent) >= MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
|
||||
now = time.monotonic()
|
||||
recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
|
||||
_failed[key] = recent
|
||||
if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
|
||||
retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - recent[0])))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
|
||||
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
|
||||
def clear(request: Request) -> None:
|
||||
"""با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
|
||||
_failed.pop(client_ip(request), None)
|
||||
|
||||
|
||||
# فرم تماس: ۵ ارسال در ۱۵ دقیقه برای هر IP
|
||||
contact_limiter = RateLimiter(
|
||||
max_events=5,
|
||||
window_seconds=15 * 60,
|
||||
message="تعداد پیامهای ارسالی زیاد است — کمی بعد دوباره امتحان کنید",
|
||||
)
|
||||
|
||||
# خبرنامه: ۳ عضویت در ساعت برای هر IP
|
||||
newsletter_limiter = RateLimiter(
|
||||
max_events=3,
|
||||
window_seconds=60 * 60,
|
||||
message="تعداد درخواستها زیاد است — کمی بعد دوباره امتحان کنید",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""پاکسازی ورودیهای غنی در زمان ذخیره.
|
||||
|
||||
بدنه مقالهها سند TipTap (ProseMirror) است و فرانتاند همان را با
|
||||
renderToHTMLString به HTML تبدیل و مستقیم تزریق میکند؛ متن گرهها در آن
|
||||
مسیر escape میشود، اما آدرسهای href/src عیناً وارد HTML میشوند — پس
|
||||
اسکیمهای خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# اسکیمهای مجاز برای href/src — مسیرهای نسبی و لنگر هم بیاسکیماند و میگذرند
|
||||
SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
|
||||
URL_ATTRS = ("href", "src")
|
||||
|
||||
|
||||
def _clean_url(value: Any) -> str | None:
|
||||
"""آدرس امن را برمیگرداند؛ در غیر این صورت None (مشخصه حذف میشود)."""
|
||||
if not isinstance(value, str):
|
||||
return value if value is not None else None
|
||||
stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
if not stripped:
|
||||
return None
|
||||
parsed = urlparse(stripped)
|
||||
if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
|
||||
return None
|
||||
return stripped
|
||||
|
||||
|
||||
def _is_junk_paragraph(node: Any) -> bool:
|
||||
"""پاراگراف خالی یا فقط نویسهٔ خنثی (nbsp/فاصله) — داخل نقلقول حذف میشود."""
|
||||
if not isinstance(node, dict) or node.get("type") != "paragraph":
|
||||
return False
|
||||
content = node.get("content") or []
|
||||
if not content:
|
||||
return True
|
||||
return all(
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "text"
|
||||
and not str(item.get("text", "")).replace("\xa0", " ").strip()
|
||||
for item in content
|
||||
)
|
||||
|
||||
|
||||
def _lift_lists_flat(node: Any) -> list[Any]:
|
||||
"""گره را به دنبالهای از گرههای نرمال تبدیل میکند — نقلقولهای داخل
|
||||
آیتم فهرست به سطح خود فهرست میآیند تا خط مسی همهٔ نقلقولها یکخط شود."""
|
||||
if not isinstance(node, dict):
|
||||
return [node]
|
||||
flat_content: list[Any] = []
|
||||
for child in node.get("content") or []:
|
||||
flat_content.extend(_lift_lists_flat(child))
|
||||
node["content"] = flat_content
|
||||
|
||||
if node.get("type") not in ("bulletList", "orderedList"):
|
||||
return [node]
|
||||
|
||||
rebuilt: list[Any] = []
|
||||
pending_items: list[Any] = []
|
||||
for li in node.get("content") or []:
|
||||
if not isinstance(li, dict) or li.get("type") != "listItem":
|
||||
pending_items.append(li)
|
||||
continue
|
||||
parts: list[list[Any]] = [[]]
|
||||
for child in li.get("content") or []:
|
||||
if isinstance(child, dict) and child.get("type") == "blockquote":
|
||||
parts.append(child)
|
||||
parts.append([])
|
||||
else:
|
||||
parts[-1].append(child)
|
||||
if len(parts) == 1:
|
||||
pending_items.append(li)
|
||||
continue
|
||||
if parts[0]:
|
||||
li["content"] = parts[0]
|
||||
pending_items.append(li)
|
||||
for part in parts[1:]:
|
||||
if isinstance(part, dict) and part.get("type") == "blockquote":
|
||||
if pending_items:
|
||||
rebuilt.append({**node, "content": pending_items})
|
||||
pending_items = []
|
||||
rebuilt.append(part)
|
||||
else:
|
||||
pending_items.extend(part)
|
||||
if pending_items:
|
||||
rebuilt.append({**node, "content": pending_items})
|
||||
return rebuilt
|
||||
|
||||
|
||||
def _lift_doc(doc: Any) -> Any:
|
||||
lifted = _lift_lists_flat(doc)
|
||||
if len(lifted) == 1 and isinstance(lifted[0], dict) and lifted[0].get("type") == "doc":
|
||||
return lifted[0]
|
||||
content: list[Any] = []
|
||||
for part in lifted:
|
||||
if isinstance(part, dict) and part.get("type") == "doc":
|
||||
content.extend(part.get("content") or [])
|
||||
else:
|
||||
content.append(part)
|
||||
return {"type": "doc", "content": content}
|
||||
|
||||
|
||||
def _walk(node: Any) -> Any:
|
||||
if isinstance(node, list):
|
||||
return [_walk(item) for item in node]
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in node.items():
|
||||
if key == "attrs" and isinstance(value, dict):
|
||||
attrs = dict(value)
|
||||
for attr in URL_ATTRS:
|
||||
if attr in attrs:
|
||||
cleaned_url = _clean_url(attrs[attr])
|
||||
if cleaned_url is None:
|
||||
attrs.pop(attr, None)
|
||||
else:
|
||||
attrs[attr] = cleaned_url
|
||||
cleaned[key] = attrs
|
||||
else:
|
||||
cleaned[key] = _walk(value)
|
||||
# داخل نقلقول: پاراگرافهای خالی/نویسهٔ خنثی حذف — وگرنه خط خالی اضافه رندر میشود
|
||||
if cleaned.get("type") == "blockquote" and isinstance(cleaned.get("content"), list):
|
||||
cleaned["content"] = [c for c in cleaned["content"] if not _is_junk_paragraph(c)]
|
||||
return cleaned
|
||||
|
||||
|
||||
def sanitize_doc(doc: Any) -> Any:
|
||||
"""سند TipTap را درجا پاک میکند (سایر ساختارها دستنخورده برمیگردند)."""
|
||||
if not isinstance(doc, dict):
|
||||
return doc
|
||||
walked = _walk(doc)
|
||||
return _lift_doc(walked)
|
||||
@@ -3,6 +3,8 @@ from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
|
||||
|
||||
from .sanitize import sanitize_doc
|
||||
|
||||
# ---------- پایه ----------
|
||||
|
||||
|
||||
@@ -21,6 +23,7 @@ class BrandOut(ORMModel):
|
||||
name: str
|
||||
name_en: str = ""
|
||||
logo: str
|
||||
logo_en: str = ""
|
||||
hero: str
|
||||
contact_bg: str
|
||||
phone: str
|
||||
@@ -55,10 +58,17 @@ class ProjectOut(ORMModel):
|
||||
area_en: str = ""
|
||||
floors: str
|
||||
floors_en: str = ""
|
||||
units: str
|
||||
units_en: str = ""
|
||||
land_area: str = ""
|
||||
pilot_floors: str = ""
|
||||
units_residential: str = ""
|
||||
units_commercial: str = ""
|
||||
units_office: str = ""
|
||||
client: str
|
||||
client_en: str = ""
|
||||
project_type: str = ""
|
||||
project_type_en: str = ""
|
||||
usage: str = ""
|
||||
usage_en: str = ""
|
||||
summary: str
|
||||
summary_en: str = ""
|
||||
body: list[str]
|
||||
@@ -68,6 +78,7 @@ class ProjectOut(ORMModel):
|
||||
class ArticleOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
author_id: int | None = None
|
||||
title: str
|
||||
title_en: str = ""
|
||||
date: str
|
||||
@@ -75,14 +86,28 @@ class ArticleOut(ORMModel):
|
||||
category: str
|
||||
category_en: str = ""
|
||||
image: str
|
||||
image_alt: str = ""
|
||||
image_title: str = ""
|
||||
read: str
|
||||
read_en: str = ""
|
||||
excerpt: str
|
||||
excerpt_en: str = ""
|
||||
body: dict
|
||||
body_en: Optional[dict] = None
|
||||
# پرسشوپاسخهای پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند
|
||||
faqs: Optional[list[dict]] = None
|
||||
faqs_en: Optional[list[dict]] = None
|
||||
status: str
|
||||
seo_keyphrase: str
|
||||
# نام نویسنده و توضیحات سئوی اختصاصی — سربرگ مقاله، تگ description و اسکیمای Article
|
||||
author: str = ""
|
||||
meta_description: str = ""
|
||||
meta_description_en: str = ""
|
||||
slug_en: str = ""
|
||||
seo_keyphrase_en: str = ""
|
||||
author_en: str = ""
|
||||
tags: list[str] = []
|
||||
tags_en: list[str] = []
|
||||
|
||||
|
||||
class TestimonialOut(ORMModel):
|
||||
@@ -386,10 +411,14 @@ class ContactIn(BaseModel):
|
||||
name: str = Field(min_length=3, max_length=128)
|
||||
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
|
||||
note: str = Field(default="", max_length=2000)
|
||||
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||
website: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
class NewsletterIn(BaseModel):
|
||||
email: EmailStr
|
||||
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
|
||||
website: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
# ---------- ورودیهای پنل مدیریت ----------
|
||||
@@ -403,6 +432,7 @@ class LoginIn(BaseModel):
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
@@ -414,6 +444,7 @@ class BrandIn(BaseModel):
|
||||
name: str = ""
|
||||
name_en: str = ""
|
||||
logo: str = ""
|
||||
logo_en: str = ""
|
||||
hero: str = ""
|
||||
contact_bg: str = ""
|
||||
phone: str = ""
|
||||
@@ -440,14 +471,21 @@ class ProjectIn(BaseModel):
|
||||
year: str = ""
|
||||
year_en: str = ""
|
||||
status: Literal["done", "progress"] = "done"
|
||||
land_area: str = ""
|
||||
pilot_floors: str = ""
|
||||
units_residential: str = ""
|
||||
units_commercial: str = ""
|
||||
units_office: str = ""
|
||||
project_type: str = ""
|
||||
project_type_en: str = ""
|
||||
usage: str = ""
|
||||
usage_en: str = ""
|
||||
image: str = ""
|
||||
gallery: list[str] = []
|
||||
area: str = ""
|
||||
area_en: str = ""
|
||||
floors: str = ""
|
||||
floors_en: str = ""
|
||||
units: str = ""
|
||||
units_en: str = ""
|
||||
client: str = ""
|
||||
client_en: str = ""
|
||||
summary: str = ""
|
||||
@@ -460,12 +498,13 @@ EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
|
||||
def validate_doc_body(value: object) -> dict:
|
||||
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
|
||||
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
|
||||
آدرسهای href/src آن به اسکیمهای امن محدود شوند."""
|
||||
if not value:
|
||||
return dict(EMPTY_DOC)
|
||||
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
|
||||
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
|
||||
return value
|
||||
return sanitize_doc(value)
|
||||
|
||||
|
||||
class ArticleIn(BaseModel):
|
||||
@@ -477,14 +516,26 @@ class ArticleIn(BaseModel):
|
||||
category: str = ""
|
||||
category_en: str = ""
|
||||
image: str = ""
|
||||
image_alt: str = ""
|
||||
image_title: str = ""
|
||||
read: str = ""
|
||||
read_en: str = ""
|
||||
excerpt: str = ""
|
||||
excerpt_en: str = ""
|
||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
faqs: list[dict] = []
|
||||
faqs_en: list[dict] = []
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
seo_keyphrase: str = Field(default="", max_length=128)
|
||||
seo_keyphrase_en: str = Field(default="", max_length=128)
|
||||
slug_en: str = Field(default="", max_length=128)
|
||||
author: str = Field(default="", max_length=128)
|
||||
author_en: str = Field(default="", max_length=128)
|
||||
tags: list[str] = []
|
||||
tags_en: list[str] = []
|
||||
meta_description: str = ""
|
||||
meta_description_en: str = ""
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
|
||||
@@ -779,14 +830,26 @@ class ArticlePatch(BaseModel):
|
||||
category: Optional[str] = None
|
||||
category_en: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
image_alt: Optional[str] = None
|
||||
image_title: Optional[str] = None
|
||||
read: Optional[str] = None
|
||||
read_en: Optional[str] = None
|
||||
excerpt: Optional[str] = None
|
||||
excerpt_en: Optional[str] = None
|
||||
body: Optional[dict] = None
|
||||
body_en: Optional[dict] = None
|
||||
faqs: Optional[list[dict]] = None
|
||||
faqs_en: Optional[list[dict]] = None
|
||||
status: Optional[Literal["draft", "published"]] = None
|
||||
seo_keyphrase: Optional[str] = None
|
||||
seo_keyphrase_en: Optional[str] = None
|
||||
slug_en: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
author_en: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
tags_en: Optional[list[str]] = None
|
||||
meta_description: Optional[str] = None
|
||||
meta_description_en: Optional[str] = None
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""محیط Alembic — متادیتا از مدلهای SQLAlchemy میآید و آدرس دیتابیس از تنظیمات."""
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app import models # noqa: F401 — ثبت مدلها در متادیتا
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
return os.environ.get("DATABASE_URL", settings.database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""نقطه شروع زنجیره مهاجرتها — جداول پایه با create_all ساخته میشوند.
|
||||
|
||||
ساخت اولیه ۲۹ جدول بر عهده Base.metadata.create_all است (در CMD کانتینر پیش از
|
||||
این مهاجرت اجرا میشود و برای دیتابیسهای موجود بیاثر است). از این نسخه به بعد
|
||||
هر تغییر اسکیما باید یک revision واقعی Alembic باشد — نه create_all و نه
|
||||
تغییر دستی — تا مسیر ارتقا و بازگشت آزمایشپذیر بماند.
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,43 @@
|
||||
"""ستونهای چندزبانه مقاله — اسلاگ/کلیدواژه/نویسنده انگلیسی و تگهای دو زبانه.
|
||||
|
||||
فیلدهای «نویسنده» و «متا دیسکریپشن» در ادیتور تا پیش از این نسخه یا مشترک
|
||||
بودند یا بیاثر؛ از این نسخه هر زبان ستون خودش را دارد.
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-10-01
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
articles = sa.table("articles")
|
||||
columns = sa.inspect(op.get_bind()).get_columns("articles")
|
||||
existing = {c["name"] for c in columns}
|
||||
if "slug_en" not in existing:
|
||||
op.add_column("articles", sa.Column("slug_en", sa.String(128), nullable=False, server_default=""))
|
||||
if "seo_keyphrase_en" not in existing:
|
||||
op.add_column("articles", sa.Column("seo_keyphrase_en", sa.String(128), nullable=False, server_default=""))
|
||||
if "author_en" not in existing:
|
||||
op.add_column("articles", sa.Column("author_en", sa.String(128), nullable=False, server_default=""))
|
||||
if "tags" not in existing:
|
||||
op.add_column("articles", sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"))
|
||||
if "tags_en" not in existing:
|
||||
op.add_column("articles", sa.Column("tags_en", sa.JSON(), nullable=False, server_default="[]"))
|
||||
_ = articles
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("articles", "tags_en")
|
||||
op.drop_column("articles", "tags")
|
||||
op.drop_column("articles", "author_en")
|
||||
op.drop_column("articles", "seo_keyphrase_en")
|
||||
op.drop_column("articles", "slug_en")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""لوگوی انگلیسی برند — ستون logo_en در جدول brand.
|
||||
|
||||
نسخهٔ انگلیسی سایت لوگوی اختصاصی خودش را دارد؛ فیلد مشترک logo برای هر دو
|
||||
زبان کافی نبود. بازگشت به فارسی با خالیبودن ستون انجام میشود.
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-10-01
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: Union[str, None] = "0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
cols = sa.inspect(op.get_bind()).get_columns("brand")
|
||||
existing = {c["name"] for c in cols}
|
||||
if "logo_en" not in existing:
|
||||
op.add_column("brand", sa.Column("logo_en", sa.Text(), nullable=False, server_default=""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
cols = sa.inspect(op.get_bind()).get_columns("brand")
|
||||
if "logo_en" in {c["name"] for c in cols}:
|
||||
op.drop_column("brand", "logo_en")
|
||||
@@ -11,3 +11,11 @@ bcrypt==4.2.1
|
||||
email-validator==2.2.0
|
||||
boto3==1.35.0
|
||||
python-multipart==0.0.18
|
||||
# پاکسازی HTML سمت سرور (تیپتپ و فیلدهای غنی)
|
||||
nh3==0.2.20
|
||||
# بهینهسازی تصویر در زمان آپلود (تغییر اندازه/فشردهسازی مجدد)
|
||||
Pillow==11.1.0
|
||||
# متریکهای Prometheus برای مانیتورینگ (در /metrics داخلی)
|
||||
prometheus-fastapi-instrumentator==7.0.0
|
||||
# مهاجرتهای نسخهدار اسکیما
|
||||
alembic==1.14.0
|
||||
|
||||
@@ -74,8 +74,12 @@ def reset_rate_limiter():
|
||||
from app import rate_limit
|
||||
|
||||
rate_limit._failed.clear()
|
||||
rate_limit.contact_limiter._events.clear()
|
||||
rate_limit.newsletter_limiter._events.clear()
|
||||
yield
|
||||
rate_limit._failed.clear()
|
||||
rate_limit.contact_limiter._events.clear()
|
||||
rate_limit.newsletter_limiter._events.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||
|
||||
import re
|
||||
import socket
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
@@ -8,12 +9,24 @@ DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
# ---------- عمومی ----------
|
||||
|
||||
|
||||
def test_health(client):
|
||||
res = client.get("/api/health")
|
||||
def test_live(client):
|
||||
"""liveness — پروسه زنده است، بدون وابستگی."""
|
||||
res = client.get("/api/live")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_deep(client):
|
||||
"""بررسی عمق — DB (SQLite در تست) سالم است؛ MinIO در محیط تست در دسترس نیست
|
||||
پس /api/health باید 503 و وضعیت degraded با جزئیات هر وابستگی بدهد."""
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code == 503
|
||||
body = res.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert body["checks"]["database"] == "ok"
|
||||
assert body["checks"]["storage"] == "error"
|
||||
|
||||
|
||||
def test_site_hides_draft_articles(client):
|
||||
res = client.get("/api/site")
|
||||
assert res.status_code == 200
|
||||
@@ -268,3 +281,399 @@ def test_en_backfill_fills_only_empty_fields(auth_client, client):
|
||||
assert res.status_code == 200
|
||||
backfill_en_content()
|
||||
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
|
||||
|
||||
|
||||
# ---------- محافظت فرمها و پاکسازی ----------
|
||||
|
||||
|
||||
def test_contact_honeypot_saves_nothing(client, auth_client):
|
||||
"""دامنهٔ پنهان پرشده یعنی ربات — پاسخ موفقِ قلابی، بدون ذخیره."""
|
||||
res = client.post(
|
||||
"/api/contact",
|
||||
json={"name": "ربات تستی", "phone": "09120000000", "website": "spam"},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["id"] == 0 # رکورد قلابی — در دیتابیس نیست
|
||||
assert auth_client.get("/api/admin/messages").json() == []
|
||||
|
||||
|
||||
def test_contact_rate_limited(client, auth_client):
|
||||
"""ارسال ششم در پنجرهٔ ۱۵ دقیقهای رد میشود (۵ مجاز برای هر IP)."""
|
||||
for _ in range(5):
|
||||
res = client.post(
|
||||
"/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"}
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
res = client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
|
||||
assert res.status_code == 429
|
||||
assert "Retry-After" in res.headers
|
||||
|
||||
|
||||
def test_newsletter_honeypot_saves_nothing(client, auth_client):
|
||||
res = client.post(
|
||||
"/api/newsletter", json={"email": "bot@example.com", "website": "spam"}
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["id"] == 0
|
||||
assert auth_client.get("/api/admin/subscribers").json() == []
|
||||
|
||||
|
||||
def test_message_and_subscriber_csv_export(auth_client):
|
||||
auth_client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
|
||||
res = auth_client.get("/api/admin/messages/export.csv")
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("text/csv")
|
||||
assert "کاربر تماس" in res.text
|
||||
|
||||
auth_client.post("/api/newsletter", json={"email": "sale@example.com"})
|
||||
res = auth_client.get("/api/admin/subscribers/export.csv")
|
||||
assert res.status_code == 200
|
||||
assert "sale@example.com" in res.text
|
||||
|
||||
|
||||
def test_article_body_url_sanitization(auth_client):
|
||||
"""اسکیمهای خطرناک در href/src سند TipTap در زمان ذخیره حذف میشوند."""
|
||||
res = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={
|
||||
"slug": "sanitized-post",
|
||||
"title": "مقاله پاکسازیشده",
|
||||
"status": "published",
|
||||
"body": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{"type": "link", "attrs": {"href": "javascript:alert(1)"}},
|
||||
],
|
||||
"text": "لینک خطرناک",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
body = res.json()["body"]
|
||||
marks = body["content"][0]["content"][0].get("marks", [])
|
||||
assert all("href" not in m.get("attrs", {}) for m in marks)
|
||||
|
||||
|
||||
# ---------- نامگذاری تصاویر، درونریزی و پرسشوپاسخ مقاله ----------
|
||||
|
||||
|
||||
class _FakeS3:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.objects = {}
|
||||
|
||||
def put_object(self, **kwargs):
|
||||
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||
|
||||
|
||||
def _mock_storage(monkeypatch):
|
||||
"""MinIO در تست در دسترس نیست — کلاینت S3 و DNS عمومی جعل میشوند."""
|
||||
fake = _FakeS3()
|
||||
monkeypatch.setattr("app.api.admin.boto3.client", lambda *a, **k: fake)
|
||||
monkeypatch.setattr(
|
||||
"app.api.admin.socket.getaddrinfo",
|
||||
lambda host, port, *a, **k: [(socket.AF_INET, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (200, 120, 60)).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_upload_with_readable_name(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("raw name.png", _png_bytes(), "image/png")},
|
||||
data={"name": "برج الهیه tower!"},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["key"]
|
||||
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + نرمالشدن به WebP
|
||||
assert "برج-الهیه-tower" in key
|
||||
assert key.endswith(".webp")
|
||||
assert res.json()["content_type"] == "image/webp"
|
||||
assert "!" not in key
|
||||
|
||||
|
||||
def _animated_gif_bytes() -> bytes:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
first = Image.new("RGB", (12, 8), (200, 120, 60))
|
||||
second = Image.new("RGB", (12, 8), (60, 120, 200))
|
||||
first.save(buf, "GIF", save_all=True, append_images=[second], duration=100, loop=0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_upload_jpeg_converted_to_webp(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (3000, 1500), (10, 10, 10)).save(buf, "JPEG", quality=90)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("tower.jpg", buf.getvalue(), "image/jpeg")},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["content_type"] == "image/webp"
|
||||
assert body["key"].endswith(".webp")
|
||||
# سقف ضلع ۲۰۰۰ پیکسل روی خروجی اعمال شده
|
||||
assert body["width"] == 2000 and body["height"] == 1000
|
||||
|
||||
|
||||
def test_upload_animated_gif_becomes_static_webp(auth_client, monkeypatch):
|
||||
fake = _mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("anim.gif", _animated_gif_bytes(), "image/gif")},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["content_type"] == "image/webp"
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
stored = next(iter(fake.objects.values()))
|
||||
out = Image.open(io.BytesIO(stored))
|
||||
assert out.format == "WEBP"
|
||||
# هیچ آپلودی هرگز متحرک ذخیره نمیشود — پوستر فریم اول
|
||||
assert getattr(out, "n_frames", 1) == 1
|
||||
|
||||
|
||||
def test_upload_undecodable_file_rejected(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("notes.txt", b"this is not a media file at all", "text/plain")},
|
||||
)
|
||||
assert res.status_code == 415
|
||||
|
||||
|
||||
def test_upload_video_becomes_static_webp_poster(auth_client, monkeypatch):
|
||||
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن پوستر تکفریمی WebP
|
||||
import shutil as _shutil
|
||||
import subprocess as _subprocess
|
||||
import tempfile as _tempfile
|
||||
import os as _os
|
||||
|
||||
fake = _mock_storage(monkeypatch)
|
||||
if _shutil.which("ffmpeg") is None:
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("clip.mp4", b"\x00\x00\x00\x18ftypmp42", "video/mp4")},
|
||||
)
|
||||
assert res.status_code == 415
|
||||
return
|
||||
|
||||
with _tempfile.TemporaryDirectory() as tmp:
|
||||
clip = _os.path.join(tmp, "clip.mp4")
|
||||
_subprocess.run(
|
||||
["ffmpeg", "-y", "-v", "error", "-f", "lavfi",
|
||||
"-i", "testsrc=duration=0.5:size=128x96:rate=10", clip],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
with open(clip, "rb") as fh:
|
||||
clip_bytes = fh.read()
|
||||
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("clip.mp4", clip_bytes, "video/mp4")},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["content_type"] == "image/webp"
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
stored = next(iter(fake.objects.values()))
|
||||
out = Image.open(io.BytesIO(stored))
|
||||
# ویدیو هرگز وب متحرک نمیشود — در کارتها سوسو نمیزند
|
||||
assert getattr(out, "n_frames", 1) == 1
|
||||
|
||||
|
||||
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("Elahieh Tower.jpg", _png_bytes(), "image/jpeg")},
|
||||
data={"name": ""},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert "Elahieh-Tower" in res.json()["key"]
|
||||
|
||||
|
||||
def test_import_image_rejects_internal_host(auth_client):
|
||||
# SSRF — میزبان حلقهمحلی/خصوصی رد میشود پیش از هر درخواست شبکه
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://127.0.0.1:9000/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://192.168.1.5/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "file:///etc/passwd"})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_import_image_rejects_bad_content_type(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
import io as _io
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "text/html"}
|
||||
|
||||
def read(self, n):
|
||||
return b"<html></html>"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", fake_urlopen)
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://example.com/page"})
|
||||
assert res.status_code == 415
|
||||
|
||||
|
||||
def test_import_image_happy_path(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "image/png"}
|
||||
|
||||
def read(self, n):
|
||||
return _png_bytes()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", lambda request, timeout: FakeResponse())
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["url"].startswith("/media/")
|
||||
assert res.json()["key"].endswith(".webp")
|
||||
assert "Elahieh-pic" in res.json()["key"]
|
||||
|
||||
|
||||
def test_article_persists_image_meta_and_faqs(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={"slug": "meta-article", "title": "متادیتا", "body": DOC},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
article_id = created.json()["id"]
|
||||
|
||||
faqs = [{"question": "هزینه چقدر است؟", "answer": "بستگی به پروژه دارد."}]
|
||||
res = auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={
|
||||
"image_alt": "نمای برج الهیه",
|
||||
"image_title": "برج آرامش",
|
||||
"faqs": faqs,
|
||||
"faqs_en": [{"question": "What is the cost?", "answer": "Depends on the project."}],
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["image_alt"] == "نمای برج الهیه"
|
||||
assert res.json()["image_title"] == "برج آرامش"
|
||||
assert res.json()["faqs"] == faqs
|
||||
|
||||
# انتشار و سپس خروجی عمومی — همین مقادیر برمیگردد
|
||||
assert auth_client.patch(f"/api/admin/articles/{article_id}", json={"status": "published"}).status_code == 200
|
||||
public = client.get("/api/articles/meta-article").json()
|
||||
assert public["image_alt"] == "نمای برج الهیه"
|
||||
assert public["faqs"] == faqs
|
||||
|
||||
assert auth_client.delete(f"/api/admin/articles/{article_id}").status_code == 204
|
||||
|
||||
|
||||
def test_blog_shell_includes_faqpage_jsonld(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={
|
||||
"slug": "faq-article",
|
||||
"title": "مقاله پرسشوپاسخ",
|
||||
"body": DOC,
|
||||
"status": "published",
|
||||
"faqs": [{"question": "زمان ساخت؟", "answer": "۳۰ ماه."}],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
res = client.get("/blog/faq-article")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" in res.text
|
||||
assert "زمان ساخت؟" in res.text
|
||||
|
||||
# نسخه انگلیسی بدون ترجمه → همان فارسی؛ با ترجمه → انگلیسی
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "FAQPage" in en
|
||||
article_id = created.json()["id"]
|
||||
auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={"faqs_en": [{"question": "How long?", "answer": "Thirty months."}]},
|
||||
)
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "How long?" in en
|
||||
assert "زمان ساخت؟" not in en
|
||||
|
||||
auth_client.delete(f"/api/admin/articles/{article_id}")
|
||||
|
||||
|
||||
def test_blog_shell_without_faqs_has_no_faqpage(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" not in res.text
|
||||
|
||||
|
||||
# ---------- پیشنمایش خصوصی (برای انتشار عمومی خاموش میشود) ----------
|
||||
|
||||
|
||||
def test_preview_mode_blocks_robots_and_sitemap(client, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "preview_mode", True)
|
||||
try:
|
||||
robots = client.get("/robots.txt")
|
||||
assert robots.status_code == 200
|
||||
assert "Disallow: /" in robots.text
|
||||
assert "Sitemap:" not in robots.text
|
||||
|
||||
sitemap = client.get("/sitemap.xml")
|
||||
assert sitemap.status_code == 200
|
||||
assert "published-post" not in sitemap.text
|
||||
|
||||
# لایه دوم: هدر noindex روی همه پاسخها
|
||||
assert "noindex" in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
finally:
|
||||
monkeypatch.setattr(settings, "preview_mode", False)
|
||||
|
||||
|
||||
def test_public_mode_allows_indexing(client):
|
||||
robots = client.get("/robots.txt").text
|
||||
assert "Disallow: /admin" in robots
|
||||
assert "Sitemap:" in robots
|
||||
assert "noindex" not in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# کانتکست بیلد باریک
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
test_pure.py
|
||||
@@ -0,0 +1,28 @@
|
||||
# سرویس چتبات — این مقادیر را در .env ریشه پروژه بگذارید (نه اینجا)
|
||||
# این فایل فقط مستندسازی متغیرهاست.
|
||||
|
||||
# --- اجباری ---
|
||||
# دیتابیس سایت (فقط خواندن) — در داکر از docker-compose.yml تزریق میشود
|
||||
# DATABASE_URL=postgresql+psycopg://aramesh:PASSWORD@db:5432/arameshplaza
|
||||
|
||||
# LLM سازگار با OpenAI — بدون این چتبات پاسخ نمیدهد
|
||||
# مثال سرور داخلی: LLM_BASE_URL=http://172.16.22.21:4460/v1
|
||||
# مثال سرویس ابری: LLM_BASE_URL=https://api.example.com/v1
|
||||
LLM_BASE_URL=
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=openai/gpt-oss-120b
|
||||
|
||||
# --- اختیاری ---
|
||||
# توکن محافظت از POST /chat/reindex — اگر خالی باشد مسیر ۴۰۳ میدهد
|
||||
CHAT_REINDEX_TOKEN=
|
||||
# ریرنکر را روی سرور کمرم خاموش کنید (RERANKER_ENABLED=false)
|
||||
RERANKER_ENABLED=true
|
||||
# وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) — پیشفرض ۰.۵
|
||||
ALPHA_DENSE=0.5
|
||||
# تعداد سند نهایی به LLM — پیشفرض ۳
|
||||
BEST_OF_K=3
|
||||
# بازسازی خودکار ایندکس پس از تغییر محتوا (ثانیه، ۰ = خاموش)
|
||||
INDEX_REFRESH_SECONDS=600
|
||||
# محدودیت نرخ: حداکثر پیام در پنجره زمانی (پیشفرض ۲۰ پیام در ۳۰۰ ثانیه)
|
||||
RATE_LIMIT_MESSAGES=20
|
||||
RATE_LIMIT_WINDOW=300
|
||||
@@ -0,0 +1,34 @@
|
||||
# سرویس چتبات — پایتون + مدلهای بازیابی (CPU)
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
# همهٔ وابستگیهای سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
|
||||
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابلاعتماد نیست
|
||||
COPY requirements.txt /srv/chatbot/requirements.txt
|
||||
# torch روی PyPI پیشفرض لینوکس نسخهٔ CUDA است و کل استک nvidia را میآورد
|
||||
# (ایمیج ~۹.۷GB روی سرور بدون GPU). نسخهٔ CPU حدود ۲۰۰MB است — همین اول نصب
|
||||
# میشود تا pip در گام بعد آن را برآوردهشده ببیند.
|
||||
# روی ماشینی با GPU میتوان این خط را حذف کرد.
|
||||
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu "torch>=2.2"
|
||||
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
|
||||
|
||||
COPY . /srv/chatbot/
|
||||
|
||||
# مدل امبدینگ/ریرنکر در /data کش میشود (والیوم داکر) — بردارها در pgvector میروند و فایل ایندکس جدا وجود ندارد
|
||||
ENV HF_HOME=/data/hf \
|
||||
HF_CACHE_DIR=/data/hf
|
||||
VOLUME /data
|
||||
|
||||
# کاربر بدون دسترسی root — فقط /data باید نوشتنی باشد (کش مدلها)
|
||||
RUN useradd --system --create-home appuser \
|
||||
&& mkdir -p /data \
|
||||
&& chown -R appuser:appuser /srv/chatbot /data
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/chat/healthz')" || exit 1
|
||||
|
||||
CMD ["uvicorn", "chatbot.app:api", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||
@@ -0,0 +1,144 @@
|
||||
# دستیار هوشمند سایت (چتبات)
|
||||
|
||||
سرویس گفتگوی محتوای سایت «گروه ساختمانی آرامش» — بازدیدکننده به فارسی سؤال میپرسد و
|
||||
دستیار **فقط از روی محتوای خود سایت** (مقالهها، پروژهها، خدمات، سؤالات متداول، اطلاعات تماس و…)
|
||||
پاسخ میدهد؛ اگر پاسخ در محتوا نباشد، بهجای ساخت جواب، صادقانه اعلام میکند.
|
||||
|
||||
این سرویس پورت خط لولهٔ پروژهٔ `llm-srerver-chatbot` (نسخه v13) با چهار تغییر اصلی برای این سایت است:
|
||||
|
||||
1. **پایگاه sparse بهجای SPLADE از BM25 استفاده میکند** — SPLADE انگلیسیمحور است و روی
|
||||
فارسی عملاً بیفایده؛ BM25 با نرمالسازی فارسی (ی/ک عربی، اعراب، ارقام، نیمفاصله) جایگزین شده است.
|
||||
2. **ذخیرهساز برداری pgvector است، نه FAISS** — بردارها در یک دیتابیس جدا (`aramesh_chatbot`)
|
||||
روی همان نمونه Postgres نگهداری میشوند؛ همگامسازی افزایشی است (فقط سطرهای جدید/تغییریافته
|
||||
امبد میشوند)، بکاپگیری با خود دیتابیس است و فایل ایندکس جدا وجود ندارد.
|
||||
3. **تأخیر کمتر** — مسیریابی `alpha` با LLM و گسترش چندپرسشی (multi-query) بهصورت پیشفرض
|
||||
خاموشاند (با متغیر محیطی قابل فعالسازی)؛ بازنویسی پرسش فقط وقتی تاریخچه گفتگو وجود دارد اجرا میشود.
|
||||
4. **استریم واقعی توکنبهتوکن** با `astream_events` بهجای دیف پیامهای کامل.
|
||||
|
||||
## خط لوله هر پیام
|
||||
|
||||
```
|
||||
پرسش کاربر
|
||||
│
|
||||
├─[اگر تاریخچه هست] بازنویسی پرسش با LLM (حل ضمیرها و اشارهها: «اون دومی» → موضوع واقعی)
|
||||
│
|
||||
├─ بازیابی ترکیبی: pgvector (معنایی، کسینوسی روی HNSW — MiniLM چندزبانه)
|
||||
│ + BM25 (کلیدواژه، متن نرمالشده فارسی)
|
||||
│ نمرهها min-max → ترکیب α×معنایی + (1-α)×کلیدواژه
|
||||
│
|
||||
├─ برش خوشهای (K-Means K=2 + silhouette ≥ 0.55) — مرز طبیعی کیفیت
|
||||
│
|
||||
├─ ریرنک با BAAI/bge-reranker-v2-m3 → نگهداشتن ۳ سند برتر
|
||||
│
|
||||
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
|
||||
```
|
||||
|
||||
ایندکس از محتوای PostgreSQL سایت ساخته میشود (`articles` منتشرشده + پرسشوپاسخهای
|
||||
پایان مقاله، `projects` با مشخصات کامل، `services`، `faqs`، `brand`، `offices`،
|
||||
`investment_models`، `process_steps`، `why_us`، `team`، `testimonials`، `timeline`،
|
||||
`story`، و متنهای مدیریت محتوا: `stats`، `hero`، `page_heroes`، `section_headings`،
|
||||
`footer`، `before_after`). سطرهای جایگاهخالی (متن زیر ~۳۰ نویسه) ایندکس نمیشوند.
|
||||
متنهای بلند به قطعات ~۷۰۰ نویسهای همپوشان برش میخورند.
|
||||
|
||||
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
|
||||
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
|
||||
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار (شامل url: `type:url-key:occ:chunk`)
|
||||
و `content_hash` دارد و همگامسازی فقط سطرهای جدید/تغییریافته را امبد میکند.
|
||||
دیتابیس و جدول در شروع سرویس خودکار ساخته میشوند.
|
||||
- **تیتر در متن قطعه**: در سندهای تکقطعهای که تیترشان در متن نیست (مثلاً نام پروژه)،
|
||||
تیتر به ابتدای متن میچسبد تا BM25 و جستوجوی معنایی پیدایش کنند.
|
||||
- **بازسازی خودکار**: یک حلقهٔ پسزمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
|
||||
میکند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام میشود
|
||||
(امبد اجباری همه: `POST /chat/reindex`).
|
||||
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگگراف) — با ریاستارت پاک میشود.
|
||||
- **گزارش گفتگوها**: هر پاسخ (بههمراه تعداد سند بازیابیشده، تشخیص «پاسخی نبود» و تأخیر)
|
||||
در جدول `chat_logs` همان دیتابیس ذخیره میشود تا پرسشهای بیپاسخ — یعنی شکافهای
|
||||
دانش — قابل کشف باشند؛ `GET /chat/stats` جمعبندی میدهد.
|
||||
|
||||
## متغیرهای محیطی (در `.env` ریشه پروژه)
|
||||
|
||||
| متغیر | پیشفرض | توضیح |
|
||||
|---|---|---|
|
||||
| `LLM_BASE_URL` | خالی | **اجباری** — نشانی سرویس LLM سازگار با OpenAI. بدون آن چتبات بالا میآید ولی پاسخ نمیدهد |
|
||||
| `LLM_API_KEY` | خالی | کلید API (برای سرور داخلی `not-needed` بگذارید) |
|
||||
| `LLM_MODEL` | `openai/gpt-oss-120b` | نام مدل |
|
||||
| `CHAT_REINDEX_TOKEN` | خالی | توکن `POST /chat/reindex` — اگر خالی باشد آن مسیر ۴۰۳ میدهد |
|
||||
| `CHATBOT_DB_NAME` | `aramesh_chatbot` | نام دیتابیس اختصاصی چتبات روی همان نمونه Postgres (بردارها اینجا میروند) |
|
||||
| `EMBEDDING_DIM` | `384` | بعد بردار مدل امبدینگ — با تعویض مدل امبدینگ باید هماهنگ شود |
|
||||
| `RERANKER_ENABLED` | `true` | روی سرور کمرم (زیر ~۴GB) `false` بگذارید |
|
||||
| `ALPHA_DENSE` | `0.5` | وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) |
|
||||
| `BEST_OF_K` | `3` | تعداد سند نهایی که به LLM میرود |
|
||||
| `INDEX_REFRESH_SECONDS` | `600` | بازسازی خودکار ایندکس پس از تغییر محتوا (۰ = خاموش) |
|
||||
| `RATE_LIMIT_MESSAGES` / `RATE_LIMIT_WINDOW` | `20` / `300` | سقف پیام هر IP در پنجره زمانی (ثانیه) |
|
||||
|
||||
`DATABASE_URL` در داکر از `docker-compose.yml` تزریق میشود — دیتابیس سایت فقط خوانده
|
||||
میشود و نوشتن (بردارها) فقط در دیتابیس جدا `aramesh_chatbot` انجام میگیرد.
|
||||
|
||||
## پیشنیاز pgvector
|
||||
|
||||
سرویس `db` استک از ایمیج `pgvector/pgvector:pg17` استفاده میکند (همان postgres:17 +
|
||||
افزونهٔ vector). اگر Postgres سایت روی نمونهٔ دیگری است، باید افزونهٔ pgvector روی آن
|
||||
فعال باشد؛ در غیر این صورت شروع چتبات با خطای واضح متوقف میشود.
|
||||
|
||||
## مسیرها
|
||||
|
||||
| مسیر | توضیح |
|
||||
|---|---|
|
||||
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکنبهتوکن پاسخ (`{"content": …}` و در پایان `{"done": true, "fallback": true|false}`) |
|
||||
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
|
||||
| `GET /chat/eval?message=…` | مخصوص ارزیابی — پاسخ + اسناد بازیابیشده با thread تازه |
|
||||
| `GET /chat/stats` | آمار گفتگوها (کل، ۷ روز اخیر، نرخ پیام جایگزین)؛ فهرست پرسشهای بیپاسخ اخیر فقط با هدر `X-Reindex-Token` |
|
||||
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
|
||||
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
|
||||
|
||||
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
|
||||
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
|
||||
|
||||
## ارزیابی
|
||||
|
||||
مجموعه طلایی `eval_set.json` (پرسشهای واقعی از محتوای دیتابیس + یک کنترل مثبت برای
|
||||
«نباید قیمت بسازد») با `eval_run.py` اجرا میشود — داخل کانتینر:
|
||||
|
||||
```bash
|
||||
docker exec aramesh-site-chatbot-1 python /srv/chatbot/eval_run.py --out /tmp/report.md
|
||||
docker exec aramesh-site-chatbot-1 cat /tmp/report.md
|
||||
```
|
||||
|
||||
خروجی: وضعیت هر پرسش (`pass` / `miss` / `manual` / `fallback-expected` / `unexpected-fallback`)،
|
||||
منابع بازیابیشده و زمان پاسخ — برای سنجش اثر تغییرات دانش و کشف پسرفتها.
|
||||
|
||||
## اجرا
|
||||
|
||||
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
|
||||
|
||||
```bash
|
||||
# در .env ریشه:
|
||||
LLM_BASE_URL=http://172.16.22.21:4460/v1 # یا هر سرویس سازگار با OpenAI
|
||||
LLM_API_KEY=not-needed
|
||||
```
|
||||
|
||||
اولین بوت مدلها را دانلود میکند (~۱.۵GB: امبدینگ + ریرنکر) و در والیوم
|
||||
`chatbot_cache` کش میشود؛ بوتهای بعدی چند ثانیه طول میکشد.
|
||||
|
||||
اجرای محلی بدون داکر:
|
||||
|
||||
```bash
|
||||
cd chatbot
|
||||
python -m venv .venv && source .venv/bin/activate # ویندوز: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
export DATABASE_URL="postgresql+psycopg://aramesh:PASS@localhost:5432/arameshplaza"
|
||||
export LLM_BASE_URL="http://…" LLM_MODEL="openai/gpt-oss-120b"
|
||||
uvicorn chatbot.app:api --port 8000 # از ریشه پوشه والد
|
||||
```
|
||||
|
||||
## منابع مورد نیاز
|
||||
|
||||
- **رم**: با ریرنکر ~۴GB (مدل ۵۶۸M پارامتری روی CPU)؛ بدون ریرنکر ~۲GB
|
||||
- **CPU**: امبدینگ و ریرنک روی CPU اجرا میشوند؛ GPU لازم نیست
|
||||
- **شبکه**: دسترسی به PostgreSQL و به نشانی `LLM_BASE_URL`
|
||||
|
||||
## نکات امنیتی
|
||||
|
||||
- دیتابیس سایت فقط **خواندن**؛ نوشتن فقط در دیتابیس اختصاصی `aramesh_chatbot` انجام میشود
|
||||
- محدودسازی نرخ per-IP فعال است (پیشفرض ۲۰ پیام در ۵ دقیقه)
|
||||
- CORS باز است ولی کوکی/اعتبارنامهای رد و بدل نمیشود؛ در تولید همهچیز هممبدأ (پشت Traefik) است
|
||||
@@ -0,0 +1 @@
|
||||
"""سرویس چتبات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
|
||||
@@ -0,0 +1,315 @@
|
||||
"""سرویس چتبات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from . import content as content_mod
|
||||
from . import pipeline
|
||||
from . import vectorstore
|
||||
from .config import settings
|
||||
from .indexing import INDEX, sync_index
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||
)
|
||||
log = logging.getLogger("chatbot")
|
||||
|
||||
|
||||
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
|
||||
|
||||
_refresh_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
|
||||
global _refresh_task
|
||||
_refresh_task = asyncio.create_task(_refresh_loop())
|
||||
yield
|
||||
_refresh_task.cancel()
|
||||
|
||||
|
||||
async def _refresh_loop() -> None:
|
||||
interval = settings.index_refresh_seconds
|
||||
if interval <= 0:
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
# همگامسازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد میشوند
|
||||
await asyncio.to_thread(sync_index)
|
||||
except Exception as e:
|
||||
log.warning("Background index refresh failed: %s", e)
|
||||
|
||||
|
||||
api = FastAPI(
|
||||
title="Aramesh Site Chatbot",
|
||||
version="1.0.0",
|
||||
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ریرنکر BGE",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
api.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
|
||||
|
||||
_hits: dict[str, deque] = defaultdict(deque)
|
||||
_last_sweep = 0.0
|
||||
|
||||
|
||||
def _rate_limited(ip: str) -> bool:
|
||||
global _last_sweep
|
||||
now = time.time()
|
||||
# پاکسازی دورهای کلیدهای بیمصرف تا دیکشنری بینهایت بزرگ نشود
|
||||
if now - _last_sweep > settings.rate_limit_window:
|
||||
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
|
||||
for k in stale:
|
||||
_hits.pop(k, None)
|
||||
_last_sweep = now
|
||||
window = _hits[ip]
|
||||
while window and now - window[0] > settings.rate_limit_window:
|
||||
window.popleft()
|
||||
if len(window) >= settings.rate_limit_messages:
|
||||
return True
|
||||
window.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request | None) -> str:
|
||||
if request is None:
|
||||
return "?"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
|
||||
|
||||
|
||||
# --- کمکیهای SSE ---
|
||||
|
||||
def _sse(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _chunk_text(chunk) -> str:
|
||||
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخشها)."""
|
||||
content = getattr(chunk, "content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
return ""
|
||||
|
||||
|
||||
async def _stream_answer(message: str, thread_id: str):
|
||||
if not INDEX.ready:
|
||||
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
|
||||
return
|
||||
if pipeline.get_llm() is None:
|
||||
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
|
||||
return
|
||||
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
started = time.time()
|
||||
answer_parts: list[str] = []
|
||||
try:
|
||||
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
|
||||
# فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
|
||||
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
|
||||
text = _chunk_text(event["data"]["chunk"])
|
||||
if text:
|
||||
answer_parts.append(text)
|
||||
yield _sse({"content": text, "done": False})
|
||||
|
||||
# وضعیت نهایی گراف: تعداد اسناد بازیابیشده — برای تشخیص «پاسخی نبود»
|
||||
n_docs = 0
|
||||
try:
|
||||
state = await pipeline.graph.aget_state(config)
|
||||
n_docs = len(state.values.get("documents") or [])
|
||||
except Exception as e:
|
||||
log.warning("Final state fetch failed: %s", e)
|
||||
answer = "".join(answer_parts)
|
||||
is_fallback = n_docs == 0 or answer.strip() == pipeline.FALLBACK_MESSAGE
|
||||
await asyncio.to_thread(
|
||||
vectorstore.log_chat, thread_id, message, answer, n_docs, is_fallback,
|
||||
int((time.time() - started) * 1000),
|
||||
)
|
||||
yield _sse({"content": "", "done": True, "fallback": is_fallback})
|
||||
except Exception as e:
|
||||
log.exception("Streaming failed")
|
||||
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
|
||||
|
||||
|
||||
# --- مسیرها ---
|
||||
|
||||
@api.get("/chat/healthz")
|
||||
async def healthz():
|
||||
return {
|
||||
"status": "ok",
|
||||
"index_ready": INDEX.ready,
|
||||
"documents": len(INDEX.docs),
|
||||
"vectors": await asyncio.to_thread(vectorstore.vector_count),
|
||||
"llm_configured": bool(settings.llm_base_url),
|
||||
"reranker_enabled": settings.reranker_enabled,
|
||||
}
|
||||
|
||||
|
||||
@api.get("/chat/suggestions")
|
||||
async def suggestions():
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/eval")
|
||||
async def eval_question(message: str = Query(...)):
|
||||
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده، با thread تازه."""
|
||||
if not INDEX.ready:
|
||||
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
|
||||
if pipeline.get_llm() is None:
|
||||
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
|
||||
|
||||
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
inputs = {"messages": [HumanMessage(content=message)]}
|
||||
final_state = await pipeline.graph.ainvoke(inputs, config=config)
|
||||
|
||||
answer = ""
|
||||
for msg in reversed(final_state.get("messages", [])):
|
||||
if isinstance(msg, AIMessage):
|
||||
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||
break
|
||||
return {
|
||||
"question": message,
|
||||
"contextualized_query": final_state.get("contextualized_query") or message,
|
||||
"answer": answer,
|
||||
"contexts": final_state.get("documents", []),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
|
||||
@api.get("/chat/stats")
|
||||
async def chat_stats(request: Request):
|
||||
"""آمار گفتگوها — جمعبندی برای همه؛ پرسشهای بیپاسخ اخیر فقط با توکن."""
|
||||
stats = await asyncio.to_thread(vectorstore.chat_stats)
|
||||
payload = {
|
||||
"total_logged": stats["total_logged"],
|
||||
"last_7d": stats["last_7d"],
|
||||
"fallback_7d": stats["fallback_7d"],
|
||||
}
|
||||
if settings.reindex_token and request.headers.get("x-reindex-token") == settings.reindex_token:
|
||||
payload["recent_fallback_questions"] = stats["recent_fallback_questions"]
|
||||
return payload
|
||||
|
||||
|
||||
@api.post("/chat/reindex")
|
||||
async def reindex(request: Request):
|
||||
"""همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
|
||||
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
|
||||
raise HTTPException(status_code=403, detail="invalid reindex token")
|
||||
stats = await asyncio.to_thread(sync_index, True)
|
||||
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
|
||||
|
||||
|
||||
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval میسازد
|
||||
|
||||
@api.get("/chat/{scope}/stream")
|
||||
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
return await stream_chat(message=message, thread_id=thread_id, request=request)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/eval")
|
||||
async def eval_scoped(scope: str, message: str = Query(...)):
|
||||
return await eval_question(message=message)
|
||||
|
||||
|
||||
@api.get("/chat/{scope}/suggestions")
|
||||
async def suggestions_scoped(scope: str):
|
||||
return {"questions": _get_suggestions()}
|
||||
|
||||
|
||||
@api.get("/chat/stream")
|
||||
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
|
||||
"""استریم توکنبهتوکن پاسخ با SSE."""
|
||||
if _rate_limited(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="تعداد پیامهای شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
|
||||
|
||||
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
async def generate():
|
||||
async for chunk in _stream_answer(message, thread_id):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# --- پیشنهاد پرسشها (کششده) ---
|
||||
|
||||
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
|
||||
|
||||
|
||||
def _get_suggestions() -> list[str]:
|
||||
global _suggestions_cache
|
||||
now = time.time()
|
||||
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
|
||||
return _suggestions_cache[1]
|
||||
|
||||
questions: list[str] = []
|
||||
if INDEX.ready:
|
||||
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
|
||||
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
|
||||
model = pipeline.get_llm()
|
||||
if model and (faqs or titles):
|
||||
material = "\n".join([d["text"][:200] for d in faqs] + titles)
|
||||
try:
|
||||
prompt = (
|
||||
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
|
||||
"احتمالاً میپرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
|
||||
'فقط آرایه JSON رشتهای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
|
||||
f"محتوا:\n{material[:2000]}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)])
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
if text.startswith("```"):
|
||||
text = text.split("```")[1]
|
||||
text = text[4:] if text.startswith("json") else text
|
||||
import re
|
||||
match = re.search(r"\[.*\]", text, re.DOTALL)
|
||||
if match:
|
||||
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
|
||||
except Exception as e:
|
||||
log.warning("Suggestion generation failed: %s", e)
|
||||
if not questions and faqs:
|
||||
# جایگزین: خود پرسشهای متداول
|
||||
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
|
||||
|
||||
_suggestions_cache = (now, questions)
|
||||
return questions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(api, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""تنظیمات سرویس چتبات — از متغیرهای محیطی یا فایل .env خوانده میشود."""
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# دیتابیس سایت (فقط خواندن) — همان DATABASE_URL بکاند
|
||||
database_url: str = "postgresql+psycopg://aramesh:aramesh@localhost:5432/arameshplaza"
|
||||
# دیتابیس اختصاصی چتبات روی همان نمونه Postgres — بردارها اینجا نوشته میشوند
|
||||
# تا دیتابیس سایت فقطخواندنی بماند؛ در شروع سرویس خودکار ساخته میشود
|
||||
chatbot_db_name: str = "aramesh_chatbot"
|
||||
|
||||
# LLM سازگار با OpenAI — اجباری؛ بدون آن پاسخگویی غیرفعال است
|
||||
llm_base_url: str = ""
|
||||
llm_api_key: str = ""
|
||||
llm_model: str = "openai/gpt-oss-120b"
|
||||
|
||||
# مدلهای بازیابی
|
||||
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||
# بعد بردار خروجی مدل امبدینگ (MiniLM-L12 = 384) — با تغییر مدل باید عوض شود
|
||||
embedding_dim: int = 384
|
||||
reranker_model: str = "BAAI/bge-reranker-v2-m3"
|
||||
reranker_enabled: bool = True
|
||||
reranker_device: str = "cpu"
|
||||
|
||||
# وزن جستوجوی معنایی در ترکیب نمرهها (۰ = فقط کلیدواژه، ۱ = فقط معنایی)
|
||||
alpha_dense: float = 0.5
|
||||
# تعداد سند نهایی که به LLM داده میشود
|
||||
best_of_k: int = 3
|
||||
# تعداد نتیجه هر پایگاه (معنایی و کلیدواژه) برای هر پرسوجو
|
||||
retrieve_k: int = 12
|
||||
# بازنویسی پرسوجو با تاریخچه گفتگو (فقط وقتی تاریخچه هست صدا زده میشود)
|
||||
contextualize_enabled: bool = True
|
||||
# گسترش پرسوجو به چند نوع — برای پیکره کوچک پیشفرض خاموش (تأخیر کمتر)
|
||||
query_expansion: bool = False
|
||||
|
||||
# مسیر کش مدلها (در داکر روی والیوم /data سوار میشود)
|
||||
hf_cache_dir: str = "/data/hf"
|
||||
|
||||
# محدودسازی نرخ درخواست هر IP (تعداد پیام در پنجره زمانی به ثانیه)
|
||||
rate_limit_messages: int = 20
|
||||
rate_limit_window: int = 300
|
||||
|
||||
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار میکند
|
||||
# (.env از نام CHAT_REINDEX_TOKEN استفاده میکند — هر دو نام پذیرفته میشود)
|
||||
reindex_token: str = Field(
|
||||
default="", validation_alias=AliasChoices("REINDEX_TOKEN", "CHAT_REINDEX_TOKEN")
|
||||
)
|
||||
|
||||
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
|
||||
suggestions_ttl: int = 600
|
||||
site_context_ttl: int = 120
|
||||
|
||||
# بازسازی خودکار ایندکس وقتی محتوای دیتابیس عوض شود (۰ = خاموش)
|
||||
index_refresh_seconds: int = 600
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,473 @@
|
||||
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
|
||||
|
||||
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
|
||||
پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
|
||||
پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند؛ اما پرسشوپاسخهای
|
||||
پایان مقالههای منتشرشده بهعنوان سند faq ایندکس میشوند.
|
||||
|
||||
بهجز جدولهای محتوایی، متنهای مدیریت محتوا (آمار، هیرو، هیرو صفحهها،
|
||||
تیتر بخشها، فوتر، قبل/بعد) هم ایندکس میشوند — ادعاهای مرکزی سایت
|
||||
(سال تجربه، تعداد پروژه و…) فقط آنجا ثبت شدهاند.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .persian import normalize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# URL پایه صفحات — چتبات مسیر نسبی میدهد؛ فرانتاند روی همان دامنه است
|
||||
_ARTICLE_URL = "/blog/{slug}"
|
||||
_PROJECT_URL = "/projects/{slug}"
|
||||
|
||||
# بخشهای صفحه اصلی و صفحات داخلی — برای نسبت دادن URL به تیتر بخشها
|
||||
_SECTION_URL = {
|
||||
"services": "/services",
|
||||
"featured_projects": "/projects",
|
||||
"articles": "/blog",
|
||||
"faq": "/faq",
|
||||
"investment_models": "/investment",
|
||||
"process": "/investment",
|
||||
}
|
||||
|
||||
# سندهای کوتاهتر از این جایگاهخالیاند («خدمت جدید» و امثال آن) — ایندکس نمیشوند
|
||||
_MIN_DOC_CHARS = 30
|
||||
|
||||
|
||||
def _psycopg_dsn(database_url: str) -> str:
|
||||
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
|
||||
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
|
||||
cur = conn.execute(sql)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def tiptap_blocks(node: Any) -> list[str]:
|
||||
"""متن بلوکبهبلوک از سند TipTap (یا هر JSON تو در تو) بیرون میکشد."""
|
||||
if not isinstance(node, dict):
|
||||
return []
|
||||
ntype = node.get("type")
|
||||
if ntype in ("paragraph", "heading", "codeBlock"):
|
||||
text = _inline_text(node).strip()
|
||||
return [text] if text else []
|
||||
blocks: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
blocks.extend(tiptap_blocks(child))
|
||||
return blocks
|
||||
|
||||
|
||||
def _inline_text(node: dict) -> str:
|
||||
parts: list[str] = []
|
||||
for child in node.get("content") or []:
|
||||
ctype = child.get("type")
|
||||
if ctype == "text":
|
||||
parts.append(child.get("text", ""))
|
||||
elif ctype == "hardBreak":
|
||||
parts.append(" ")
|
||||
elif ctype == "image":
|
||||
alt = (child.get("attrs") or {}).get("alt") or ""
|
||||
if alt:
|
||||
parts.append(f"[تصویر: {alt}]")
|
||||
else:
|
||||
parts.append(_inline_text(child))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def body_to_text(body: Any) -> str:
|
||||
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
|
||||
if not body:
|
||||
return ""
|
||||
if isinstance(body, list):
|
||||
# فرمت قدیمی: آرایه رشتهای پاراگرافها
|
||||
return "\n".join(str(p).strip() for p in body if str(p).strip())
|
||||
blocks = tiptap_blocks(body)
|
||||
return "\n".join(b for b in blocks if b)
|
||||
|
||||
|
||||
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
|
||||
text = (text or "").strip()
|
||||
if len(text) < _MIN_DOC_CHARS:
|
||||
if text:
|
||||
log.info("Skipped tiny doc (%d chars, type=%s, title=%s)", len(text), dtype, title)
|
||||
return None
|
||||
return {"text": text, "type": dtype, "title": title, "url": url}
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return normalize(str(value or "")).strip()
|
||||
|
||||
|
||||
def _format_stat_rows(rows: list[dict[str, Any]]) -> list[str]:
|
||||
"""سطرهای جدول stats → «۲۳+ سال تجربه ساخت» (پسوند تکحرفی میچسبد، بقیه با فاصله)."""
|
||||
parts: list[str] = []
|
||||
for r in rows:
|
||||
suffix = _clean(r.get("suffix"))
|
||||
value = str(r.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
num = f"{value}{suffix}" if len(suffix) == 1 else f"{value} {suffix}".strip()
|
||||
parts.append(f"{num} {_clean(r.get('label'))}".strip())
|
||||
return parts
|
||||
|
||||
|
||||
def fetch_site_documents(database_url: str) -> list[dict]:
|
||||
"""همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
docs: list[dict] = []
|
||||
|
||||
# --- برند و اطلاعات تماس (رکورد یگانه) ---
|
||||
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
if brand_rows:
|
||||
b = brand_rows[0]
|
||||
contact = "\n".join(
|
||||
filter(None, [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
])
|
||||
)
|
||||
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- آمار و افتخارات — اعداد مرکزی سایت فقط اینجا ثبتاند ---
|
||||
stats = _format_stat_rows(_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position"))
|
||||
if stats:
|
||||
d = _doc("آمار و افتخارات مجموعه: " + "؛ ".join(stats), "stats", "آمار و افتخارات", "/")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- هیرو صفحه اصلی (رکورد یگانه) ---
|
||||
hero_rows = _fetch_rows(
|
||||
dsn,
|
||||
"SELECT kicker, title, title_highlight, subtitle, badge_value, badge_label FROM hero LIMIT 1",
|
||||
)
|
||||
if hero_rows:
|
||||
h = hero_rows[0]
|
||||
title = _clean(h["title"])
|
||||
if _clean(h["title_highlight"]):
|
||||
title = f"{title} (کلمهٔ برجسته: {_clean(h['title_highlight'])})"
|
||||
lines = list(filter(None, [
|
||||
f"شعار بالای تیتر: {_clean(h['kicker'])}" if _clean(h["kicker"]) else "",
|
||||
f"تیتر اصلی: {title}" if title else "",
|
||||
f"زیرتیتر: {_clean(h['subtitle'])}" if _clean(h["subtitle"]) else "",
|
||||
(
|
||||
f"نشان: {_clean(h['badge_value'])} {_clean(h['badge_label'])}"
|
||||
if _clean(h["badge_value"]) or _clean(h["badge_label"]) else ""
|
||||
),
|
||||
]))
|
||||
d = _doc("\n".join(lines), "hero", "معرفی صفحه اصلی", "/")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- دفاتر ---
|
||||
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
|
||||
d = _doc(
|
||||
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
|
||||
"office", f"دفتر {_clean(row['city'])}", "/contact",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- خدمات ---
|
||||
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'):
|
||||
d = _doc(
|
||||
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
"service", _clean(row["title"]), "/services",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پرسشهای متداول ---
|
||||
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
|
||||
d = _doc(
|
||||
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
|
||||
"faq", _clean(row["question"]), "/faq",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- پروژهها (شامل پروژههای نقشهٔ صفحه پروژهها — فقط مشخصات، بدون توضیح) ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"""
|
||||
SELECT slug, title, location, year, status, area, floors, land_area, pilot_floors,
|
||||
units_residential, units_commercial, units_office, client,
|
||||
project_type, usage, summary, body
|
||||
FROM projects ORDER BY position
|
||||
""",
|
||||
):
|
||||
units = "، ".join(
|
||||
f"{v} واحد {label}"
|
||||
for label, col in (("مسکونی", row["units_residential"]),
|
||||
("تجاری", row["units_commercial"]),
|
||||
("اداری", row["units_office"]))
|
||||
if (v := _clean(col)) and v != "0"
|
||||
)
|
||||
meta = "\n".join(filter(None, [
|
||||
f"مکان: {_clean(row['location'])}" if row["location"] else "",
|
||||
f"سال: {_clean(row['year'])}" if row["year"] else "",
|
||||
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
|
||||
f"نوع پروژه: {_clean(row['project_type'])}" if row["project_type"] else "",
|
||||
f"کاربری: {_clean(row['usage'])}" if row["usage"] else "",
|
||||
f"متراژ زیربنا: {_clean(row['area'])}" if row["area"] else "",
|
||||
f"عرصه: {_clean(row['land_area'])}" if row["land_area"] else "",
|
||||
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
|
||||
f"طبقات پیلوت: {_clean(row['pilot_floors'])}" if row["pilot_floors"] else "",
|
||||
f"واحدها: {units}" if units else "",
|
||||
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
|
||||
]))
|
||||
text = "\n".join(filter(None, [
|
||||
f"پروژه «{_clean(row['title'])}»",
|
||||
meta,
|
||||
_clean(row["summary"]),
|
||||
body_to_text(row["body"]),
|
||||
]))
|
||||
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مقالهها (فقط منتشرشده) + پرسشوپاسخهای پایان مقاله ---
|
||||
for row in _fetch_rows(
|
||||
dsn,
|
||||
"SELECT slug, title, date, category, excerpt, body, faqs FROM articles WHERE status = 'published' ORDER BY position",
|
||||
):
|
||||
text = "\n".join(filter(None, [
|
||||
f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
|
||||
_clean(row["excerpt"]),
|
||||
body_to_text(row["body"]),
|
||||
]))
|
||||
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
article_url = _ARTICLE_URL.format(slug=row["slug"])
|
||||
for f in row["faqs"] or []:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
q, a = _clean(f.get("question")), _clean(f.get("answer"))
|
||||
d = _doc(f"پرسش: {q}\nپاسخ: {a}", "faq", q, article_url)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مدلهای سرمایهگذاری ---
|
||||
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
|
||||
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
|
||||
d = _doc(
|
||||
"\n".join(filter(None, [
|
||||
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
|
||||
f"نکات: {points}" if points else "",
|
||||
])),
|
||||
"investment", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- مراحل همکاری ---
|
||||
for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'):
|
||||
d = _doc(
|
||||
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"process", _clean(row["title"]), "/investment",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- تیتر و توضیح بخشها (مدیریت محتوا) ---
|
||||
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM section_headings ORDER BY key"):
|
||||
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
|
||||
d = _doc(text, "section", _clean(row["title"]), _SECTION_URL.get(row["key"], "/"))
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- هیرو صفحههای داخلی (about/services/projects/… ) ---
|
||||
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM page_heroes ORDER BY key"):
|
||||
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
|
||||
d = _doc(text, "page_hero", _clean(row["title"]), f"/{row['key']}")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- قبل/بعد (بخش صفحه اصلی) ---
|
||||
ba_rows = _fetch_rows(dsn, "SELECT title, lead FROM before_after LIMIT 1")
|
||||
if ba_rows:
|
||||
text = "\n".join(filter(None, [_clean(ba_rows[0]["title"]), _clean(ba_rows[0]["lead"])]))
|
||||
d = _doc(text, "before_after", _clean(ba_rows[0]["title"]) or "قبل و بعد", "/")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
# --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما / فوتر ---
|
||||
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
|
||||
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
|
||||
d = _doc(
|
||||
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
|
||||
"team", _clean(row["name"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
testimonial_rows = _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position")
|
||||
for row in testimonial_rows:
|
||||
d = _doc(
|
||||
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
|
||||
"testimonial", _clean(row["name"]), "/",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
# سند تجمیعی — پرسشهای طبیعی مثل «نظرات مشتریان چیست؟» به تکنفرها نمیرسند
|
||||
# (بیریشهسازی BM25: «نظرات» با «نظر» و «مشتریان» با «کارفرما» هیچ اشتراکی ندارند)
|
||||
if len(testimonial_rows) >= 2:
|
||||
agg = "؛ ".join(
|
||||
f"{_clean(t['name'])} ({_clean(t['role'])}): {_clean(t['quote'])}"
|
||||
for t in testimonial_rows
|
||||
)
|
||||
d = _doc(f"نظرات مشتریان و کارفرمایان پروژهها:\n{agg}", "testimonial", "نظرات مشتریان", "/")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
|
||||
d = _doc(
|
||||
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
|
||||
"timeline", _clean(row["title"]), "/about",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
|
||||
if story_rows and story_rows[0]:
|
||||
s = story_rows[0]
|
||||
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
|
||||
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
|
||||
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
footer_rows = _fetch_rows(dsn, "SELECT about_text FROM footer LIMIT 1")
|
||||
if footer_rows and _clean(footer_rows[0]["about_text"]):
|
||||
d = _doc(
|
||||
_clean(footer_rows[0]["about_text"]),
|
||||
"about", "درباره مجموعه (فوتر)", "/",
|
||||
)
|
||||
if d:
|
||||
docs.append(d)
|
||||
|
||||
log.info("Fetched %d documents from site database", len(docs))
|
||||
return docs
|
||||
|
||||
|
||||
def _url_key(url: str) -> str:
|
||||
"""URL → کلید یکتا برای doc_id («/projects/x» → «projects-x»)."""
|
||||
return url.strip("/").replace("/", "-") or "home"
|
||||
|
||||
|
||||
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
|
||||
"""متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
|
||||
|
||||
هر قطعه یک doc_id پایدار میگیرد («type:url-key:ترتیب در همان url:شماره قطعه») تا
|
||||
همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
|
||||
URL بخشی از doc_id است — بدون آن همه پروژهها/مقالهها که url یکتا دارند
|
||||
روی همان id میافتند و در جدول بردار یکدیگر را بازنویسی میکنند.
|
||||
"""
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
from .vectorstore import chunk_content_hash
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=overlap,
|
||||
separators=["\n", "۔", ".", "،", " ", ""],
|
||||
)
|
||||
|
||||
# شماره ردیف هر سند در میان سندهای همtype و هم-url (برای FAQs که url یکسان دارند)
|
||||
occurrence: dict[str, int] = {}
|
||||
|
||||
out: list[dict] = []
|
||||
for doc in docs:
|
||||
key = f"{doc['type']}|{doc['url']}"
|
||||
occ = occurrence.get(key, 0)
|
||||
occurrence[key] = occ + 1
|
||||
|
||||
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
|
||||
for i, piece in enumerate(pieces, 1):
|
||||
if len(pieces) == 1:
|
||||
# تیتر باید در متن امبدشده باشد تا BM25/معنایی پیدایش کند —
|
||||
# در سندهای تکقطعهای که تیترشان در متن نیست، به ابتدای قطعه میچسبد
|
||||
text = piece if not doc["title"] or doc["title"] in piece[:150] else f"{doc['title']}\n{piece}"
|
||||
else:
|
||||
text = f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}"
|
||||
chunk = {
|
||||
"doc_id": f"{doc['type']}:{_url_key(doc['url'])}:{occ}:{i}",
|
||||
"text": text,
|
||||
"type": doc["type"],
|
||||
"title": doc["title"],
|
||||
"url": doc["url"],
|
||||
}
|
||||
chunk["content_hash"] = chunk_content_hash(chunk)
|
||||
out.append(chunk)
|
||||
|
||||
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
|
||||
return out
|
||||
|
||||
|
||||
def content_fingerprint(docs: list[dict]) -> str:
|
||||
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام میشود."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# --- زمینه همیشهتازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
|
||||
_site_context_cache: tuple[float, str] | None = None
|
||||
|
||||
|
||||
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
|
||||
"""نام برند، تماس، خدمات، آمار و دفاتر — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
|
||||
global _site_context_cache
|
||||
import time as _time
|
||||
|
||||
now = _time.time()
|
||||
if _site_context_cache and now - _site_context_cache[0] < ttl:
|
||||
return _site_context_cache[1]
|
||||
try:
|
||||
dsn = _psycopg_dsn(database_url)
|
||||
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
|
||||
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
|
||||
stats = _format_stat_rows(
|
||||
_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position")
|
||||
)
|
||||
offices = _fetch_rows(dsn, "SELECT city, address FROM offices ORDER BY position")
|
||||
lines: list[str] = []
|
||||
if brand:
|
||||
b = brand[0]
|
||||
lines += [
|
||||
f"نام مجموعه: {_clean(b['name'])}",
|
||||
f"تلفن تماس: {_clean(b['phone'])}",
|
||||
f"ایمیل: {_clean(b['email'])}",
|
||||
f"آدرس: {_clean(b['address'])}",
|
||||
]
|
||||
if services:
|
||||
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
|
||||
if stats:
|
||||
lines.append("آمار: " + "؛ ".join(stats))
|
||||
if offices:
|
||||
lines.append("دفاتر: " + "؛ ".join(
|
||||
f"{_clean(o['city'])} ({_clean(o['address'])})" for o in offices
|
||||
))
|
||||
ctx = "\n".join(lines)
|
||||
_site_context_cache = (now, ctx)
|
||||
return ctx
|
||||
except Exception as e:
|
||||
log.warning("Site context fetch failed: %s", e)
|
||||
return _site_context_cache[1] if _site_context_cache else ""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تستهای سبک، torch لود نمیشود)."""
|
||||
|
||||
import os
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_embeddings():
|
||||
from langchain_huggingface import HuggingFaceEmbeddings
|
||||
|
||||
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||
return HuggingFaceEmbeddings(
|
||||
model_name=settings.embedding_model,
|
||||
model_kwargs={"device": "cpu"},
|
||||
encode_kwargs={"normalize_embeddings": False},
|
||||
cache_folder=settings.hf_cache_dir,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""اجرای مجموعه طلایی روی سرویس چتبات و ساخت گزارش مارکداون.
|
||||
|
||||
استفاده (داخل کانتینر یا هر جا که به سرویس دسترسی هست):
|
||||
python eval_run.py --base http://localhost:8000
|
||||
python eval_run.py --out eval-report.md
|
||||
|
||||
هر پرسش از /chat/eval سرویس میشود؛ پاسخ با زیررشتههای انتظار
|
||||
(پس از نرمالسازی فارسی) مقایسه و رتبهبندی میشود:
|
||||
pass — همه/یکی از انتظارها برآورده شد
|
||||
miss — انتظار برآورده نشد (پاسخ احتمالاً نادرست یا بیربط)
|
||||
manual — انتظار عددی تعریف نشده؛ بازبینی دستی
|
||||
fallback-expected / unexpected-fallback — پیام جایگزین
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
FALLBACK_MESSAGE = (
|
||||
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||
)
|
||||
|
||||
|
||||
def ask(base: str, question: str, timeout: int = 180) -> dict:
|
||||
url = f"{base}/chat/eval?{urllib.parse.urlencode({'message': question})}"
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="اجرای مجموعه طلایی ارزیابی چتبات")
|
||||
ap.add_argument("--base", default="http://localhost:8000", help="نشانی سرویس چتبات")
|
||||
ap.add_argument("--set", dest="eval_set",
|
||||
default=str(Path(__file__).with_name("eval_set.json")))
|
||||
ap.add_argument("--out", default="-", help="فایل خروجی گزارش (- = stdout)")
|
||||
ap.add_argument("--sleep", type=float, default=0.5, help="مکث بین پرسشها (ثانیه)")
|
||||
args = ap.parse_args()
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from chatbot.persian import normalize
|
||||
|
||||
data = json.loads(Path(args.eval_set).read_text(encoding="utf-8"))
|
||||
rows: list[dict] = []
|
||||
|
||||
for item in data["questions"]:
|
||||
q = item["q"]
|
||||
t0 = time.time()
|
||||
try:
|
||||
res = ask(args.base, q)
|
||||
except Exception as e:
|
||||
rows.append({**item, "answer": f"ERROR: {e}", "sources": [], "seconds": 0.0,
|
||||
"status": "error", "fallback": False})
|
||||
print(f" error: {q[:50]}", file=sys.stderr)
|
||||
continue
|
||||
seconds = time.time() - t0
|
||||
answer = str(res.get("answer", ""))
|
||||
contexts = res.get("contexts") or []
|
||||
is_fallback = answer.strip() == FALLBACK_MESSAGE or not contexts
|
||||
|
||||
if item.get("expect_fallback"):
|
||||
status = "fallback-expected" if is_fallback else "unexpected-answer"
|
||||
elif is_fallback:
|
||||
status = "unexpected-fallback"
|
||||
else:
|
||||
needles = [normalize(s) for s in item.get("expect_any", [])]
|
||||
if not needles:
|
||||
status = "manual"
|
||||
elif any(n in normalize(answer) for n in needles):
|
||||
status = "pass"
|
||||
else:
|
||||
status = "miss"
|
||||
|
||||
rows.append({**item, "answer": answer, "sources": contexts, "seconds": seconds,
|
||||
"status": status, "fallback": is_fallback})
|
||||
print(f" [{status}] {q[:50]}", file=sys.stderr)
|
||||
|
||||
time.sleep(args.sleep)
|
||||
|
||||
# --- گزارش ---
|
||||
lines = [f"# گزارش ارزیابی چتبات — {time.strftime('%Y-%m-%d %H:%M')}", ""]
|
||||
counts: dict[str, int] = {}
|
||||
for r in rows:
|
||||
counts[r["status"]] = counts.get(r["status"], 0) + 1
|
||||
graded = [r for r in rows if r["status"] in ("pass", "miss", "unexpected-fallback", "unexpected-answer")]
|
||||
ok = sum(1 for r in rows if r["status"] in ("pass", "fallback-expected"))
|
||||
lines += [
|
||||
f"- مجموع پرسشها: {len(rows)} — درست: {ok} — خطا: {counts.get('miss', 0) + counts.get('unexpected-fallback', 0) + counts.get('unexpected-answer', 0)}",
|
||||
f"- بازبینی دستی: {counts.get('manual', 0)} — خطای فنی: {counts.get('error', 0)}",
|
||||
f"- میانگین زمان پاسخ: {sum(r['seconds'] for r in rows) / max(len(rows), 1):.1f}s",
|
||||
"",
|
||||
]
|
||||
for r in rows:
|
||||
srcs = [c.rsplit("(منبع:", 1)[-1].strip(" ()\n") for c in r["sources"]] if r["sources"] else []
|
||||
lines += [
|
||||
f"## [{r['status']}] {r['q']}",
|
||||
f"*موضوع: {r.get('topic', '?')} — {r['seconds']:.1f}s — منابع: {'، '.join(srcs) or '—'}*",
|
||||
"",
|
||||
r["answer"],
|
||||
"",
|
||||
]
|
||||
|
||||
report = "\n".join(lines)
|
||||
if args.out == "-":
|
||||
print(report)
|
||||
else:
|
||||
Path(args.out).write_text(report, encoding="utf-8")
|
||||
print(f"Report written to {args.out}", file=sys.stderr)
|
||||
|
||||
return 1 if any(r["status"] in ("miss", "unexpected-answer") for r in rows) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "مجموعه طلایی ارزیابی چتبات — پرسشها از واقعیتهای زنده دیتابیس سایت ساخته شدهاند. expect_any: حداقل یکی از این زیررشتهها (پس از نرمالسازی) در پاسخ باشد؛ expect_fallback: انتظار پیام جایگزین (شکاف دانش). رشتههای خالی = بررسی دستی.",
|
||||
"questions": [
|
||||
{"q": "پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟", "topic": "projects", "expect_any": ["8100"]},
|
||||
{"q": "پروژه آرامش - بابک کی و کجا تحویل شده؟", "topic": "projects", "expect_any": ["1405", "6200"]},
|
||||
{"q": "وضعیت پروژه میرزاطاهر ۶ چطور است؟", "topic": "projects", "expect_any": ["در حال ساخت"]},
|
||||
{"q": "کدام پروژه شما در محله گورتان اصفهان است؟", "topic": "projects", "expect_any": ["وحدت"]},
|
||||
{"q": "پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟", "topic": "projects", "expect_any": ["29"]},
|
||||
{"q": "گروه ساختمانی آرامش چند سال سابقه دارد؟", "topic": "stats", "expect_any": ["23", "1381"]},
|
||||
{"q": "تا امروز چند پروژه تحویل دادهاید؟", "topic": "stats", "expect_any": ["64"]},
|
||||
{"q": "مجموع متراژ ساختوساز شما چقدر است؟", "topic": "stats", "expect_any": ["111"]},
|
||||
{"q": "چند خانواده در پروژههای شما ساکن هستند؟", "topic": "stats", "expect_any": ["500"]},
|
||||
{"q": "شماره تماس شما چیست؟", "topic": "contact", "expect_any": ["09133107809"]},
|
||||
{"q": "دفتر تهران کجاست؟", "topic": "offices", "expect_any": ["ولیعصر"]},
|
||||
{"q": "آدرس ایمیل مجموعه چیست؟", "topic": "contact", "expect_any": ["arameshsakhtemani"]},
|
||||
{"q": "دفاتر شما در چه شهرهایی است؟", "topic": "offices", "expect_any": ["نوشهر"]},
|
||||
{"q": "گروه آرامش چه سالی تأسیس شده؟", "topic": "faqs", "expect_any": ["1381"]},
|
||||
{"q": "زمین دارم، ساخت را از کجا شروع کنم؟", "topic": "faqs", "expect_any": []},
|
||||
{"q": "شرایط مشارکت در ساخت چگونه است؟", "topic": "investment", "expect_any": []},
|
||||
{"q": "مدل صندوق سرمایهگذاری چطور کار میکند؟", "topic": "investment", "expect_any": ["24"]},
|
||||
{"q": "پیشخرید واحد یعنی چه؟", "topic": "investment", "expect_any": ["پرداخت", "شخصیسازی", "تحویل"]},
|
||||
{"q": "خدمات گروه آرامش چیست؟", "topic": "services", "expect_any": []},
|
||||
{"q": "نظرات مشتریان قبلی درباره شما چیست؟", "topic": "testimonials", "expect_any": []},
|
||||
{"q": "هزینه ساخت هر متر مربع چقدر است؟", "topic": "canary", "expect_fallback": true}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""ایندکس ترکیبی روی pgvector + BM25 در حافظه.
|
||||
|
||||
بردارها در pgvector (دیتابیس اختصاصی چتبات) بهصورت افزایشی همگام میشوند —
|
||||
فقط سطرهای جدید یا تغییر یافته امبد میشوند. BM25 چون سبک است در هر شروع
|
||||
از روی همان سطرها ساخته میشود و نیازی به کش دیسکی ندارد.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
from . import content as content_mod
|
||||
from .config import settings
|
||||
from .persian import tokenize
|
||||
from . import vectorstore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SiteIndex:
|
||||
"""حالت در حافظهٔ بازیابی — سندها (برای BM25) و وضعیت آماده بودن."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.docs: list[dict] = []
|
||||
self.bm25: BM25Okapi | None = None
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self.bm25 is not None and bool(self.docs)
|
||||
|
||||
|
||||
INDEX = SiteIndex()
|
||||
|
||||
|
||||
def sync_index(force_rebuild: bool = False) -> dict:
|
||||
"""همگامسازی کامل: محتوا → قطعه → pgvector (افزایشی) → ساخت BM25.
|
||||
|
||||
خروجی: آمار همگامسازی. خطای اتصال به دیتابیس بالا فرستاده میشود.
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
vectorstore.ensure_schema()
|
||||
|
||||
docs = content_mod.fetch_site_documents(settings.database_url)
|
||||
chunks = content_mod.chunk_documents(docs)
|
||||
stats = vectorstore.sync_chunks(chunks, force=force_rebuild)
|
||||
|
||||
INDEX.docs = vectorstore.fetch_all_chunks()
|
||||
corpus_tokens = [tokenize(d["text"]) for d in INDEX.docs]
|
||||
INDEX.bm25 = BM25Okapi(corpus_tokens) if corpus_tokens else None
|
||||
|
||||
log.info("Index ready in %.1fs (%d vectors, %d docs, %s)",
|
||||
time.time() - start, len(INDEX.docs), len(chunks), stats)
|
||||
return stats
|
||||
@@ -0,0 +1,79 @@
|
||||
"""نرمالسازی متن فارسی و توکنسازی برای جستوجوی کلیدواژه (BM25).
|
||||
|
||||
بدون وابستگی سنگین — فقط استانداردسازی نویسهها، حذف اعراب و نیمفاصلههای
|
||||
مشکلی و شکستن کلمههای مرکب روی نیمفاصله برای بهبود بازیابی.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# نویسههای عربی که در متن فارسی اشتباهاً استفاده میشوند
|
||||
_CHAR_MAP = {
|
||||
"\u064a": "\u06cc", # ي → ی
|
||||
"\u0643": "\u06a9", # ك → ک
|
||||
"\u0623": "\u0627", # أ → ا
|
||||
"\u0625": "\u0627", # إ → ا
|
||||
"\u0671": "\u0627", # ٱ → ا
|
||||
"\u0629": "\u0647", # ة → ه
|
||||
"\u0624": "\u0648", # ؤ → و
|
||||
"\u0626": "\u06cc", # ئ → ی
|
||||
"\u0640": "", # ـ (کشیده) حذف
|
||||
}
|
||||
|
||||
# اعراب و علامتهای اضافی
|
||||
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655\u0640]")
|
||||
|
||||
# ارقام فارسی و عربی → ASCII
|
||||
_DIGITS = str.maketrans({
|
||||
"\u06f0": "0", "\u06f1": "1", "\u06f2": "2", "\u06f3": "3", "\u06f4": "4",
|
||||
"\u06f5": "5", "\u06f6": "6", "\u06f7": "7", "\u06f8": "8", "\u06f9": "9",
|
||||
"\u0660": "0", "\u0661": "1", "\u0662": "2", "\u0663": "3", "\u0664": "4",
|
||||
"\u0665": "5", "\u0666": "6", "\u0667": "7", "\u0668": "8", "\u0669": "9",
|
||||
})
|
||||
|
||||
# واژههای دستوری پرتکرار — در جستوجو بیمعنا هستند
|
||||
STOPWORDS = frozenset("""
|
||||
و در به از که با این آن است هست بود بودن شد شدن شود کرد کردن کرده کند کنی
|
||||
برای را های ها هایی هم نیز اما یا اگر تا بر روی زیر بین پس قبل بعد همه هر چه
|
||||
چیزی کس کسی کجا چرا چگونه چطور چند یکی دو سه ما شما آنها ایشان من تو او
|
||||
کدام کی چی چهقدر چقدر
|
||||
می نخواهد خواهد باشد باشند هستیم هستند بودم بودیم دارد دارند داشته داد دهد
|
||||
بله خیر آیا فقط نیز خیلی بسیار کمی دیگر دیگری همان همین وقتی زمانی وقتیکه
|
||||
اساساً لطفاً میشه میکنه هستن هستم
|
||||
""".split())
|
||||
|
||||
# علائم نگارشی فارسی/لاتین — قبل از توکنسازی حذف میشوند (نیمفاصله جزو اینها نیست)
|
||||
_PUNCT_RE = re.compile(r"[،؛؟!,;:\?\"'()\[\]{}«»….*#@&%+=/\\|~^$\u066d\u066c\u066b]+")
|
||||
|
||||
# توکن: حروف/ارقام فارسی و لاتین + نیمفاصله در میان کلمه
|
||||
_TOKEN_RE = re.compile(r"[\w\u0600-\u06ff\u200c]+")
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""نویسههای غیراستاندارد عربی/فارسی را به شکل یکدست فارسی میبرد."""
|
||||
if not text:
|
||||
return ""
|
||||
out = []
|
||||
for ch in text:
|
||||
out.append(_CHAR_MAP.get(ch, ch))
|
||||
text = "".join(out)
|
||||
text = _DIACRITICS.sub("", text)
|
||||
text = text.translate(_DIGITS)
|
||||
# فاصلههای تکراری را یکی کن — نیمفاصله عمداً حفظ میشود (میشود)
|
||||
text = re.sub(r"[ \t\r\n]+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
"""توکنهای نرمالشده برای BM25 — کلمههای مرکب روی نیمفاصله هم جدا ایندکس میشوند."""
|
||||
text = _PUNCT_RE.sub(" ", normalize(text).lower())
|
||||
tokens = []
|
||||
for raw in _TOKEN_RE.findall(text):
|
||||
if raw in STOPWORDS or len(raw) < 2:
|
||||
continue
|
||||
tokens.append(raw)
|
||||
# «میشود» → می + شود (فراخوانی بیشتر برای بازیابی)
|
||||
if "\u200c" in raw:
|
||||
for part in raw.split("\u200c"):
|
||||
if len(part) >= 2 and part not in STOPWORDS:
|
||||
tokens.append(part)
|
||||
return tokens
|
||||
@@ -0,0 +1,191 @@
|
||||
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
|
||||
|
||||
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفهجویی در تأخیر)
|
||||
۲) بازیابی ترکیبی با چند نوعواژه (اختیاری)
|
||||
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import json as _json
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
from . import content as content_mod
|
||||
from .config import settings
|
||||
from .retrieval import retrieve_final
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_MESSAGE = (
|
||||
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
|
||||
"برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
|
||||
)
|
||||
LLM_UNAVAILABLE = (
|
||||
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
|
||||
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چتبات تنظیم کنید."
|
||||
)
|
||||
|
||||
_llm = None
|
||||
|
||||
|
||||
def get_llm() -> ChatOpenAI | None:
|
||||
"""LLM تولید — اگر پیکربندی نشده باشد None برمیگرداند."""
|
||||
global _llm
|
||||
if _llm is None and settings.llm_base_url:
|
||||
_llm = ChatOpenAI(
|
||||
model=settings.llm_model,
|
||||
base_url=settings.llm_base_url,
|
||||
api_key=settings.llm_api_key or "not-needed",
|
||||
temperature=0,
|
||||
streaming=True,
|
||||
)
|
||||
return _llm
|
||||
|
||||
|
||||
class GraphState(TypedDict):
|
||||
messages: Annotated[List, add_messages]
|
||||
documents: List[str]
|
||||
contextualized_query: Optional[str]
|
||||
|
||||
|
||||
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
|
||||
|
||||
def contextualize_query(question: str, history: list) -> str:
|
||||
"""ضمیرها و اشارههای پرسش را با تاریخچه گفتگو مشخص میکند («اون دومی» → موضوع واقعی)."""
|
||||
model = get_llm()
|
||||
if model is None or not history:
|
||||
return question
|
||||
try:
|
||||
history_text = "\n".join(
|
||||
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
|
||||
for m in history[-6:]
|
||||
)
|
||||
prompt = (
|
||||
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
|
||||
"- ضمیرها و اشارهها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
|
||||
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
|
||||
"- فقط متن پرسش بازنویسیشده را برگردان؛ هیچ توضیحی ننویس.\n\n"
|
||||
f"تاریخچه گفتگو:\n{history_text}\n\n"
|
||||
f"پرسش کاربر: {question}\n\n"
|
||||
"پرسش مستقل:"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
|
||||
if not rewritten or len(rewritten) > len(question) * 4:
|
||||
return question
|
||||
if rewritten != question:
|
||||
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
|
||||
return rewritten
|
||||
except Exception as e:
|
||||
log.warning("Contextualization failed: %s", e)
|
||||
return question
|
||||
|
||||
|
||||
def expand_query(query: str) -> list[str]:
|
||||
"""گسترش پرسش به چند نوعواژه (اختیاری — پیشفرض خاموش برای پیکره کوچک)."""
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return [query]
|
||||
try:
|
||||
prompt = (
|
||||
"برای جستوجو در محتوای سایت یک گروه ساختمانی، ۳ نوعواژه جایگزین برای این پرسش بساز: "
|
||||
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
|
||||
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
|
||||
f"پرسش: {query}"
|
||||
)
|
||||
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
|
||||
text = resp.content if isinstance(resp.content, str) else ""
|
||||
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
|
||||
if match:
|
||||
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
|
||||
if variants:
|
||||
log.info("Query variants: %s", [query] + variants)
|
||||
return [query] + variants
|
||||
except Exception as e:
|
||||
log.warning("Query expansion failed: %s", e)
|
||||
return [query]
|
||||
|
||||
|
||||
# --- گام ۲: بازیابی ---
|
||||
|
||||
def retrieve_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return {"documents": []}
|
||||
question = messages[-1].content
|
||||
history = messages[:-1]
|
||||
|
||||
query = contextualize_query(question, history) if settings.contextualize_enabled else question
|
||||
variants = expand_query(query) if settings.query_expansion else [query]
|
||||
|
||||
docs = retrieve_final(query, variants)
|
||||
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
|
||||
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
|
||||
return {
|
||||
"documents": doc_texts,
|
||||
"contextualized_query": query if query != question else None,
|
||||
}
|
||||
|
||||
|
||||
# --- گام ۳: تولید پاسخ ---
|
||||
|
||||
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
|
||||
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
|
||||
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
|
||||
reminder = (
|
||||
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
|
||||
if contextualized_query else ""
|
||||
)
|
||||
return (
|
||||
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ میدهی.\n"
|
||||
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
|
||||
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
|
||||
f"--- اسناد بازیابیشده مرتبط با پرسش ---\n{docs_block}\n"
|
||||
f"{reminder}"
|
||||
"--- قوانین پاسخ ---\n"
|
||||
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ بههیچوجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
|
||||
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفهای و محترمانه داشته باشد.\n"
|
||||
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
|
||||
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
|
||||
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در دادهها آمده است."
|
||||
)
|
||||
|
||||
|
||||
def generate_node(state: GraphState) -> dict:
|
||||
messages = state.get("messages", [])
|
||||
question = messages[-1].content if messages else ""
|
||||
|
||||
model = get_llm()
|
||||
if model is None:
|
||||
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
|
||||
|
||||
documents = state.get("documents") or []
|
||||
system = build_system_prompt(documents, state.get("contextualized_query"))
|
||||
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
|
||||
|
||||
history = messages[:-1]
|
||||
reply = model.invoke(
|
||||
[SystemMessage(content=system), *history, HumanMessage(content=question)],
|
||||
config={"tags": ["generate"]},
|
||||
)
|
||||
return {"messages": [reply]}
|
||||
|
||||
|
||||
# --- گراف ---
|
||||
|
||||
memory = MemorySaver()
|
||||
workflow = StateGraph(GraphState)
|
||||
workflow.add_node("retrieve", retrieve_node)
|
||||
workflow.add_node("generate", generate_node)
|
||||
workflow.set_entry_point("retrieve")
|
||||
workflow.add_edge("retrieve", "generate")
|
||||
workflow.add_edge("generate", END)
|
||||
graph = workflow.compile(checkpointer=memory)
|
||||
@@ -0,0 +1,20 @@
|
||||
# سرویس چتبات — بازیابی ترکیبی روی محتوای سایت
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic-settings>=2.3
|
||||
|
||||
# گراف گفتگو و LLM
|
||||
langgraph>=0.2.60
|
||||
langchain>=0.3
|
||||
langchain-openai>=0.2
|
||||
langchain-text-splitters>=0.3
|
||||
|
||||
# بازیابی — بردارها در pgvector میروند؛ BM25 و خوشهبندی در حافظه
|
||||
sentence-transformers>=3.0
|
||||
langchain-huggingface>=0.1
|
||||
rank-bm25>=0.2.2
|
||||
scikit-learn>=1.4
|
||||
numpy>=1.26
|
||||
|
||||
# دیتابیس (فقط خواندن)
|
||||
psycopg[binary]>=3.1
|
||||
@@ -0,0 +1,50 @@
|
||||
"""مدل ریرنکر BGE — با لود تنبل و امتیازدهی دستهای."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CrossEncoder:
|
||||
"""غلاف سبک روی AutoModelForSequenceClassification — API شبیه rank_bm25."""
|
||||
def __init__(self) -> None:
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
|
||||
os.makedirs(settings.hf_cache_dir, exist_ok=True)
|
||||
self._torch = torch
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(settings.reranker_model, cache_dir=settings.hf_cache_dir)
|
||||
self.model = AutoModelForSequenceClassification.from_pretrained(
|
||||
settings.reranker_model, cache_dir=settings.hf_cache_dir
|
||||
)
|
||||
device = settings.reranker_device if settings.reranker_device == "cuda" and torch.cuda.is_available() else "cpu"
|
||||
self.model.to(device)
|
||||
self.model.eval()
|
||||
self.device = device
|
||||
log.info("Reranker loaded on %s", device)
|
||||
|
||||
def score(self, pairs: list[list[str]], batch_size: int = 16) -> list[float]:
|
||||
torch = self._torch
|
||||
out: list[float] = []
|
||||
with torch.no_grad():
|
||||
for i in range(0, len(pairs), batch_size):
|
||||
inputs = self.tokenizer(
|
||||
pairs[i:i + batch_size],
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=512,
|
||||
return_tensors="pt",
|
||||
).to(self.device)
|
||||
logits = self.model(**inputs).logits.squeeze(-1)
|
||||
out.extend(logits.cpu().tolist())
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_reranker() -> _CrossEncoder:
|
||||
return _CrossEncoder()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""بازیابی ترکیبی — پورت خط لوله v13 با سه تغییر برای این سایت:
|
||||
|
||||
۱) پایگاه sparse بهجای SPLADE (انگلیسیمحور و بیفایده روی فارسی) از BM25
|
||||
با نرمالسازی فارسی استفاده میکند.
|
||||
۲) جستوجوی معنایی بهجای FAISS با pgvector و فاصله کسینوسی در SQL انجام
|
||||
میشود (همگامسازی افزایشی، فیلترپذیر، بدون فایل ایندکس جدا).
|
||||
۳) مسیریابی alpha با LLM حذف شده و وزن ثابت تنظیمپذیر است — برای پیکرهٔ
|
||||
کوچک سایت، هزینهٔ یک فراخوانی LLM اضافی بهازای هر پیام نمیارزد.
|
||||
بقیهٔ منطق (نرمالسازی نمره، فیوژن وزندار، برش خوشهبندی، ریرنک) عین v13 است.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from . import vectorstore
|
||||
from .config import settings
|
||||
from .indexing import INDEX
|
||||
from .persian import tokenize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def dense_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی معنایی pgvector — خروجی: (سند، شباهت کسینوسی)."""
|
||||
return vectorstore.dense_search(query, top_k)
|
||||
|
||||
|
||||
def keyword_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی کلیدواژه BM25 روی متن نرمالشده فارسی."""
|
||||
if INDEX.bm25 is None:
|
||||
return []
|
||||
try:
|
||||
tokens = tokenize(query)
|
||||
if not tokens:
|
||||
return []
|
||||
scores = INDEX.bm25.get_scores(tokens)
|
||||
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
|
||||
return [(INDEX.docs[i], float(scores[i])) for i in ranked if scores[i] > 0]
|
||||
except Exception as e:
|
||||
log.error("BM25 retrieval failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _minmax(scores: list[float]) -> list[float]:
|
||||
if not scores:
|
||||
return []
|
||||
lo, hi = min(scores), max(scores)
|
||||
if hi == lo:
|
||||
return [0.5] * len(scores)
|
||||
return [(s - lo) / (hi - lo) for s in scores]
|
||||
|
||||
|
||||
def hybrid_retrieve(query: str, alpha: float | None = None, top_k: int | None = None) -> list[tuple[dict, float]]:
|
||||
"""ترکیب وزندار نمره معنایی و کلیدواژه؛ خروجی مرتبشده بر اساس نمره ترکیبی."""
|
||||
alpha = settings.alpha_dense if alpha is None else alpha
|
||||
top_k = top_k or settings.retrieve_k
|
||||
|
||||
dense = dense_retrieve(query, top_k)
|
||||
keyword = keyword_retrieve(query, top_k)
|
||||
log.info("Retrieval '%s': dense=%d, keyword=%d", query[:60], len(dense), len(keyword))
|
||||
if not dense and not keyword:
|
||||
return []
|
||||
|
||||
dense_norm = _minmax([s for _, s in dense])
|
||||
keyword_norm = _minmax([s for _, s in keyword])
|
||||
|
||||
pool: dict = {} # doc_id → {doc, dense_max, kw_max}
|
||||
for (doc, _), norm in zip(dense, dense_norm):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||
entry["dense_max"] = max(entry["dense_max"], norm)
|
||||
for (doc, _), norm in zip(keyword, keyword_norm):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
|
||||
entry["kw_max"] = max(entry["kw_max"], norm)
|
||||
|
||||
combined = [
|
||||
(entry["doc"], alpha * entry["dense_max"] + (1 - alpha) * entry["kw_max"])
|
||||
for entry in pool.values()
|
||||
]
|
||||
combined.sort(key=lambda x: x[1], reverse=True)
|
||||
return combined
|
||||
|
||||
|
||||
def cluster_cutoff(scored: list[tuple[dict, float]], silhouette_threshold: float = 0.55) -> list[tuple[dict, float]]:
|
||||
"""برش خوشهای v13 — مرز طبیعی کیفیت بهجای top-k دلخواه.
|
||||
|
||||
روی نمرههای ۱بعدی K-Means با K=2 میزند؛ اگر جدایی خوشهها معنادار
|
||||
باشد (silhouette ≥ 0.55) فقط خوشهٔ بالا را نگه میدارد.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
n = len(scored)
|
||||
if n < 4:
|
||||
return scored
|
||||
try:
|
||||
from sklearn.cluster import KMeans
|
||||
from sklearn.metrics import silhouette_score
|
||||
|
||||
scores = np.array([s for _, s in scored], dtype=float).reshape(-1, 1)
|
||||
kmeans = KMeans(n_clusters=2, random_state=42, n_init=10)
|
||||
labels = kmeans.fit_predict(scores)
|
||||
sil = silhouette_score(scores, labels)
|
||||
if sil < silhouette_threshold:
|
||||
log.info("Clustering: silhouette=%.3f < %.2f — keeping all %d docs", sil, silhouette_threshold, n)
|
||||
return scored
|
||||
centers = kmeans.cluster_centers_.flatten()
|
||||
good_label = int(np.argmax(centers))
|
||||
cutoff = int(np.sum(labels == good_label))
|
||||
cutoff = max(cutoff, 1)
|
||||
log.info("Clustering: silhouette=%.3f — cutting at %d/%d docs", sil, cutoff, n)
|
||||
return scored[:cutoff]
|
||||
except Exception as e:
|
||||
log.warning("Clustering failed (%s) — keeping all docs", e)
|
||||
return scored
|
||||
|
||||
|
||||
def rerank(query: str, scored: list[tuple[dict, float]], top_k: int) -> list[tuple[dict, float]]:
|
||||
"""ریرنک کراسانکودر BGE — مرتبسازی نهایی بر اساس شباهت واقعی پرسش/سند."""
|
||||
if not scored:
|
||||
return []
|
||||
if not settings.reranker_enabled:
|
||||
return scored[:top_k]
|
||||
|
||||
try:
|
||||
from .reranker import get_reranker
|
||||
|
||||
start = time.time()
|
||||
pairs = [[query, doc["text"]] for doc, _ in scored]
|
||||
scores = get_reranker().score(pairs)
|
||||
rescored = sorted(
|
||||
((doc, float(s)) for (doc, _), s in zip(scored, scores)),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)
|
||||
log.info("Reranked %d docs in %.2fs — top scores: %s",
|
||||
len(scored), time.time() - start,
|
||||
[f"{s:.2f}" for _, s in rescored[:3]])
|
||||
return rescored[:top_k]
|
||||
except Exception as e:
|
||||
log.error("Reranking failed (%s) — falling back to fusion order", e)
|
||||
return scored[:top_k]
|
||||
|
||||
|
||||
def retrieve_final(query: str, query_variants: list[str] | None = None) -> list[dict]:
|
||||
"""خط کامل بازیابی برای یک پرسش: هر نوع → فیوژن → برش خوشه → ریرنک → K نهایی."""
|
||||
variants = query_variants or [query]
|
||||
|
||||
pool: dict = {}
|
||||
for variant in variants:
|
||||
for doc, score in hybrid_retrieve(variant):
|
||||
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "score": 0.0})
|
||||
entry["score"] = max(entry["score"], score)
|
||||
|
||||
combined = sorted(((e["doc"], e["score"]) for e in pool.values()),
|
||||
key=lambda x: x[1], reverse=True)
|
||||
if not combined:
|
||||
return []
|
||||
|
||||
filtered = cluster_cutoff(combined)
|
||||
# ریرنک روی نتیجههای برشخورده (سقف retrieve_k تا تأخیر کنترل شود)
|
||||
rerank_candidates = filtered[: settings.retrieve_k]
|
||||
final = rerank(query, rerank_candidates, settings.best_of_k)
|
||||
|
||||
log.info("Final retrieval: %d docs (from %d candidates, %d variants)",
|
||||
len(final), len(combined), len(variants))
|
||||
return [doc for doc, _ in final]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""تستهای خالص (بدون مدل/دیتابیس) برای ماژولهای چتبات — اجرا: python test_pure.py"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
|
||||
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
|
||||
from chatbot.persian import normalize, tokenize
|
||||
|
||||
docs = [
|
||||
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
|
||||
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
|
||||
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
|
||||
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
|
||||
]
|
||||
chunks = chunk_documents(docs)
|
||||
ids = [c["doc_id"] for c in chunks]
|
||||
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
|
||||
|
||||
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
|
||||
assert faq_ids == ["faq:faq:0:1", "faq:faq:1:1"], faq_ids
|
||||
|
||||
# سندهایی که url یکتا دارند (پروژه/مقاله) نباید روی هم بیفتند — url بخشی از doc_id است
|
||||
two_projects = [
|
||||
{"text": "پروژه الف " + "ت" * 60, "type": "project", "title": "الف", "url": "/projects/alpha"},
|
||||
{"text": "پروژه ب " + "ت" * 60, "type": "project", "title": "ب", "url": "/projects/beta"},
|
||||
]
|
||||
pids = sorted(c["doc_id"] for c in chunk_documents(two_projects))
|
||||
assert pids == ["project:projects-alpha:0:1", "project:projects-beta:0:1"], pids
|
||||
|
||||
# تیتر سندهای تکقطعهای باید در متن امبدشده باشد تا جستوجو پیدایش کند
|
||||
faq_chunks = [c for c in chunks if c["type"] == "faq"]
|
||||
assert all(c["text"].startswith(c["title"]) for c in faq_chunks), [c["text"] for c in faq_chunks]
|
||||
|
||||
art = [c for c in chunks if c["type"] == "article"]
|
||||
assert len(art) >= 2 and art[1]["doc_id"] == "article:blog-long:0:2", [x["doc_id"] for x in art]
|
||||
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
|
||||
|
||||
c = chunks[0]
|
||||
assert c["content_hash"] == chunk_content_hash(c)
|
||||
c2 = dict(c, title="عنوان عوض شد")
|
||||
# hash فیلد کپیشده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
|
||||
assert chunk_content_hash(c2) != c["content_hash"]
|
||||
|
||||
chunks2 = chunk_documents(docs)
|
||||
assert [x["doc_id"] for x in chunks2] == ids
|
||||
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
|
||||
|
||||
settings_dsn = chatbot_dsn()
|
||||
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
|
||||
assert "+psycopg" not in settings_dsn
|
||||
|
||||
# نرمالسازی و توکنسازی فارسی
|
||||
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
|
||||
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
|
||||
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
|
||||
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
|
||||
|
||||
# استخراج متن TipTap و فرمت قدیمی
|
||||
tiptap_doc = {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
|
||||
{"type": "paragraph", "content": [
|
||||
{"type": "text", "text": "این برج در تهران "},
|
||||
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
|
||||
{"type": "hardBreak"},
|
||||
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
|
||||
]},
|
||||
{"type": "bulletList", "content": [
|
||||
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
|
||||
]},
|
||||
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
|
||||
],
|
||||
}
|
||||
blocks = tiptap_blocks(tiptap_doc)
|
||||
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
|
||||
assert body_to_text(["الف", "ب"]) == "الف\nب"
|
||||
assert body_to_text(None) == ""
|
||||
|
||||
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
|
||||
@@ -0,0 +1,288 @@
|
||||
"""ذخیرهساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
|
||||
|
||||
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
|
||||
نگهداری میشوند تا دیتابیس سایت فقطخواندنی بماند. همگامسازی افزایشی است:
|
||||
فقط سطرهای جدید/تغییریافته امبد میشوند (کلید: doc_id پایدار + hash محتوا).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _strip_driver(dsn: str) -> str:
|
||||
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def chatbot_dsn() -> str:
|
||||
"""DSN دیتابیس اختصاصی چتبات — از DATABASE_URL با تعویض نام دیتابیس ساخته میشود."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
|
||||
|
||||
|
||||
def _admin_dsn() -> str:
|
||||
"""DSN دیتابیس postgres برای ساخت دیتابیس چتبات (فقط یکبار)."""
|
||||
parts = urlsplit(_strip_driver(settings.database_url))
|
||||
return urlunsplit(parts._replace(path="/postgres"))
|
||||
|
||||
|
||||
def ensure_schema() -> None:
|
||||
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا میشود."""
|
||||
import psycopg
|
||||
from psycopg import errors as pg_errors
|
||||
|
||||
try:
|
||||
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
try:
|
||||
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
|
||||
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
|
||||
except pg_errors.DuplicateDatabase:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
|
||||
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS chatbot_chunks (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
embedding vector({settings.embedding_dim}) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
|
||||
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
# گزارش گفتگوها — حلقه بازخورد برای کشف پرسشهای بیپاسخ
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
thread_id TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
n_docs INTEGER NOT NULL DEFAULT 0,
|
||||
is_fallback BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS chat_logs_ts_idx ON chat_logs (ts DESC)")
|
||||
|
||||
|
||||
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
|
||||
"""همگامسازی افزایشی قطعات با جدول pgvector.
|
||||
|
||||
- سطرهایی که دیگر وجود ندارند حذف میشوند
|
||||
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert میشوند
|
||||
- force=True همه سطرها را از نو امبد میکند
|
||||
خروجی: آمار عملیات برای لاگ.
|
||||
"""
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
model = settings.embedding_model
|
||||
wanted = {c["doc_id"]: c for c in chunks}
|
||||
|
||||
# row_factory لازم است: پایینتر row["model"]/row["doc_id"] خوانده میشود و
|
||||
# پیشفرض psycopg3 تاپل برمیگرداند (خطای tuple indices must be integers).
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||
row_factory=dict_row) as conn:
|
||||
existing: dict[str, str] = {}
|
||||
if not force:
|
||||
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
|
||||
if row["model"] == model:
|
||||
existing[row["doc_id"]] = row["content_hash"]
|
||||
else:
|
||||
conn.execute("DELETE FROM chatbot_chunks")
|
||||
|
||||
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
|
||||
if to_delete:
|
||||
conn.execute(
|
||||
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
|
||||
[to_delete],
|
||||
)
|
||||
|
||||
to_upsert = [
|
||||
c for c in chunks
|
||||
if existing.get(c["doc_id"]) != c["content_hash"]
|
||||
]
|
||||
inserted = updated = 0
|
||||
|
||||
if to_upsert:
|
||||
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
|
||||
for chunk, vec in zip(to_upsert, vectors):
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s::vector)
|
||||
ON CONFLICT (doc_id) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
model = EXCLUDED.model,
|
||||
content = EXCLUDED.content,
|
||||
embedding = EXCLUDED.embedding
|
||||
""",
|
||||
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
|
||||
)
|
||||
if chunk["doc_id"] in existing:
|
||||
updated += 1
|
||||
else:
|
||||
inserted += 1
|
||||
|
||||
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
|
||||
"unchanged": len(wanted) - len(to_upsert)}
|
||||
log.info("Vector sync: %s", stats)
|
||||
return stats
|
||||
|
||||
|
||||
def fetch_all_chunks() -> list[dict]:
|
||||
"""همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
|
||||
row_factory=dict_row) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"doc_id": r["doc_id"],
|
||||
"content_hash": r["content_hash"],
|
||||
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def vector_count() -> int:
|
||||
"""تعداد بردارهای ذخیرهشده — برای healthz (خطا → ۰)."""
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def log_chat(thread_id: str, question: str, answer: str, n_docs: int,
|
||||
is_fallback: bool, latency_ms: int) -> None:
|
||||
"""ثبت یک گفتگو در chat_logs — هرگز خطا نمیدهد (لاگ نباید جریان پاسخ را بشکند)."""
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_logs (thread_id, question, answer, n_docs, is_fallback, latency_ms)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(thread_id, question, answer[:4000], n_docs, is_fallback, latency_ms),
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("chat_logs insert failed: %s", e)
|
||||
|
||||
|
||||
def chat_stats(days: int = 7) -> dict:
|
||||
"""آمار تجمعی گفتگوها — بدون متن پرسشها (متنها فقط با توکن در /chat/stats)."""
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
|
||||
total = int(conn.execute("SELECT count(*) FROM chat_logs").fetchone()[0])
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT count(*), count(*) FILTER (WHERE is_fallback)
|
||||
FROM chat_logs WHERE ts > now() - (%s || ' days')::interval
|
||||
""",
|
||||
(str(days),),
|
||||
).fetchone()
|
||||
recent_fallbacks: list[str] = []
|
||||
if row and row[1]:
|
||||
recent_fallbacks = [
|
||||
r[0] for r in conn.execute(
|
||||
"""
|
||||
SELECT question FROM chat_logs
|
||||
WHERE is_fallback AND ts > now() - (%s || ' days')::interval
|
||||
ORDER BY ts DESC LIMIT 20
|
||||
""",
|
||||
(str(days),),
|
||||
).fetchall()
|
||||
]
|
||||
return {
|
||||
"total_logged": total,
|
||||
"last_7d": int(row[0]) if row else 0,
|
||||
"fallback_7d": int(row[1]) if row else 0,
|
||||
"recent_fallback_questions": recent_fallbacks,
|
||||
}
|
||||
except Exception as e:
|
||||
log.warning("chat_stats failed: %s", e)
|
||||
return {"total_logged": 0, "last_7d": 0, "fallback_7d": 0, "recent_fallback_questions": []}
|
||||
|
||||
|
||||
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
|
||||
"""جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||
|
||||
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
|
||||
"""
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row as _dict_row
|
||||
|
||||
from .embeddings import get_embeddings
|
||||
|
||||
vec = get_embeddings().embed_query(query)
|
||||
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
|
||||
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
|
||||
FROM chatbot_chunks
|
||||
ORDER BY embedding <=> %s::vector
|
||||
LIMIT %s
|
||||
""",
|
||||
(literal, literal, top_k),
|
||||
).fetchall()
|
||||
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
|
||||
except Exception as e:
|
||||
log.warning("Dense search failed (%s) — falling back to keyword only", e)
|
||||
return []
|
||||
|
||||
|
||||
def _row_to_doc(row: Any) -> dict:
|
||||
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"text": content.get("text", ""),
|
||||
"type": content.get("type", ""),
|
||||
"title": content.get("title", ""),
|
||||
"url": content.get("url", ""),
|
||||
}
|
||||
|
||||
|
||||
def chunk_content_hash(chunk: dict) -> str:
|
||||
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید بهروز شود."""
|
||||
payload = json.dumps(
|
||||
{k: chunk[k] for k in ("text", "type", "title", "url")},
|
||||
ensure_ascii=False, sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
@@ -0,0 +1,189 @@
|
||||
# لایه مانیتورینگ — Prometheus، Grafana، Loki، Alertmanager و صادرکنندهها
|
||||
#
|
||||
# توسعه: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||
# تولید: سه فایل با هم: base + monitoring + prod (بخش مانیتورینگ README)
|
||||
#
|
||||
# داشبورد: grafana.localhost (توسعه) — رمز از GRAFANA_ADMIN_PASSWORD در .env
|
||||
# هشدارها: Prometheus → Alertmanager → تلگرام (ALERT_TELEGRAM_* در .env)
|
||||
# منابع: مجموعاً حدود ۱ تا ۱.۵ گیگابایت RAM — با حذف هر سرویس، بقیه سرپا میمانند.
|
||||
|
||||
x-default-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.53.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=15d
|
||||
- --storage.tsdb.retention.size=2GB
|
||||
volumes:
|
||||
- ./monitoring/prometheus:/etc/prometheus:ro
|
||||
- prometheus_data:/prometheus
|
||||
networks: [monitoring, edge]
|
||||
environment:
|
||||
# پروب عمومی — در تولید SITE_URL در .env تنظیم میشود (مثل https://aramesh.example)
|
||||
SITE_URL: ${SITE_URL:-http://frontend/}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --config.file=/etc/alertmanager/alertmanager.rendered.yml
|
||||
- --storage.path=/alertmanager
|
||||
# فایل رندرشدهٔ alertmanager.rendered.yml را scripts/stack.sh از قالب
|
||||
# alertmanager.yml با مقادیر .env (ALERT_BOT_TOKEN/ALERT_CHAT_ID) میسازد —
|
||||
# Alertmanager خودش متغیر محیطی را در کانفیگ جایگزین نمیکند
|
||||
volumes:
|
||||
- ./monitoring/alertmanager:/etc/alertmanager:ro
|
||||
- alertmanager_data:/alertmanager
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
# منابع هر کانتینر — روی Linux سرور کامل کار میکند؛ روی Docker Desktop ویندوز
|
||||
# ممکن است محدود باشد (حذفش بقیه استک را نمیشکند)
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
privileged: true
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 192M
|
||||
|
||||
# منابع میزبان + سنجهٔ تازگی پشتیبان (backups/*.prom نوشتهٔ scripts/backup.sh)
|
||||
node-exporter:
|
||||
image: quay.io/prometheus/node-exporter:v1.8.2
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --path.rootfs=/host
|
||||
- --collector.textfile.directory=/textfile
|
||||
volumes:
|
||||
- /:/host:ro
|
||||
- ./backups:/textfile:ro
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
postgres-exporter:
|
||||
image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATA_SOURCE_NAME: postgresql://aramesh:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}@db:5432/arameshplaza?sslmode=disable
|
||||
networks: [internal, monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:v0.25.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
# edge برای رسیدن به سرویسهایی که پروب میشوند (frontend/minio/umami/chatbot/backend)
|
||||
networks: [monitoring, edge]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# لاگ همهٔ کانتینرها — جستوجو در Grafana؛ خطاهای بکاند از همینجا دیده میشوند
|
||||
loki:
|
||||
image: grafana/loki:3.1.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: -config.file=/etc/loki/loki-config.yml
|
||||
volumes:
|
||||
- ./monitoring/loki:/etc/loki:ro
|
||||
- loki_data:/loki
|
||||
networks: [monitoring]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:3.1.1
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: -config.file=/etc/promtail/promtail-config.yml
|
||||
volumes:
|
||||
- ./monitoring/promtail:/etc/promtail:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- promtail_positions:/positions
|
||||
networks: [monitoring]
|
||||
depends_on: [loki]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.1.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD را در .env تنظیم کنید}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
networks: [monitoring, edge]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || wget -q --spider http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.grafana.rule=Host(`grafana.localhost`)
|
||||
- traefik.http.routers.grafana.entrypoints=web
|
||||
- traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
alertmanager_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
promtail_positions:
|
||||
@@ -1,15 +1,28 @@
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۴۳.
|
||||
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۳.
|
||||
# پیشنیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ بهعلاوه رکورد DNS دامنه به این سرور.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
# bash scripts/stack.sh deploy-prod
|
||||
# (معادل: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build)
|
||||
#
|
||||
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
||||
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
|
||||
#
|
||||
# ---- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ----
|
||||
# در حالت پیشنمایش (PREVIEW_MODE=on) کل سایت پشت Basic Auth است و noindex میگیرد:
|
||||
# تیم با یک نام کاربری/رمز مشترک وارد میشود، محتوا را ویرایش میکند و هیچکس دیگری —
|
||||
# از جمله گوگل — به سایت دسترسی ندارد. برای انتشار عمومی:
|
||||
# bash scripts/preview-mode.sh off → deploy مجدد → ثبت sitemap در Search Console.
|
||||
#
|
||||
# DOMAIN دامنه اصلی است و DOMAIN_ALIAS دامنه دوم (هر دو مسیر و گواهی میگیرند).
|
||||
# در زمان انتشار، برای جلوگیری از محتوای تکراری، دامنه دوم را به اصلی ریدایرکت کنید:
|
||||
# ALIAS_REDIRECT=on (در .env) — ریدایرکت ۳۰۱ دائمی به دامنه اصلی.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
command: !override
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
@@ -18,6 +31,9 @@ services:
|
||||
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.le.acme.httpchallenge=true
|
||||
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
|
||||
- --ping=true
|
||||
- --accesslog=true
|
||||
- --metrics.prometheus=true
|
||||
ports: !override
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -25,26 +41,42 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt_data:/letsencrypt
|
||||
|
||||
backend:
|
||||
environment:
|
||||
# سایت هممبدأ است — CORS لازم نیست
|
||||
CORS_ORIGINS: '[]'
|
||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=websecure
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
|
||||
# MinIO کنسول (رمز root) و API مستقیمش فقط از localhost سرور در دسترس باشند —
|
||||
# از راه دور با تونل SSH: ssh -L 9001:127.0.0.1:9001 server
|
||||
minio:
|
||||
ports: !override
|
||||
- "127.0.0.1:9001:9001"
|
||||
- "127.0.0.1:19000:9000"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=websecure
|
||||
- traefik.http.routers.media.tls.certresolver=le
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.routers.media.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}media-rewrite,sec-headers
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
|
||||
backend:
|
||||
environment:
|
||||
# سایت هممبدأ است — CORS لازم نیست
|
||||
CORS_ORIGINS: '[]'
|
||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||
# پیشنمایش خصوصی — هدر noindex + robots مسدودکننده (لایه دوم؛ لایه اول گیت Traefik)
|
||||
PREVIEW_MODE: ${PREVIEW_MODE:-off}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=websecure
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||
# سئو: همهٔ صفحات عمومی از بکاند سرو میشوند تا متا در HTML اولیه تزریق شود —
|
||||
# مسیرهای استاتیک دقیقاند (Path) تا assets/ فونتها از Caddy سرو شوند
|
||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`))
|
||||
- traefik.http.routers.seo.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||
|
||||
frontend:
|
||||
labels:
|
||||
@@ -52,6 +84,58 @@ services:
|
||||
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||
- traefik.http.routers.web.entrypoints=websecure
|
||||
- traefik.http.routers.web.tls.certresolver=le
|
||||
- traefik.http.routers.web.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||
# تعریف یکبارهٔ میانافزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده میشود
|
||||
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
|
||||
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
|
||||
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
|
||||
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
|
||||
# ---- گیت پیشنمایش خصوصی ----
|
||||
# ورود با نام کاربری/رمز مشترک (htpasswd) — تا وقتی PREVIEW_MIDDLEWARES تنظیم است فعال میشود
|
||||
- traefik.http.middlewares.preview-auth.basicauth.users=${PREVIEW_AUTH_USERS:-disabled:nope}
|
||||
- traefik.http.middlewares.preview-auth.basicauth.realm=Aramesh private preview
|
||||
# لایه دوم جلوگیری از ایندکس — حتی اگر گیت حذف شد، گوگل چیزی ذخیره نکند
|
||||
- traefik.http.middlewares.preview-noindex.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow, noarchive
|
||||
# ---- ریدایرکت دامنه دوم به اصلی (زمان انتشار: ALIAS_REDIRECT=on) ----
|
||||
- traefik.http.middlewares.to-primary.redirectregex.regex=^https?://[^/]+(.*)
|
||||
- traefik.http.middlewares.to-primary.redirectregex.replacement=https://${DOMAIN}$$1
|
||||
- traefik.http.middlewares.to-primary.redirectregex.permanent=true
|
||||
- traefik.http.routers.alias-redirect.rule=Host(`${DOMAIN_ALIAS:-invalid.invalid}`)
|
||||
- traefik.http.routers.alias-redirect.entrypoints=websecure
|
||||
- traefik.http.routers.alias-redirect.tls.certresolver=le
|
||||
- traefik.http.routers.alias-redirect.priority=1000
|
||||
- traefik.http.routers.alias-redirect.middlewares=${ALIAS_REDIRECT_MIDDLEWARES:-sec-headers}
|
||||
- traefik.http.routers.alias-redirect.service=web
|
||||
|
||||
chatbot:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.chat.rule=Host(`${DOMAIN}`) && PathPrefix(`/chat`)
|
||||
- traefik.http.routers.chat.entrypoints=websecure
|
||||
- traefik.http.routers.chat.tls.certresolver=le
|
||||
- traefik.http.routers.chat.priority=100
|
||||
- traefik.http.routers.chat.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||
|
||||
umami:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.analytics.rule=Host(`analytics.${DOMAIN}`)
|
||||
- traefik.http.routers.analytics.entrypoints=websecure
|
||||
- traefik.http.routers.analytics.tls.certresolver=le
|
||||
- traefik.http.routers.analytics.middlewares=sec-headers
|
||||
- traefik.http.services.analytics.loadbalancer.server.port=3000
|
||||
|
||||
# داشبورد Grafana (docker-compose.monitoring.yml) در تولید با TLS نیاز به روتر
|
||||
# Host(`grafana.DOMAIN`) دارد — چون آن سرویس فقط با فایل monitoring بالا میآید،
|
||||
# برچسبهایش را در override سرور بگذارید (README، بخش مانیتورینگ):
|
||||
# docker-compose.override.yml روی سرور:
|
||||
# services:
|
||||
# grafana:
|
||||
# labels:
|
||||
# - traefik.http.routers.grafana.rule=Host(`grafana.YOURDOMAIN`)
|
||||
# - traefik.http.routers.grafana.entrypoints=websecure
|
||||
# - traefik.http.routers.grafana.tls.certresolver=le
|
||||
# - traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||
|
||||
volumes:
|
||||
letsencrypt_data:
|
||||
|
||||
@@ -1,71 +1,121 @@
|
||||
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
|
||||
# cp .env.example .env
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
|
||||
# cp .env.example .env (یا: bash scripts/gen-env.sh)
|
||||
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml هم سوار شود (README):
|
||||
# bash scripts/stack.sh deploy-prod
|
||||
#
|
||||
# لایههای اختیاری:
|
||||
# docker-compose.monitoring.yml — Prometheus/Grafana/Loki (پروفایل زیرساخت)
|
||||
# پروفایل ops — پشتیبان ساعتی دیتابیس
|
||||
# پروفایل tools — ابزار mc برای پشتیبانگیری دستی
|
||||
|
||||
x-default-logging: &default-logging
|
||||
# بدون این، لاگ json-file بیمرز رشد میکند و دیسک سرور را پر میکند
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
|
||||
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند)، /media (MinIO)،
|
||||
# /chat (چتبات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
|
||||
traefik:
|
||||
image: traefik:v3
|
||||
# v3.3+ قدیمیترین کلاینت داکر را دارد که با Engineهای جدید (API 1.5x) مشکل دارد —
|
||||
# با v3.6 پین شده است
|
||||
image: traefik:v3.6
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --entrypoints.web.address=:80
|
||||
# healthcheck داخلی + متریکها برای Prometheus + لاگ دسترسی
|
||||
- --ping=true
|
||||
- --accesslog=true
|
||||
- --metrics.prometheus=true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks: [edge]
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "healthcheck", "--ping"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
# pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چتبات
|
||||
image: pgvector/pgvector:pg17
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
POSTGRES_DB: arameshplaza
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
shm_size: 256m
|
||||
networks: [internal]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks: [edge, internal]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
|
||||
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو میشوند)
|
||||
# در تولید، docker-compose.prod.yml اینها را فقط روی 127.0.0.1 میبندد —
|
||||
# کنسول رمز root دارد و نباید به اینترنت باز باشد
|
||||
- "9001:9001"
|
||||
- "19000:9000"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.media.rule=PathPrefix(`/media`)
|
||||
- traefik.http.routers.media.entrypoints=web
|
||||
- traefik.http.routers.media.priority=100
|
||||
- traefik.http.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||
- traefik.http.routers.media.middlewares=media-rewrite
|
||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed.
|
||||
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبتاند) روی یک حجم تازه
|
||||
# وارد باکت میشوند تا هر نسخه تازهکلون/بیلد، همان عکسها را داشته باشد؛
|
||||
# روی حجم موجود، mc cp فایلهای تکراری را رد میکند و دادهتازه را نمیخورد.
|
||||
# یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed
|
||||
# فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
|
||||
minio-setup:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
@@ -76,19 +126,51 @@ services:
|
||||
volumes:
|
||||
- ./seed-media:/seed-media:ro
|
||||
restart: "no"
|
||||
networks: [internal]
|
||||
logging: *default-logging
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
|
||||
mc mb --ignore-existing local/$$MINIO_BUCKET &&
|
||||
mc anonymous set download local/$$MINIO_BUCKET &&
|
||||
echo 'minio-setup: bucket ready' &&
|
||||
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
|
||||
echo 'minio-setup: seed media mirrored'
|
||||
COUNT=$$(mc ls --recursive local/$$MINIO_BUCKET | wc -l) &&
|
||||
if [ \"$$COUNT\" -eq 0 ]; then
|
||||
echo 'minio-setup: bucket ready — mirroring seed media';
|
||||
mc mirror --overwrite /seed-media local/$$MINIO_BUCKET/;
|
||||
else
|
||||
echo \"minio-setup: bucket ready — $$COUNT object(s) already present, seed mirror skipped\";
|
||||
fi
|
||||
"
|
||||
|
||||
# یکبار اجرا میشود: دیتابیسهای جانبی روی همان نمونهٔ Postgres
|
||||
# (umami برای آنالیتیکس، aramesh_chatbot برای بردارهای چتبات)
|
||||
db-init:
|
||||
image: postgres:17-alpine
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: aramesh
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
networks: [internal]
|
||||
logging: *default-logging
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
set -e;
|
||||
for DBN in umami aramesh_chatbot; do
|
||||
if ! psql -d arameshplaza -tAc \"SELECT 1 FROM pg_database WHERE datname='$$DBN'\" | grep -q 1; then
|
||||
psql -d arameshplaza -c \"CREATE DATABASE $$DBN\";
|
||||
fi;
|
||||
echo \"db-init: $$DBN ready\";
|
||||
done
|
||||
"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
|
||||
@@ -106,11 +188,14 @@ services:
|
||||
FRONTEND_DIST: /srv/frontend
|
||||
volumes:
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 15s
|
||||
@@ -119,9 +204,17 @@ services:
|
||||
start_period: 20s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=web
|
||||
- traefik.http.routers.api.priority=100
|
||||
# لایه دوم محافظت از فرمها — سیلاب درخواست را پیش از رسیدن به بکاند میبُرد؛
|
||||
# محدودسازی دقیق هر فرم در خود بکاند است (rate_limit.py)
|
||||
- traefik.http.routers.api.middlewares=api-flood
|
||||
- traefik.http.middlewares.api-flood.ratelimit.average=50
|
||||
- traefik.http.middlewares.api-flood.ratelimit.period=1s
|
||||
- traefik.http.middlewares.api-flood.ratelimit.burst=150
|
||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
||||
@@ -129,29 +222,111 @@ services:
|
||||
- traefik.http.routers.seo.entrypoints=web
|
||||
- traefik.http.routers.seo.priority=50
|
||||
- traefik.http.routers.seo.service=api
|
||||
# /metrics فقط برای Prometheus — روتری تعریف نمیشود، از بیرون در دسترس نیست
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
|
||||
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- backend
|
||||
- minio
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks: [edge]
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.web.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.web.entrypoints=web
|
||||
- traefik.http.routers.web.priority=1
|
||||
- traefik.http.services.web.loadbalancer.server.port=80
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# دستیار هوشمند — RAG روی محتوای سایت با pgvector
|
||||
# اولین اجرا مدلها را دانلود میکند (~۵۰۰MB روی حجم chatbot_hf) — کند است، طبیعی است
|
||||
chatbot:
|
||||
build: ./chatbot
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-openai/gpt-oss-120b}
|
||||
CHAT_REINDEX_TOKEN: ${CHAT_REINDEX_TOKEN:-}
|
||||
volumes:
|
||||
- chatbot_hf:/data
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.chat.rule=PathPrefix(`/chat`)
|
||||
- traefik.http.routers.chat.entrypoints=web
|
||||
- traefik.http.routers.chat.priority=100
|
||||
- traefik.http.routers.chat.middlewares=api-flood
|
||||
- traefik.http.services.chat.loadbalancer.server.port=8000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
|
||||
# آنالیتیکس محصول (Umami) — بازدید، ارجاع، رخدادهای سفارشی (فرم/تلفن/چتبات)
|
||||
# داشبورد: analytics.localhost (توسعه) یا analytics.DOMAIN (تولید)
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-v2.17.0
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
DATABASE_URL: postgresql://aramesh:${POSTGRES_PASSWORD}@db:5432/umami
|
||||
# اسکریپت ردیاب به ارجاعدهندهٔ واقعی نیاز دارد — پشت Traefik
|
||||
CLIENT_IP_HEADER: x-forwarded-for
|
||||
DISABLE_TELEMETRY: 1
|
||||
networks: [edge, internal]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
# 127.0.0.1 نه localhost — «localhost» به ::1 حل میشود و umami فقط IPv4 گوش میدهد
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/heartbeat || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Traefik باید IP لبه را بگیرد — سرویس روی دو شبکه است
|
||||
- traefik.docker.network=aramesh-backend_edge
|
||||
- traefik.http.routers.analytics.rule=Host(`analytics.localhost`)
|
||||
- traefik.http.routers.analytics.entrypoints=web
|
||||
- traefik.http.services.analytics.loadbalancer.server.port=3000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
# ابزار پشتیبانگیری — فقط با پروفایل tools اجرا میشود (scripts/backup.sh این کار را میکند)
|
||||
mc:
|
||||
image: minio/mc:latest
|
||||
image: minio/mc:RELEASE.2025-04-08T15-39-49Z
|
||||
profiles: ["tools"]
|
||||
depends_on:
|
||||
minio:
|
||||
@@ -159,7 +334,42 @@ services:
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
|
||||
networks: [internal]
|
||||
|
||||
# پشتیبان ساعتی خودکار دیتابیس — بدون نیاز به cron میزبان؛ فقط با پروفایل ops
|
||||
# docker compose --profile ops up -d db-backup
|
||||
# RPO را از ۲۴ ساعت (cron شبانه) به ۱ ساعت میرساند؛ scripts/backup.sh برای
|
||||
# پشتیبان کامل (دیتابیس + تصاویر MinIO) سر جایش است.
|
||||
db-backup:
|
||||
image: prodrigestivill/postgres-backup-local:17
|
||||
profiles: ["ops"]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_DB: arameshplaza
|
||||
POSTGRES_USER: aramesh
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
|
||||
SCHEDULE: "@hourly"
|
||||
BACKUP_NUM_KEEP: "48"
|
||||
BACKUP_DIR: /backups
|
||||
volumes:
|
||||
- backups:/backups
|
||||
networks: [internal]
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
networks:
|
||||
edge: # سرویسهایی که Traefik به آنها مسیریابی میکند
|
||||
internal: # دیتابیس و ذخیرهسازی — فرانتاند راهی به این شبکه ندارد
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
minio_data:
|
||||
chatbot_hf:
|
||||
backups:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# فقط Caddyfile و dist/ به کانتکست بیلد میروند — node_modules و سورس عظیماند و لازم نیستند
|
||||
*
|
||||
!Caddyfile
|
||||
!dist
|
||||
@@ -1,12 +1,29 @@
|
||||
{
|
||||
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
|
||||
admin off
|
||||
}
|
||||
|
||||
:80 {
|
||||
root * /srv
|
||||
encode gzip
|
||||
encode zstd gzip
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# کش بلندمدت برای فایلهای هشدار بیلد
|
||||
# فایلهای هشدار بیلد: کش بلندمدت بیرحم — ولی هرگز fallback به index.html نداشته باشند؛
|
||||
# پاسخ 200ٔ HTML برای یک مسیر /assets/* نبوده (مثلاً هنگام پنجرهٔ تعویض کانتینر در استقرار)
|
||||
# توسط CDN (WCDN، سیاست SMART) با همین هدر immutable یک سال کش میشود و اسکریپت ماژول
|
||||
# را برای همه بازدیدکنندهها میشکند. فایل ناموجود در assets باید 404 بدهد، نه HTML.
|
||||
@assets path /assets/*
|
||||
handle @assets {
|
||||
file_server
|
||||
}
|
||||
|
||||
# مسیریابی SPA — مسیرهای ناشناختهٔ غیرasset به index.html برمیگردند
|
||||
handle {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||
@notassets not path /assets/*
|
||||
header @notassets Cache-Control "no-cache"
|
||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
# نسخهٔ alpine کاربر caddy ندارد — اینجا ساخته میشود؛ /config و /data مسیرهای
|
||||
# storage سیدی هستند و باید برای کاربر نوشتنی باشند
|
||||
RUN addgroup -S caddy \
|
||||
&& adduser -S -G caddy -s /sbin/nologin caddy \
|
||||
&& mkdir -p /config /data \
|
||||
&& chown -R caddy:caddy /config /data
|
||||
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY dist /srv
|
||||
COPY --chown=caddy:caddy dist /srv
|
||||
|
||||
# بدون root — فقط فایلهای استاتیک سرو میشوند و TLS را Traefik تمام میکند
|
||||
USER caddy
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
|
||||
|
||||
# Aramesh Construction Group — Frontend
|
||||
|
||||
A React + Vite + Tailwind CSS application. All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||
|
||||
## Typography
|
||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering.
|
||||
## Typography & Smart Bidi Text
|
||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||
|
||||
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||
|
||||
## Recent UI/UX Improvements
|
||||
* **Projects Map Restoration**: Successfully re-integrated and mounted the interactive project overview map directly at the top of the Projects page to display all project markers.
|
||||
* **Project Details Update**: Revamped and updated the presentation of project details for better clarity and structure.
|
||||
* **Stats Section Relocation**: Moved the statistics (metrics) section higher up in the page layout for better visibility and impact.
|
||||
* **FAQ Section Redesign**: Refactored the FAQ layout into a centered vertical stack with full RTL/LTR responsiveness, featuring an integrated centered header block (title, description, and accent line) and direction-aware (`text-start`) accordion content.
|
||||
* **Newsletter Section Polish**: Updated the newsletter container to a clean solid `bg-copper` background while introducing high-contrast button styling (`bg-ink` default transitioning to white on hover) preserving site-wide kinetic animation logic.
|
||||
* **Article Cards Spacing**: Refined vertical layout spacing and responsive gaps between title, excerpt, and footer blocks without impacting typography scales.
|
||||
* **Tiptap Editor Fixes**:
|
||||
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||
* **Dynamic TOC & Layout**:
|
||||
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||
|
||||
## CMS, SEO & Editor Features
|
||||
|
||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||
|
||||
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||
* **Custom Tiptap WYSIWYG Editor:**
|
||||
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||
|
||||
## Development Setup
|
||||
|
||||
@@ -36,3 +67,10 @@ npm run build
|
||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
|
||||
```
|
||||
|
||||
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، میتوانید از این کامیت یکخطی استفاده کنید:
|
||||
|
||||
```bash
|
||||
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- پیشبارگذاری فونتهای اصلی — بدون این، swap فونت با تأخیر میآید (LCP) -->
|
||||
<link rel="preload" href="/fonts/pinar/Pinar-Light.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/fonts/pinar/Pinar-Regular.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/fonts/pinar/Pinar-Bold.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<title>گروه ساختمانی آرامش</title>
|
||||
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژههای ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایهگذاری ساختوساز." />
|
||||
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
|
||||
@@ -24,6 +28,16 @@
|
||||
document.documentElement.setAttribute('dir', 'ltr');
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
// تحلیل رفتار (Umami) — در زمان بیلد تزریق میشود؛ VITE_UMAMI_SRC خالی یعنی خاموش
|
||||
if (import.meta.env.VITE_UMAMI_SRC) {
|
||||
const s = document.createElement('script');
|
||||
s.defer = true;
|
||||
s.src = import.meta.env.VITE_UMAMI_SRC;
|
||||
s.setAttribute('data-website-id', import.meta.env.VITE_UMAMI_WEBSITE_ID ?? '');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,15 +11,26 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/extension-table": "^3.31.3",
|
||||
"@tiptap/extension-table-cell": "^3.31.3",
|
||||
"@tiptap/extension-table-header": "^3.31.3",
|
||||
"@tiptap/extension-table-row": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -933,7 +944,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
@@ -943,7 +953,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
@@ -953,8 +962,7 @@
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
@@ -1040,6 +1048,92 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -1776,6 +1870,59 @@
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz",
|
||||
"integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-cell": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.31.3.tgz",
|
||||
"integrity": "sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-header": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.31.3.tgz",
|
||||
"integrity": "sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-row": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.31.3.tgz",
|
||||
"integrity": "sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-table": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||
@@ -1972,6 +2119,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2020,6 +2173,15 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
@@ -2440,6 +2602,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -2763,6 +2934,22 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -3137,6 +3324,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -3290,6 +3483,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -3617,6 +3816,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -3630,6 +3835,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -3733,6 +3944,45 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
|
||||
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||
"@maplibre/mlt": "^1.3.0",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -3770,6 +4020,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
@@ -3791,6 +4050,12 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -3860,7 +4125,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4017,6 +4281,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4056,6 +4332,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pmtiles": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
|
||||
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -4228,6 +4513,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -4238,6 +4529,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
|
||||
@@ -4377,6 +4679,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4408,6 +4716,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -4420,6 +4734,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -4433,12 +4753,61 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.88.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4481,6 +4850,43 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.0",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.8.1",
|
||||
"@floating-ui/dom": "^1.0.1",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"use-isomorphic-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -4504,6 +4910,15 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -4534,6 +4949,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -4693,6 +5117,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/slugify": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -4918,6 +5351,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -5055,6 +5494,20 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-isomorphic-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npx vite",
|
||||
"build": "npx vite build",
|
||||
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"preview": "npx vite preview"
|
||||
},
|
||||
@@ -13,15 +14,26 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@tiptap/extension-image": "^3.31.3",
|
||||
"@tiptap/extension-link": "^3.31.3",
|
||||
"@tiptap/extension-table": "^3.31.3",
|
||||
"@tiptap/extension-table-cell": "^3.31.3",
|
||||
"@tiptap/extension-table-header": "^3.31.3",
|
||||
"@tiptap/extension-table-row": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
"slugify": "^1.6.9",
|
||||
"sweetalert2": "^11.26.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
// node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
var fleb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
/* unused */
|
||||
0,
|
||||
0,
|
||||
/* impossible */
|
||||
0
|
||||
]);
|
||||
var fdeb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
8,
|
||||
8,
|
||||
9,
|
||||
9,
|
||||
10,
|
||||
10,
|
||||
11,
|
||||
11,
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
13,
|
||||
/* unused */
|
||||
0,
|
||||
0
|
||||
]);
|
||||
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||
var freb = function(eb, start) {
|
||||
var b2 = new u16(31);
|
||||
for (var i = 0; i < 31; ++i) {
|
||||
b2[i] = start += 1 << eb[i - 1];
|
||||
}
|
||||
var r = new i32(b2[30]);
|
||||
for (var i = 1; i < 30; ++i) {
|
||||
for (var j2 = b2[i]; j2 < b2[i + 1]; ++j2) {
|
||||
r[j2] = j2 - b2[i] << 5 | i;
|
||||
}
|
||||
}
|
||||
return { b: b2, r };
|
||||
};
|
||||
var _a = freb(fleb, 2);
|
||||
var fl = _a.b;
|
||||
var revfl = _a.r;
|
||||
fl[28] = 258, revfl[258] = 28;
|
||||
var _b = freb(fdeb, 0);
|
||||
var fd = _b.b;
|
||||
var revfd = _b.r;
|
||||
var rev = new u16(32768);
|
||||
for (i = 0; i < 32768; ++i) {
|
||||
x2 = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||
x2 = (x2 & 52428) >> 2 | (x2 & 13107) << 2;
|
||||
x2 = (x2 & 61680) >> 4 | (x2 & 3855) << 4;
|
||||
rev[i] = ((x2 & 65280) >> 8 | (x2 & 255) << 8) >> 1;
|
||||
}
|
||||
var x2;
|
||||
var i;
|
||||
var hMap = function(cd, mb, r) {
|
||||
var s = cd.length;
|
||||
var i = 0;
|
||||
var l = new u16(mb);
|
||||
for (; i < s; ++i) {
|
||||
if (cd[i])
|
||||
++l[cd[i] - 1];
|
||||
}
|
||||
var le = new u16(mb);
|
||||
for (i = 1; i < mb; ++i) {
|
||||
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||
}
|
||||
var co;
|
||||
if (r) {
|
||||
co = new u16(1 << mb);
|
||||
var rvb = 15 - mb;
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
var sv = i << 4 | cd[i];
|
||||
var r_1 = mb - cd[i];
|
||||
var v2 = le[cd[i] - 1]++ << r_1;
|
||||
for (var m = v2 | (1 << r_1) - 1; v2 <= m; ++v2) {
|
||||
co[rev[v2] >> rvb] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
co = new u16(s);
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return co;
|
||||
};
|
||||
var flt = new u8(288);
|
||||
for (i = 0; i < 144; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
for (i = 144; i < 256; ++i)
|
||||
flt[i] = 9;
|
||||
var i;
|
||||
for (i = 256; i < 280; ++i)
|
||||
flt[i] = 7;
|
||||
var i;
|
||||
for (i = 280; i < 288; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
var fdt = new u8(32);
|
||||
for (i = 0; i < 32; ++i)
|
||||
fdt[i] = 5;
|
||||
var i;
|
||||
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||
var max = function(a) {
|
||||
var m = a[0];
|
||||
for (var i = 1; i < a.length; ++i) {
|
||||
if (a[i] > m)
|
||||
m = a[i];
|
||||
}
|
||||
return m;
|
||||
};
|
||||
var bits = function(d2, p, m) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8) >> (p & 7) & m;
|
||||
};
|
||||
var bits16 = function(d2, p) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8 | d2[o + 2] << 16) >> (p & 7);
|
||||
};
|
||||
var shft = function(p) {
|
||||
return (p + 7) / 8 | 0;
|
||||
};
|
||||
var slc = function(v2, s, e) {
|
||||
if (s == null || s < 0)
|
||||
s = 0;
|
||||
if (e == null || e > v2.length)
|
||||
e = v2.length;
|
||||
return new u8(v2.subarray(s, e));
|
||||
};
|
||||
var ec = [
|
||||
"unexpected EOF",
|
||||
"invalid block type",
|
||||
"invalid length/literal",
|
||||
"invalid distance",
|
||||
"stream finished",
|
||||
"no stream handler",
|
||||
,
|
||||
// determined by compression function
|
||||
"no callback",
|
||||
"invalid UTF-8 data",
|
||||
"extra field too long",
|
||||
"date not in range 1980-2099",
|
||||
"filename too long",
|
||||
"stream finishing",
|
||||
"invalid zip data"
|
||||
// determined by unknown compression method
|
||||
];
|
||||
var err = function(ind, msg, nt) {
|
||||
var e = new Error(msg || ec[ind]);
|
||||
e.code = ind;
|
||||
if (Error.captureStackTrace)
|
||||
Error.captureStackTrace(e, err);
|
||||
if (!nt)
|
||||
throw e;
|
||||
return e;
|
||||
};
|
||||
var inflt = function(dat, st, buf, dict) {
|
||||
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||
if (!sl || st.f && !st.l)
|
||||
return buf || new u8(0);
|
||||
var noBuf = !buf;
|
||||
var resize = noBuf || st.i != 2;
|
||||
var noSt = st.i;
|
||||
if (noBuf)
|
||||
buf = new u8(sl * 3);
|
||||
var cbuf = function(l2) {
|
||||
var bl = buf.length;
|
||||
if (l2 > bl) {
|
||||
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||
nbuf.set(buf);
|
||||
buf = nbuf;
|
||||
}
|
||||
};
|
||||
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||
var tbts = sl * 8;
|
||||
do {
|
||||
if (!lm) {
|
||||
final = bits(dat, pos, 1);
|
||||
var type = bits(dat, pos + 1, 3);
|
||||
pos += 3;
|
||||
if (!type) {
|
||||
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||
if (t > sl) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + l);
|
||||
buf.set(dat.subarray(s, t), bt);
|
||||
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||
continue;
|
||||
} else if (type == 1)
|
||||
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||
else if (type == 2) {
|
||||
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||
pos += 14;
|
||||
var ldt = new u8(tl);
|
||||
var clt = new u8(19);
|
||||
for (var i = 0; i < hcLen; ++i) {
|
||||
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||
}
|
||||
pos += hcLen * 3;
|
||||
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||
var clm = hMap(clt, clb, 1);
|
||||
for (var i = 0; i < tl; ) {
|
||||
var r = clm[bits(dat, pos, clbmsk)];
|
||||
pos += r & 15;
|
||||
var s = r >> 4;
|
||||
if (s < 16) {
|
||||
ldt[i++] = s;
|
||||
} else {
|
||||
var c = 0, n = 0;
|
||||
if (s == 16)
|
||||
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||
else if (s == 17)
|
||||
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||
else if (s == 18)
|
||||
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||
while (n--)
|
||||
ldt[i++] = c;
|
||||
}
|
||||
}
|
||||
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||
lbt = max(lt);
|
||||
dbt = max(dt);
|
||||
lm = hMap(lt, lbt, 1);
|
||||
dm = hMap(dt, dbt, 1);
|
||||
} else
|
||||
err(1);
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||
var lpos = pos;
|
||||
for (; ; lpos = pos) {
|
||||
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||
pos += c & 15;
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (!c)
|
||||
err(2);
|
||||
if (sym < 256)
|
||||
buf[bt++] = sym;
|
||||
else if (sym == 256) {
|
||||
lpos = pos, lm = null;
|
||||
break;
|
||||
} else {
|
||||
var add = sym - 254;
|
||||
if (sym > 264) {
|
||||
var i = sym - 257, b2 = fleb[i];
|
||||
add = bits(dat, pos, (1 << b2) - 1) + fl[i];
|
||||
pos += b2;
|
||||
}
|
||||
var d2 = dm[bits16(dat, pos) & dms], dsym = d2 >> 4;
|
||||
if (!d2)
|
||||
err(3);
|
||||
pos += d2 & 15;
|
||||
var dt = fd[dsym];
|
||||
if (dsym > 3) {
|
||||
var b2 = fdeb[dsym];
|
||||
dt += bits16(dat, pos) & (1 << b2) - 1, pos += b2;
|
||||
}
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var end = bt + add;
|
||||
if (bt < dt) {
|
||||
var shift = dl - dt, dend = Math.min(dt, end);
|
||||
if (shift + bt < 0)
|
||||
err(3);
|
||||
for (; bt < dend; ++bt)
|
||||
buf[bt] = dict[shift + bt];
|
||||
}
|
||||
for (; bt < end; ++bt)
|
||||
buf[bt] = buf[bt - dt];
|
||||
}
|
||||
}
|
||||
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||
if (lm)
|
||||
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||
} while (!final);
|
||||
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||
};
|
||||
var et = /* @__PURE__ */ new u8(0);
|
||||
var gzs = function(d2) {
|
||||
if (d2[0] != 31 || d2[1] != 139 || d2[2] != 8)
|
||||
err(6, "invalid gzip data");
|
||||
var flg = d2[3];
|
||||
var st = 10;
|
||||
if (flg & 4)
|
||||
st += (d2[10] | d2[11] << 8) + 2;
|
||||
for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d2[st++])
|
||||
;
|
||||
return st + (flg & 2);
|
||||
};
|
||||
var gzl = function(d2) {
|
||||
var l = d2.length;
|
||||
return (d2[l - 4] | d2[l - 3] << 8 | d2[l - 2] << 16 | d2[l - 1] << 24) >>> 0;
|
||||
};
|
||||
var zls = function(d2, dict) {
|
||||
if ((d2[0] & 15) != 8 || d2[0] >> 4 > 7 || (d2[0] << 8 | d2[1]) % 31)
|
||||
err(6, "invalid zlib data");
|
||||
if ((d2[1] >> 5 & 1) == +!dict)
|
||||
err(6, "invalid zlib data: " + (d2[1] & 32 ? "need" : "unexpected") + " dictionary");
|
||||
return (d2[1] >> 3 & 4) + 2;
|
||||
};
|
||||
function inflateSync(data, opts) {
|
||||
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function gunzipSync(data, opts) {
|
||||
var st = gzs(data);
|
||||
if (st + 8 > data.length)
|
||||
err(6, "invalid gzip data");
|
||||
return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
|
||||
}
|
||||
function unzlibSync(data, opts) {
|
||||
return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function decompressSync(data, opts) {
|
||||
return data[0] == 31 && data[1] == 139 && data[2] == 8 ? gunzipSync(data, opts) : (data[0] & 15) != 8 || data[0] >> 4 > 7 || (data[0] << 8 | data[1]) % 31 ? inflateSync(data, opts) : unzlibSync(data, opts);
|
||||
}
|
||||
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||
var tds = 0;
|
||||
try {
|
||||
td.decode(et, { stream: true });
|
||||
tds = 1;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// node_modules/pmtiles/dist/esm/index.js
|
||||
var j = Object.defineProperty;
|
||||
var S = Math.pow;
|
||||
var d = (o, t) => j(o, "name", { value: t, configurable: true });
|
||||
var h = (o, t, e) => new Promise((r, n) => {
|
||||
var s = (c) => {
|
||||
try {
|
||||
a(e.next(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, i = (c) => {
|
||||
try {
|
||||
a(e.throw(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, a = (c) => c.done ? r(c.value) : Promise.resolve(c.value).then(s, i);
|
||||
a((e = e.apply(o, t)).next());
|
||||
});
|
||||
var re = d((o, t) => {
|
||||
let e = false, r = "", n = L.GridLayer.extend({ createTile: d((s, i) => {
|
||||
let a = document.createElement("img"), c = new AbortController(), l = c.signal;
|
||||
return a.cancel = () => {
|
||||
c.abort();
|
||||
}, e || (o.getHeader().then((u) => {
|
||||
u.tileType === 1 || u.tileType === 6 ? console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details.") : u.tileType === 2 ? r = "image/png" : u.tileType === 3 ? r = "image/jpeg" : u.tileType === 4 ? r = "image/webp" : u.tileType === 5 && (r = "image/avif");
|
||||
}), e = true), o.getZxy(s.z, s.x, s.y, l).then((u) => {
|
||||
if (u) {
|
||||
let m = new Blob([u.data], { type: r }), g = window.URL.createObjectURL(m);
|
||||
a.src = g;
|
||||
} else a.style.display = "none";
|
||||
a.cancel = void 0, i(void 0, a);
|
||||
}).catch((u) => {
|
||||
if (u.name !== "AbortError") throw u;
|
||||
}), a;
|
||||
}, "createTile"), _removeTile: d(function(s) {
|
||||
let i = this._tiles[s];
|
||||
i && (i.el.cancel && i.el.cancel(), i.el.src && window.URL.revokeObjectURL(i.el.src), i.el.width = 0, i.el.height = 0, i.el.deleted = true, L.DomUtil.remove(i.el), delete this._tiles[s], this.fire("tileunload", { tile: i.el, coords: this._keyToTileCoords(s) }));
|
||||
}, "_removeTile") });
|
||||
return new n(t);
|
||||
}, "leafletRasterLayer");
|
||||
var z = d((o) => (t, e) => {
|
||||
if (e instanceof AbortController) return o(t, e);
|
||||
let r = new AbortController();
|
||||
return o(t, r).then((n) => e(void 0, n.data, n.cacheControl || "", n.expires || ""), (n) => e(n)).catch((n) => e(n)), { cancel: d(() => r.abort(), "cancel") };
|
||||
}, "v3compat");
|
||||
var A = class A2 {
|
||||
constructor(t) {
|
||||
this.tilev4 = d((t2, e) => h(this, null, function* () {
|
||||
if (t2.type === "json") {
|
||||
let g = t2.url.substr(10), p = this.tiles.get(g);
|
||||
if (p || (p = new w(g), this.tiles.set(g, p)), this.metadata) {
|
||||
let K = yield p.getTileJson(t2.url);
|
||||
return e.signal.throwIfAborted(), { data: K };
|
||||
}
|
||||
let f = yield p.getHeader();
|
||||
return e.signal.throwIfAborted(), (f.minLon >= f.maxLon || f.minLat >= f.maxLat) && console.error(`Bounds of PMTiles archive ${f.minLon},${f.minLat},${f.maxLon},${f.maxLat} are not valid.`), { data: { tiles: [`${t2.url}/{z}/{x}/{y}`], minzoom: f.minZoom, maxzoom: f.maxZoom, bounds: [f.minLon, f.minLat, f.maxLon, f.maxLat] } };
|
||||
}
|
||||
let r = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/), n = t2.url.match(r);
|
||||
if (!n) throw new Error("Invalid PMTiles protocol URL");
|
||||
let s = n[1], i = this.tiles.get(s);
|
||||
i || (i = new w(s), this.tiles.set(s, i));
|
||||
let a = n[2], c = n[3], l = n[4], u = yield i == null ? void 0 : i.getZxy(+a, +c, +l, e.signal);
|
||||
if (e.signal.throwIfAborted(), u) return { data: new Uint8Array(u.data), cacheControl: u.cacheControl, expires: u.expires };
|
||||
let m = yield i.getHeader();
|
||||
if (m.tileType === 1 || m.tileType === 6) {
|
||||
if (this.errorOnMissingTile) throw new Error("Tile not found.");
|
||||
return { data: new Uint8Array() };
|
||||
}
|
||||
return { data: null };
|
||||
}), "tilev4");
|
||||
this.tile = z(this.tilev4);
|
||||
this.tiles = /* @__PURE__ */ new Map(), this.metadata = (t == null ? void 0 : t.metadata) || false, this.errorOnMissingTile = (t == null ? void 0 : t.errorOnMissingTile) || false;
|
||||
}
|
||||
add(t) {
|
||||
this.tiles.set(t.source.getKey(), t);
|
||||
}
|
||||
get(t) {
|
||||
return this.tiles.get(t);
|
||||
}
|
||||
};
|
||||
d(A, "Protocol");
|
||||
var B = A;
|
||||
function b(o, t) {
|
||||
return (t >>> 0) * 4294967296 + (o >>> 0);
|
||||
}
|
||||
d(b, "toNum");
|
||||
function N(o, t) {
|
||||
let e = t.buf, r = e[t.pos++], n = (r & 112) >> 4;
|
||||
if (r < 128 || (r = e[t.pos++], n |= (r & 127) << 3, r < 128) || (r = e[t.pos++], n |= (r & 127) << 10, r < 128) || (r = e[t.pos++], n |= (r & 127) << 17, r < 128) || (r = e[t.pos++], n |= (r & 127) << 24, r < 128) || (r = e[t.pos++], n |= (r & 1) << 31, r < 128)) return b(o, n);
|
||||
throw new Error("Expected varint not more than 10 bytes");
|
||||
}
|
||||
d(N, "readVarintRemainder");
|
||||
function x(o) {
|
||||
let t = o.buf, e = t[o.pos++], r = e & 127;
|
||||
return e < 128 || (e = t[o.pos++], r |= (e & 127) << 7, e < 128) || (e = t[o.pos++], r |= (e & 127) << 14, e < 128) || (e = t[o.pos++], r |= (e & 127) << 21, e < 128) ? r : (e = t[o.pos], r |= (e & 15) << 28, N(r, o));
|
||||
}
|
||||
d(x, "readVarint");
|
||||
function I(o, t, e, r, n) {
|
||||
return n === 0 ? r !== 0 ? [o - 1 - e, o - 1 - t] : [e, t] : [t, e];
|
||||
}
|
||||
d(I, "rotate");
|
||||
function q(o, t, e) {
|
||||
if (o > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
if (t >= 1 << o || e >= 1 << o) throw new Error("tile x/y outside zoom level bounds");
|
||||
let r = ((1 << o) * (1 << o) - 1) / 3, n = o - 1, [s, i] = [t, e];
|
||||
for (let a = 1 << n; a > 0; a >>= 1) {
|
||||
let c = s & a, l = i & a;
|
||||
r += (3 * c ^ l) * (1 << n), [s, i] = I(a, s, i, c, l), n--;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(q, "zxyToTileId");
|
||||
function G(o) {
|
||||
let t = 3 * o + 1;
|
||||
return t < 4294967296 ? 31 - Math.clz32(t) : 63 - Math.clz32(t / 4294967296);
|
||||
}
|
||||
d(G, "tileIdToZ");
|
||||
function ie(o) {
|
||||
let t = G(o) >> 1;
|
||||
if (t > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
let e = ((1 << t) * (1 << t) - 1) / 3, r = o - e, n = 0, s = 0, i = 1 << t;
|
||||
for (let a = 1; a < i; a <<= 1) {
|
||||
let c = a & r / 2, l = a & (r ^ c);
|
||||
[n, s] = I(a, n, s, c, l), r = r / 2, n += c, s += l;
|
||||
}
|
||||
return [t, n, s];
|
||||
}
|
||||
d(ie, "tileIdToZxy");
|
||||
var J = ((s) => (s[s.Unknown = 0] = "Unknown", s[s.None = 1] = "None", s[s.Gzip = 2] = "Gzip", s[s.Brotli = 3] = "Brotli", s[s.Zstd = 4] = "Zstd", s))(J || {});
|
||||
function P(o, t) {
|
||||
return h(this, null, function* () {
|
||||
if (t === 1 || t === 0) return o;
|
||||
if (t === 2) {
|
||||
if (typeof globalThis.DecompressionStream == "undefined") return decompressSync(new Uint8Array(o));
|
||||
let e = new Response(o).body;
|
||||
if (!e) throw new Error("Failed to read response stream");
|
||||
let r = e.pipeThrough(new globalThis.DecompressionStream("gzip"));
|
||||
return new Response(r).arrayBuffer();
|
||||
}
|
||||
throw new Error("Compression method not supported");
|
||||
});
|
||||
}
|
||||
d(P, "defaultDecompress");
|
||||
var O = ((a) => (a[a.Unknown = 0] = "Unknown", a[a.Mvt = 1] = "Mvt", a[a.Png = 2] = "Png", a[a.Jpeg = 3] = "Jpeg", a[a.Webp = 4] = "Webp", a[a.Avif = 5] = "Avif", a[a.Mlt = 6] = "Mlt", a))(O || {});
|
||||
function _(o) {
|
||||
return o === 1 ? ".mvt" : o === 2 ? ".png" : o === 3 ? ".jpg" : o === 4 ? ".webp" : o === 5 ? ".avif" : o === 6 ? ".mlt" : "";
|
||||
}
|
||||
d(_, "tileTypeExt");
|
||||
var Y = 127;
|
||||
function Q(o, t) {
|
||||
let e = 0, r = o.length - 1;
|
||||
for (; e <= r; ) {
|
||||
let n = r + e >> 1, s = t - o[n].tileId;
|
||||
if (s > 0) e = n + 1;
|
||||
else if (s < 0) r = n - 1;
|
||||
else return o[n];
|
||||
}
|
||||
return r >= 0 && (o[r].runLength === 0 || t - o[r].tileId < o[r].runLength) ? o[r] : null;
|
||||
}
|
||||
d(Q, "findTile");
|
||||
var T = class T2 {
|
||||
constructor(t) {
|
||||
this.file = t;
|
||||
}
|
||||
getKey() {
|
||||
return this.file.name;
|
||||
}
|
||||
getBytes(t, e) {
|
||||
return h(this, null, function* () {
|
||||
return { data: yield this.file.slice(t, t + e).arrayBuffer() };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(T, "FileSource");
|
||||
var D = class D2 {
|
||||
constructor(t, e = new Headers(), r = void 0) {
|
||||
var a, c;
|
||||
this.url = t, this.customHeaders = e, this.credentials = r, this.mustReload = false;
|
||||
let n = "";
|
||||
"navigator" in globalThis && (n = (c = (a = globalThis.navigator) == null ? void 0 : a.userAgent) != null ? c : "");
|
||||
let s = n.indexOf("Windows") > -1, i = /Chrome|Chromium|Edg|OPR|Brave/.test(n);
|
||||
this.chromeWindowsNoCache = false, s && i && (this.chromeWindowsNoCache = true);
|
||||
}
|
||||
getKey() {
|
||||
return this.url;
|
||||
}
|
||||
setHeaders(t) {
|
||||
this.customHeaders = t;
|
||||
}
|
||||
getBytes(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s, i;
|
||||
r ? i = r : (s = new AbortController(), i = s.signal);
|
||||
let a = new Headers(this.customHeaders);
|
||||
a.set("range", `bytes=${t}-${t + e - 1}`);
|
||||
let c;
|
||||
this.mustReload ? c = "reload" : this.chromeWindowsNoCache && (c = "no-store");
|
||||
let l = yield fetch(this.url, { signal: i, cache: c, headers: a, credentials: this.credentials });
|
||||
if (t === 0 && l.status === 416) {
|
||||
let p = l.headers.get("Content-Range");
|
||||
if (!p || !p.startsWith("bytes */")) throw new Error("Missing content-length on 416 response");
|
||||
let f = +p.substr(8);
|
||||
a.set("range", `bytes=0-${f - 1}`), l = yield fetch(this.url, { signal: i, cache: "reload", headers: a, credentials: this.credentials });
|
||||
}
|
||||
let u = l.headers.get("Etag");
|
||||
if (u != null && u.startsWith("W/") && (u = null), l.status === 416 || n && u && u !== n) throw this.mustReload = true, new v(`Server returned non-matching ETag ${n} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);
|
||||
if (l.status >= 300) throw new Error(`Bad response code: ${l.status}`);
|
||||
let m = l.headers.get("Content-Length");
|
||||
if (l.status === 200 && (!m || +m > e)) throw s && s.abort(), new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");
|
||||
return { data: yield l.arrayBuffer(), etag: u || void 0, cacheControl: l.headers.get("Cache-Control") || void 0, expires: l.headers.get("Expires") || void 0 };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(D, "FetchSource");
|
||||
var E = D;
|
||||
function y(o, t) {
|
||||
let e = o.getUint32(t + 4, true), r = o.getUint32(t + 0, true);
|
||||
return e * S(2, 32) + r;
|
||||
}
|
||||
d(y, "getUint64");
|
||||
function X(o, t) {
|
||||
let e = new DataView(o), r = e.getUint8(7);
|
||||
if (r > 3) throw new Error(`Archive is spec version ${r} but this library supports up to spec version 3`);
|
||||
return { specVersion: r, rootDirectoryOffset: y(e, 8), rootDirectoryLength: y(e, 16), jsonMetadataOffset: y(e, 24), jsonMetadataLength: y(e, 32), leafDirectoryOffset: y(e, 40), leafDirectoryLength: y(e, 48), tileDataOffset: y(e, 56), tileDataLength: y(e, 64), numAddressedTiles: y(e, 72), numTileEntries: y(e, 80), numTileContents: y(e, 88), clustered: e.getUint8(96) === 1, internalCompression: e.getUint8(97), tileCompression: e.getUint8(98), tileType: e.getUint8(99), minZoom: e.getUint8(100), maxZoom: e.getUint8(101), minLon: e.getInt32(102, true) / 1e7, minLat: e.getInt32(106, true) / 1e7, maxLon: e.getInt32(110, true) / 1e7, maxLat: e.getInt32(114, true) / 1e7, centerZoom: e.getUint8(118), centerLon: e.getInt32(119, true) / 1e7, centerLat: e.getInt32(123, true) / 1e7, etag: t };
|
||||
}
|
||||
d(X, "bytesToHeader");
|
||||
function Z(o) {
|
||||
let t = { buf: new Uint8Array(o), pos: 0 }, e = x(t), r = [], n = 0;
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
r.push({ tileId: n + i, offset: 0, length: 0, runLength: 1 }), n += i;
|
||||
}
|
||||
for (let s = 0; s < e; s++) r[s].runLength = x(t);
|
||||
for (let s = 0; s < e; s++) r[s].length = x(t);
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
i === 0 && s > 0 ? r[s].offset = r[s - 1].offset + r[s - 1].length : r[s].offset = i - 1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(Z, "deserializeIndex");
|
||||
var R = class R2 extends Error {
|
||||
};
|
||||
d(R, "EtagMismatch");
|
||||
var v = R;
|
||||
function V(o, t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield o.getBytes(0, 16384);
|
||||
if (new DataView(e.data).getUint16(0, true) !== 19792) throw new Error("Wrong magic number for PMTiles archive");
|
||||
let n = e.data.slice(0, Y), s = X(n, e.etag), i = e.data.slice(s.rootDirectoryOffset, s.rootDirectoryOffset + s.rootDirectoryLength), a = `${o.getKey()}|${s.etag || ""}|${s.rootDirectoryOffset}|${s.rootDirectoryLength}`, c = Z(yield t(i, s.internalCompression));
|
||||
return [s, [a, c.length, c]];
|
||||
});
|
||||
}
|
||||
d(V, "getHeaderAndRoot");
|
||||
function F(o, t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = yield o.getBytes(e, r, s, n.etag), a = yield t(i.data, n.internalCompression), c = Z(a);
|
||||
if (c.length === 0) throw new Error("Empty directory is invalid");
|
||||
return c;
|
||||
});
|
||||
}
|
||||
d(F, "getDirectory");
|
||||
var U = class U2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, r.data;
|
||||
let n = yield V(t, this.decompress);
|
||||
return n[1] && this.cache.set(n[1][0], { lastUsed: this.counter++, data: n[1][2] }), this.cache.set(e, { lastUsed: this.counter++, data: n[0] }), this.prune(), n[0];
|
||||
});
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) return a.lastUsed = this.counter++, a.data;
|
||||
let c = yield F(t, this.decompress, e, r, n, s);
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: c }), this.prune(), c;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size > this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
this.cache.delete(t.getKey());
|
||||
});
|
||||
}
|
||||
};
|
||||
d(U, "ResolvedValueCache");
|
||||
var M = class M2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.invalidations = /* @__PURE__ */ new Map(), this.pendingFetches = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, yield r.data;
|
||||
let n = new Promise((s, i) => {
|
||||
V(t, this.decompress).then((a) => {
|
||||
a[1] && this.cache.set(a[1][0], { lastUsed: this.counter++, data: Promise.resolve(a[1][2]) }), s(a[0]), this.prune();
|
||||
}).catch((a) => {
|
||||
i(a);
|
||||
});
|
||||
});
|
||||
return this.cache.set(e, { lastUsed: this.counter++, data: n }), n;
|
||||
});
|
||||
}
|
||||
trackSignal(t, e, r) {
|
||||
e.refs++, r.addEventListener("abort", () => {
|
||||
--e.refs <= 0 && this.pendingFetches.get(t) === e && (e.controller.abort(), this.cache.delete(t), this.pendingFetches.delete(t));
|
||||
}, { once: true });
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) {
|
||||
a.lastUsed = this.counter++;
|
||||
let m = this.pendingFetches.get(i);
|
||||
return m && this.trackSignal(i, m, s != null ? s : new AbortController().signal), yield a.data;
|
||||
}
|
||||
let c = new AbortController(), l = { controller: c, refs: 0 };
|
||||
this.trackSignal(i, l, s != null ? s : new AbortController().signal), this.pendingFetches.set(i, l);
|
||||
let u = new Promise((m, g) => {
|
||||
F(t, this.decompress, e, r, n, c.signal).then((p) => {
|
||||
this.pendingFetches.delete(i), m(p), this.prune();
|
||||
}).catch((p) => {
|
||||
g(p);
|
||||
});
|
||||
});
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: u }), u;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size >= this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey();
|
||||
if (this.invalidations.get(e)) return yield this.invalidations.get(e);
|
||||
this.cache.delete(t.getKey());
|
||||
let r = new Promise((n, s) => {
|
||||
this.getHeader(t).then((i) => {
|
||||
n(), this.invalidations.delete(e);
|
||||
}).catch((i) => {
|
||||
s(i);
|
||||
});
|
||||
});
|
||||
this.invalidations.set(e, r);
|
||||
});
|
||||
}
|
||||
};
|
||||
d(M, "SharedPromiseCache");
|
||||
var C = M;
|
||||
var H = class H2 {
|
||||
constructor(t, e, r) {
|
||||
typeof t == "string" ? this.source = new E(t) : this.source = t, r ? this.decompress = r : this.decompress = P, e ? this.cache = e : this.cache = new C();
|
||||
}
|
||||
getHeader() {
|
||||
return h(this, null, function* () {
|
||||
return yield this.cache.getHeader(this.source);
|
||||
});
|
||||
}
|
||||
getZxyAttempt(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s = q(t, e, r), i = yield this.cache.getHeader(this.source);
|
||||
if (n == null || n.throwIfAborted(), t < i.minZoom || t > i.maxZoom) return;
|
||||
let a = i.rootDirectoryOffset, c = i.rootDirectoryLength;
|
||||
for (let l = 0; l <= 3; l++) {
|
||||
let u = yield this.cache.getDirectory(this.source, a, c, i, n);
|
||||
n == null || n.throwIfAborted();
|
||||
let m = Q(u, s);
|
||||
if (m) {
|
||||
if (m.runLength > 0) {
|
||||
let g = yield this.source.getBytes(i.tileDataOffset + m.offset, m.length, n, i.etag);
|
||||
return { data: yield this.decompress(g.data, i.tileCompression), cacheControl: g.cacheControl, expires: g.expires };
|
||||
}
|
||||
a = i.leafDirectoryOffset + m.offset, c = m.length;
|
||||
} else return;
|
||||
}
|
||||
throw new Error("Maximum directory depth exceeded");
|
||||
});
|
||||
}
|
||||
getZxy(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getZxyAttempt(t, e, r, n);
|
||||
} catch (s) {
|
||||
if (s instanceof v) return this.cache.invalidate(this.source), yield this.getZxyAttempt(t, e, r, n);
|
||||
throw s;
|
||||
}
|
||||
});
|
||||
}
|
||||
getMetadataAttempt() {
|
||||
return h(this, null, function* () {
|
||||
let t = yield this.cache.getHeader(this.source), e = yield this.source.getBytes(t.jsonMetadataOffset, t.jsonMetadataLength, void 0, t.etag), r = yield this.decompress(e.data, t.internalCompression), n = new TextDecoder("utf-8");
|
||||
return JSON.parse(n.decode(r));
|
||||
});
|
||||
}
|
||||
getMetadata() {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getMetadataAttempt();
|
||||
} catch (t) {
|
||||
if (t instanceof v) return this.cache.invalidate(this.source), yield this.getMetadataAttempt();
|
||||
throw t;
|
||||
}
|
||||
});
|
||||
}
|
||||
getTileJson(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield this.getHeader(), r = yield this.getMetadata(), n = _(e.tileType);
|
||||
return { tilejson: "3.0.0", scheme: "xyz", tiles: [`${t}/{z}/{x}/{y}${n}`], vector_layers: r.vector_layers, attribution: r.attribution, description: r.description, name: r.name, version: r.version, bounds: [e.minLon, e.minLat, e.maxLon, e.maxLat], center: [e.centerLon, e.centerLat, e.centerZoom], minzoom: e.minZoom, maxzoom: e.maxZoom };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(H, "PMTiles");
|
||||
var w = H;
|
||||
|
||||
// src/map/pmtiles.worker.ts
|
||||
var workerScope = self;
|
||||
workerScope.addProtocol("pmtiles", new B().tile);
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 283 KiB |
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* بهروزرسانی یکبارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
|
||||
*
|
||||
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» میشود — هم روی دیسک
|
||||
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
|
||||
* موفقیت پشتیبان، پالت تازه نوشته و آپلود میشود.
|
||||
*
|
||||
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
|
||||
* میشوند، نه JSON خام؛ پس تنها راه درست بهروزرسانی، PUT روی همان API است.
|
||||
*
|
||||
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیشفرض هیچ تغییری نمیدهد:
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --palette
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --restore
|
||||
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
|
||||
*
|
||||
* در همهٔ حالتها (جز restore) ترتیب امن رعایت میشود: خواندن و اعتبارسنجی →
|
||||
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
|
||||
*
|
||||
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده میشوند (MINIO_ROOT_USER /
|
||||
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نامها.
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* پالت تازه */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** نگاشت دستهها به رنگهای تازهٔ برند — تنها منبع حقیقت این اسکریپت */
|
||||
const PALETTE = {
|
||||
background: '#181512',
|
||||
urban: '#27221c',
|
||||
highway: '#6a5544',
|
||||
street: '#4a3a2d',
|
||||
alley: '#352c25',
|
||||
water: '#172c32',
|
||||
parks: '#20312b',
|
||||
nature: '#314238',
|
||||
mainText: '#ead9c6',
|
||||
subText: '#a99584',
|
||||
borders: '#3e362e',
|
||||
} as const
|
||||
|
||||
type Category = keyof typeof PALETTE
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* قواعد دستهبندی لایهها */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Layer = {
|
||||
id: string
|
||||
type: string
|
||||
'source-layer'?: string
|
||||
paint?: Record<string, unknown>
|
||||
layout?: Record<string, unknown>
|
||||
}
|
||||
type StyleDocument = {
|
||||
version?: number
|
||||
layers?: Layer[]
|
||||
sources?: Record<string, unknown>
|
||||
/** الگوی نشانی گلیفها — باید به پوشهٔ v2.0 باکت اشاره کند */
|
||||
glyphs?: string
|
||||
}
|
||||
|
||||
/** پوشهٔ فونتهای آفلاین در باکت MinIO */
|
||||
const GLYPHS_FOLDER = 'v2.0'
|
||||
/** الگوی استاندارد گلیف — جانگهدارها دستنخورده میمانند */
|
||||
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
|
||||
|
||||
/**
|
||||
* اصلاح مسیر گلیفها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
|
||||
* «v2.0/{fontstack}/{range}.pbf» تبدیل میشود تا با ساختار پوشههای باکت
|
||||
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمیکند.
|
||||
*/
|
||||
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
|
||||
const current = (style.glyphs ?? '').trim()
|
||||
const marker = current.indexOf('{fontstack}')
|
||||
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
|
||||
if (next === current) return null
|
||||
style.glyphs = next
|
||||
return { from: current || '(خالی)', to: next }
|
||||
}
|
||||
|
||||
/** آیا مقدار text-field به یک فیلد «نام» اشاره میکند؟ (name، name_en، name:latin و…) */
|
||||
const NAME_REFERENCE = /name(:[a-z-]+)?/i
|
||||
|
||||
/**
|
||||
* فونتهای محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
|
||||
* تنها پوشههای Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
|
||||
* فقط لاتیناند.
|
||||
*/
|
||||
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
|
||||
|
||||
/**
|
||||
* بازگردانی متن برچسبها به قالب دوزبانهٔ اصلی، بهتفکیک لایه.
|
||||
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راهها)
|
||||
*/
|
||||
const TEXT_FIELD_RESTORE: Record<string, string> = {
|
||||
water_name: '{name:latin}\n{name:nonlatin}',
|
||||
place_other: '{name:latin}\n{name:nonlatin}',
|
||||
place_suburb: '{name:latin}\n{name:nonlatin}',
|
||||
place_village: '{name:latin}\n{name:nonlatin}',
|
||||
place_town: '{name:latin}\n{name:nonlatin}',
|
||||
place_city: '{name:latin}\n{name:nonlatin}',
|
||||
place_city_large: '{name:latin}\n{name:nonlatin}',
|
||||
place_state: '{name:latin}\n{name:nonlatin}',
|
||||
highway_name_other: '{name:latin} {name:nonlatin}',
|
||||
place_country_other: '{name:latin}',
|
||||
place_country_minor: '{name:latin}',
|
||||
place_country_major: '{name:latin}',
|
||||
}
|
||||
|
||||
/** قالب پیشفرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
|
||||
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
|
||||
|
||||
function referencesName(value: unknown): boolean {
|
||||
if (typeof value === 'string') return NAME_REFERENCE.test(value)
|
||||
if (Array.isArray(value)) return value.some(referencesName)
|
||||
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
|
||||
return false
|
||||
}
|
||||
|
||||
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
|
||||
function persianFontFor(stack: string[]): string {
|
||||
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
|
||||
if (local) return local
|
||||
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
|
||||
}
|
||||
|
||||
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
|
||||
|
||||
/**
|
||||
* آمادهسازی برچسبها برای فارسی:
|
||||
* ۱) متن به قالب دوزبانهٔ اصلی برمیگردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
|
||||
* ۲) پشتهٔ فونت بازچینی میشود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
|
||||
* چون مپلیبر برای هر پشته فقط یک فایل گلیف میگیرد، همان فونت اول باید هر دو خط
|
||||
* را پوشش دهد و Noto Sans لاتین را هم دارد.
|
||||
*/
|
||||
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
|
||||
const changed: LabelChange[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
for (const layer of style.layers ?? []) {
|
||||
const layout = layer.layout
|
||||
if (layer.type !== 'symbol' || !layout) continue
|
||||
|
||||
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
|
||||
if (!stack.length) continue
|
||||
|
||||
const persianFont = persianFontFor(stack)
|
||||
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
|
||||
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
|
||||
|
||||
if (!('text-field' in layout)) {
|
||||
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح میشود
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, font: fontChange })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!referencesName(layout['text-field'])) {
|
||||
layout['text-font'] = restacked
|
||||
skipped.push(layer.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const from = JSON.stringify(layout['text-field']).slice(0, 48)
|
||||
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
|
||||
layout['text-field'] = to
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
|
||||
}
|
||||
|
||||
return { changed, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* بررسی فونتها: هر فونتی که در text-font لایههای برچسب استفاده شده باید در
|
||||
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
|
||||
*/
|
||||
async function verifyFonts(style: StyleDocument): Promise<void> {
|
||||
const fonts = new Set<string>()
|
||||
for (const layer of style.layers ?? []) {
|
||||
if (layer.type !== 'symbol') continue
|
||||
const stack = layer.layout?.['text-font']
|
||||
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
|
||||
}
|
||||
|
||||
if (!fonts.size) {
|
||||
console.log('• فونتی در لایههای برچسب پیدا نشد')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
|
||||
const missing: string[] = []
|
||||
for (const font of [...fonts].sort()) {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
|
||||
const ok = (await fetch(url, { method: 'HEAD' })).ok
|
||||
if (!ok) missing.push(font)
|
||||
console.log(` ${ok ? '✓' : '✗'} ${font}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
missing.length
|
||||
? `✗ فونتهای ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
|
||||
: `✓ همهٔ فونتها در ${GLYPHS_FOLDER} باکت موجودند`,
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بررسی پوشش فونتهای محلی (کدام فونت فارسی را رندر میکند؟) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** بازههای کلیدی گلیف: لاتین پایه، عربی/فارسی، فرمهای نمایشی عربی */
|
||||
const COVERAGE_RANGES = [
|
||||
{ label: 'لاتین', range: '0-255' },
|
||||
{ label: 'فارسی', range: '1536-1791' },
|
||||
{ label: 'فرم فارسی', range: '65024-65279' },
|
||||
] as const
|
||||
|
||||
/** فهرست پوشههای فونت داخل v2.0 باکت */
|
||||
async function listFontFolders(): Promise<string[]> {
|
||||
const response = await fetch(
|
||||
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
|
||||
)
|
||||
const xml = await response.text()
|
||||
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
|
||||
return [...xml.matchAll(pattern)].map((match) => match[1])
|
||||
}
|
||||
|
||||
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
|
||||
async function rangeCoverage(font: string, range: string): Promise<string> {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return 'نیست'
|
||||
const bytes = Number(response.headers.get('content-length') ?? 0)
|
||||
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
|
||||
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
|
||||
}
|
||||
|
||||
/** گزارش پوشش خطها برای همهٔ فونتهای باکت — ورودی تصمیم انتخاب فونت برچسبها */
|
||||
async function auditFonts(): Promise<void> {
|
||||
const fonts = await listFontFolders()
|
||||
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
|
||||
for (const font of fonts) {
|
||||
const cells: string[] = []
|
||||
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
|
||||
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
|
||||
}
|
||||
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر میکند.')
|
||||
}
|
||||
|
||||
/**
|
||||
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا میشود تا مطمئن شویم سند
|
||||
* سالم است و اسکیمای سبک (نسخه، لایهها، منابع) نشکسته است.
|
||||
*/
|
||||
function validateStyle(style: StyleDocument, label: string): void {
|
||||
const problems: string[] = []
|
||||
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
|
||||
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
|
||||
else {
|
||||
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
|
||||
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
|
||||
}
|
||||
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
|
||||
problems.push('sources باید یک شیء باشد')
|
||||
}
|
||||
|
||||
console.log(
|
||||
problems.length
|
||||
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
|
||||
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
|
||||
)
|
||||
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
category: Category
|
||||
/** با این الگو روی شناسهٔ لایه تطبیق داده میشود */
|
||||
test: RegExp
|
||||
/** کدام ویژگیهای paint رنگآمیزی شوند */
|
||||
properties: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* جدول قواعد — به ترتیب بررسی میشود و نخستین تطبیق برنده است.
|
||||
* رنگهای داخل عبارتها (interpolate/match) هم بازنویسی میشوند تا پالت
|
||||
* یکدست بماند.
|
||||
*/
|
||||
const RULES: Rule[] = [
|
||||
{ category: 'background', test: /^background$/, properties: ['background-color'] },
|
||||
|
||||
// آب: خود لایههای آب و راههای آبی (برچسبها قاعدهٔ جدا دارند)
|
||||
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// پارکها پیش از طبیعت بررسی میشوند تا چمن/پارک زیر «طبیعت» نرود
|
||||
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// طبیعت: پوشش زمینِ جنگلی/علفزارِ بیرون از شهر
|
||||
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// کاربری شهری و ساختمانها
|
||||
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
|
||||
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
|
||||
|
||||
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
|
||||
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
// برچسبهای اصلی: شهر/شهرک و نام بزرگراهها
|
||||
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
|
||||
|
||||
// بقیهٔ برچسبها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
|
||||
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
|
||||
]
|
||||
|
||||
/** ویژگیهای فرعی که همیشه با رنگ پسزمینه هماهنگ میشوند تا خوانایی بماند */
|
||||
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
|
||||
/** ویژگیهای آیکون برچسبها که با متن همرنگ میشوند */
|
||||
const ICON_PROPERTIES = ['icon-color']
|
||||
|
||||
const COLOR_PATTERN = /^(#|rgb|hsl)/i
|
||||
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بازنویسی رنگها (توابع خالص) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارتها — را با رنگ تازه عوض میکند */
|
||||
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
|
||||
if (isColor(value)) {
|
||||
stats.seen.add(value.trim())
|
||||
stats.replaced += 1
|
||||
return color
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
|
||||
|
||||
/** یک لایه را بر پایهٔ قواعد رنگآمیزی میکند و گزارش تغییرها را برمیگرداند */
|
||||
function recolorLayer(layer: Layer): LayerReport {
|
||||
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
|
||||
const stats = { replaced: 0, seen: new Set<string>() }
|
||||
|
||||
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
|
||||
|
||||
if (rule) {
|
||||
for (const property of rule.properties) {
|
||||
if (!(property in layer.paint)) continue
|
||||
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لایههای برچسب: هاله و آیکونها با پالت هماهنگ میشوند
|
||||
if (layer.type === 'symbol') {
|
||||
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
|
||||
? PALETTE[rule.category]
|
||||
: PALETTE.subText
|
||||
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
|
||||
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
|
||||
}
|
||||
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
|
||||
if (!(property in layer.paint)) continue
|
||||
const target = property === 'icon-color' ? textColor : PALETTE.background
|
||||
layer.paint[property] = paintColor(layer.paint[property], target, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لبهٔ ساختمانها با رنگ مرزها همخانواده میشود
|
||||
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
|
||||
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
|
||||
}
|
||||
|
||||
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
|
||||
}
|
||||
|
||||
/** کل سند سبک را بازنویسی میکند (بدون دستزدن به ساختار و ویژگیهای غیررنگی) */
|
||||
function recolorStyle(style: StyleDocument): LayerReport[] {
|
||||
const layers = style.layers ?? []
|
||||
return layers.map(recolorLayer)
|
||||
}
|
||||
|
||||
/** گزارش فشردهٔ تغییرهای پالت — بهتفکیک دسته و لایههای دستنخورده */
|
||||
function reportPalette(reports: LayerReport[]): void {
|
||||
const byCategory = new Map<string, number>()
|
||||
for (const report of reports) {
|
||||
if (!report.replaced) continue
|
||||
const category = report.category ?? 'بدوندسته'
|
||||
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
|
||||
}
|
||||
|
||||
console.log('\nگزارش تغییرها:')
|
||||
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
|
||||
}
|
||||
|
||||
const untouched = reports.filter((report) => !report.replaced)
|
||||
if (untouched.length) {
|
||||
console.log(`\nلایههای بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Credentials = { user: string; secret: string }
|
||||
|
||||
async function readCredentials(): Promise<Credentials> {
|
||||
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
|
||||
const fromFile: Record<string, string> = {}
|
||||
try {
|
||||
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
|
||||
if (match) fromFile[match[1]] = match[2].trim()
|
||||
}
|
||||
} catch {
|
||||
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین میشوند */
|
||||
}
|
||||
|
||||
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
|
||||
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
|
||||
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
|
||||
return { user, secret }
|
||||
}
|
||||
|
||||
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
|
||||
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
|
||||
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
|
||||
|
||||
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام میدهند */
|
||||
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
|
||||
const url = new URL(objectPath, endpoint)
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
||||
const dateStamp = amzDate.slice(0, 8)
|
||||
const payloadHash = sha256(body)
|
||||
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
|
||||
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
||||
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
|
||||
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
|
||||
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
|
||||
const authorization =
|
||||
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'x-amz-content-sha256': payloadHash,
|
||||
'x-amz-date': amzDate,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* اجرا */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
|
||||
const STYLE_PATH = '/maps/style.json'
|
||||
const BACKUP_PATH = '/maps/style.backup.json'
|
||||
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
|
||||
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
|
||||
|
||||
/** بازگردانی: پشتیبان دقیقاً بهجای style.json نوشته میشود */
|
||||
async function restore(): Promise<void> {
|
||||
const backup = await readFile(LOCAL_BACKUP, 'utf8')
|
||||
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
|
||||
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
|
||||
|
||||
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
|
||||
console.log(
|
||||
restored === backup
|
||||
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
|
||||
: '✗ محتوای بازگرداندهشده با پشتیبان یکسان نیست — دستی بررسی کنید',
|
||||
)
|
||||
}
|
||||
|
||||
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
const action = ACTIONS.find((flag) => process.argv.includes(flag))
|
||||
|
||||
// پیشفرض امن: بدون پرچم هیچ نوشتنی انجام نمیشود
|
||||
if (!action) {
|
||||
console.log(
|
||||
'کاری انجام نشد — یکی از پرچمها را بدهید:\n' +
|
||||
' --audit-fonts گزارش پوشش خطهای هر فونت در v2.0 (فقط خواندن)\n' +
|
||||
' --fix-labels برچسبهای دوزبانه + پشتهٔ فونت محلی\n' +
|
||||
' --fix-glyphs اصلاح مسیر گلیفها به v2.0\n' +
|
||||
' --palette اعمال پالت تازه\n' +
|
||||
' --restore بازگردانی از پشتیبان\n' +
|
||||
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--audit-fonts') {
|
||||
await auditFonts()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--restore') {
|
||||
await restore()
|
||||
return
|
||||
}
|
||||
|
||||
// ۱) خواندن سبک فعلی
|
||||
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
|
||||
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
|
||||
const original = await response.text()
|
||||
const style = JSON.parse(original) as StyleDocument
|
||||
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
|
||||
validateStyle(style, 'نسخهٔ فعلی')
|
||||
|
||||
// ۲) پشتیبانگیری، پیش از هر تغییر
|
||||
await mkdir(OUTPUT_DIR, { recursive: true })
|
||||
await writeFile(LOCAL_BACKUP, original, 'utf8')
|
||||
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
|
||||
if (!dryRun) {
|
||||
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
|
||||
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
|
||||
}
|
||||
|
||||
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
|
||||
if (action === '--fix-labels') {
|
||||
const { changed, skipped } = fixLabels(style)
|
||||
console.log(`\nلایههای برچسب اصلاحشده (${changed.length}):`)
|
||||
for (const layer of changed) {
|
||||
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
|
||||
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(`\nلایههای بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
|
||||
}
|
||||
await verifyFonts(style)
|
||||
} else if (action === '--fix-glyphs') {
|
||||
const change = fixGlyphsPath(style)
|
||||
console.log(
|
||||
change ? `\nمسیر گلیفها: ${change.from} → ${change.to}` : '\nمسیر گلیفها از قبل به v2.0 اشاره میکند',
|
||||
)
|
||||
await verifyFonts(style)
|
||||
} else {
|
||||
reportPalette(recolorStyle(style))
|
||||
}
|
||||
validateStyle(style, 'نسخهٔ تازه')
|
||||
|
||||
// ۴) نوشتن و آپلود نسخهٔ تازه
|
||||
const updated = JSON.stringify(style, null, 2)
|
||||
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
|
||||
if (dryRun) {
|
||||
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
|
||||
return
|
||||
}
|
||||
|
||||
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
|
||||
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('\nخطا:', error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { Preloader } from './components/Preloader';
|
||||
import { Header } from './components/Header';
|
||||
import { Footer } from './components/Footer';
|
||||
import { FloatingContact } from './components/FloatingContact';
|
||||
// چتبات موقتاً خاموش است — برای روشنکردن، این ایمپورت و خط رندر پایین را برگردانید
|
||||
// import { ChatWidget } from './components/ChatWidget';
|
||||
import { Home } from './pages/Home';
|
||||
import { About } from './pages/About';
|
||||
import { ServicesPage } from './pages/ServicesPage';
|
||||
@@ -21,6 +23,7 @@ import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
import { ArticleWritePage } from './admin/ArticleWritePage';
|
||||
import { ProjectWritePage } from './admin/ProjectWritePage';
|
||||
import { LangProvider, useLang } from './i18n/lang';
|
||||
|
||||
// مسیرهای عمومی یک بار تعریف میشوند و دو بار سرو میشوند: ریشه فارسی و /en انگلیسی
|
||||
@@ -68,6 +71,8 @@ function AnimatedRoutes() {
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/projects/new" element={<ProjectWritePage />} />
|
||||
<Route path="/admin/projects/:id" element={<ProjectWritePage />} />
|
||||
{FA_ROUTES}
|
||||
<Route path="*" element={<Home />} />
|
||||
{EN_ROUTES}
|
||||
@@ -92,11 +97,14 @@ function AppChrome() {
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
const brand = data.brand.name;
|
||||
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
||||
// alternate انگلیسی فقط وقتی اعلام میشود که متای انگلیسی واقعاً ثبت شده باشد
|
||||
const hasEn = !!(meta && ((meta.title_en ?? '').trim() || (meta.description_en ?? '').trim()));
|
||||
applySeo({
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
hasEn,
|
||||
});
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
@@ -106,6 +114,8 @@ function AppChrome() {
|
||||
<AnimatedRoutes />
|
||||
{!isAdminPage && <Footer />}
|
||||
{!isAdminPage && <FloatingContact />}
|
||||
{/* چتبات موقتاً خاموش است — برای روشنکردن، این خط را از حالت کامنت خارج کنید */}
|
||||
{/* {!isAdminPage && <ChatWidget />} */}
|
||||
{isAuthenticated && !isAdminPage && <AdminBar />}
|
||||
</>
|
||||
);
|
||||
@@ -115,8 +125,10 @@ function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
const { t, dir, lang } = useLang();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
||||
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { KeyRoundIcon, XIcon } from 'lucide-react'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
idle: 'همگامسازی شد',
|
||||
@@ -126,9 +128,18 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang, syncEditLang, role, canManageSite } = useAdmin()
|
||||
const { lang } = useLang()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
// زبان ویرایش از زبان صفحهٔ در حال نمایش پیروی میکند — ویرایش روی /en به ستونهای
|
||||
// انگلیسی میرود و روی صفحات فارسی به فارسی؛ وگرنه ویرایشِ دیدهشده در /en ناخواسته
|
||||
// متن فارسی را بازنویسی میکرد. کلید دستی پایین فقط برای ویرایش ستون زبان دیگر
|
||||
// روی همین صفحه است و با ناوبری بعدی دوباره همگام میشود.
|
||||
useEffect(() => {
|
||||
if (isEditMode) syncEditLang(lang)
|
||||
}, [isEditMode, lang, syncEditLang])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -140,6 +151,7 @@ export function AdminBar() {
|
||||
حالت مدیریت
|
||||
</span>
|
||||
|
||||
{canManageSite && (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
@@ -160,11 +172,12 @@ export function AdminBar() {
|
||||
</span>
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
title="تعیین میکند ویرایشها روی کدام ستون زبان ذخیره شوند"
|
||||
title="معمولاً خودکار با زبان صفحه همگام میشود — این کلید فقط برای ویرایش ستون زبان دیگر روی همین صفحه است"
|
||||
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
? 'border-copper bg-copper/10 text-copper'
|
||||
@@ -188,6 +201,40 @@ export function AdminBar() {
|
||||
رمز
|
||||
</button>
|
||||
|
||||
{canManageSite && (
|
||||
<>
|
||||
<Link
|
||||
to="/admin/write"
|
||||
data-admin-link
|
||||
className="border border-copper/50 px-3 py-1.5 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
نوشتن مقاله
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی پیامها', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
|
||||
})}
|
||||
title="دانلود پیامهای فرم تماس بهصورت CSV"
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
پیامها (CSV)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void adminApi.downloadCsv('subscribers').catch((e) => {
|
||||
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی خبرنامه', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
|
||||
})}
|
||||
title="دانلود مشترکان خبرنامه بهصورت CSV"
|
||||
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
|
||||
>
|
||||
خبرنامه (CSV)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
|
||||
@@ -3,18 +3,24 @@ import type { ReactNode } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
|
||||
import { entityConfig, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
export type AdminRole = 'admin' | 'creator'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
/** نقش کاربر واردشده — 'creator' فقط نویسندهٔ محتواست */
|
||||
role: AdminRole | null
|
||||
/** فقط نقش admin به کل بخشهای سایت دسترسی دارد */
|
||||
canManageSite: boolean
|
||||
isEditMode: boolean
|
||||
toggleEditMode: () => void
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
login: (username: string, password: string) => Promise<AdminRole>
|
||||
logout: () => void
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
@@ -22,6 +28,9 @@ interface AdminContextValue {
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
/** همگامسازی زبان ویرایش با زبان صفحهٔ در حال نمایش — کلید دستی فقط برای
|
||||
* ویرایش ستون زبان دیگر روی همان صفحه است؛ در ناوبری دوباره از صفحه پیروی میکند */
|
||||
syncEditLang: (lang: EditLang) => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
@@ -55,12 +64,26 @@ function showSaveError(error: unknown): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** نقش را از توکن JWT درمیآورد — فال بک sessionStorage */
|
||||
function roleFromToken(token: string | null): AdminRole | null {
|
||||
if (!token) return null
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
|
||||
return payload?.role === 'creator' ? 'creator' : 'admin'
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
|
||||
const [role, setRole] = useState<AdminRole | null>(() =>
|
||||
sessionStorage.getItem('aramesh_role') as AdminRole | null ?? roleFromToken(getToken()),
|
||||
)
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
|
||||
)
|
||||
const [editLang, setEditLang] = useState<EditLang>(
|
||||
const [editLang, setEditLangState] = useState<EditLang>(
|
||||
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
|
||||
)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
@@ -74,24 +97,39 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const toggleEditLang = useCallback(() => {
|
||||
setEditLang((v) => {
|
||||
setEditLangState((v) => {
|
||||
const next: EditLang = v === 'fa' ? 'en' : 'fa'
|
||||
sessionStorage.setItem('aramesh_edit_lang', next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
// بدون بازنویسی انتخاب دستی وقتی مقدار همان است — تا اثر همگامسازی بیدلیل رندر نکند
|
||||
const syncEditLang = useCallback((next: EditLang) => {
|
||||
setEditLangState((v) => {
|
||||
if (v === next) return v
|
||||
sessionStorage.setItem('aramesh_edit_lang', next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string): Promise<AdminRole> => {
|
||||
await adminApi.login(username, password)
|
||||
const nextRole = roleFromToken(getToken()) ?? 'admin'
|
||||
sessionStorage.setItem('aramesh_role', nextRole)
|
||||
setRole(nextRole)
|
||||
setIsAuthenticated(true)
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویس مقالهها هم ادغام شوند
|
||||
reload()
|
||||
return nextRole
|
||||
}, [reload])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null)
|
||||
setIsAuthenticated(false)
|
||||
setIsEditMode(false)
|
||||
setRole(null)
|
||||
sessionStorage.removeItem('aramesh_role')
|
||||
sessionStorage.removeItem('aramesh_edit_mode')
|
||||
// داده سایت دوباره بارگذاری میشود تا پیشنویسها از درخت داده حذف شوند
|
||||
reload()
|
||||
@@ -129,6 +167,21 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}, [isEditMode])
|
||||
|
||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||
useEffect(() => {
|
||||
const syncAuth = () => {
|
||||
setIsAuthenticated(!!getToken())
|
||||
setRole(roleFromToken(getToken()))
|
||||
}
|
||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.addEventListener('storage', syncAuth)
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.removeEventListener('storage', syncAuth)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saving') return
|
||||
@@ -139,9 +192,12 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('beforeunload', guard)
|
||||
}, [saveStatus])
|
||||
|
||||
const canManageSite = role === 'admin'
|
||||
const value = useMemo<AdminContextValue>(
|
||||
() => ({
|
||||
isAuthenticated,
|
||||
role,
|
||||
canManageSite,
|
||||
isEditMode,
|
||||
toggleEditMode,
|
||||
login,
|
||||
@@ -150,8 +206,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
runSave,
|
||||
editLang,
|
||||
toggleEditLang,
|
||||
syncEditLang,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||
[isAuthenticated, role, canManageSite, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang, syncEditLang],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
@@ -189,7 +246,7 @@ export function useFieldSaver() {
|
||||
}
|
||||
await adminApi.patch(resource, id, changes)
|
||||
updateSite((d) => {
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
let node: Record<string, unknown> | undefined
|
||||
if (config) {
|
||||
const target = d[config.dataKey as keyof SiteData] as unknown
|
||||
|
||||
@@ -20,8 +20,8 @@ export function AdminLoginPage() {
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
navigate('/')
|
||||
const nextRole = await login(username.trim(), password)
|
||||
navigate(nextRole === 'creator' ? '/admin/write' : '/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useController, useForm } from 'react-hook-form'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||
import { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
@@ -8,28 +9,118 @@ import { adminApi } from './adminClient'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import type { ArticleBodyDoc } from '../data/site'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||
import { ARTICLE_CATEGORIES } from './entities'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
import { formatReadingTime, readingTimeMinutes } from '../utils/readingTime'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
type Draft = {
|
||||
id: number
|
||||
slug: string
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||
*/
|
||||
type ArticleMetaForm = {
|
||||
title: string
|
||||
title_en: string
|
||||
date: string
|
||||
date_en: string
|
||||
category: string
|
||||
category_en: string
|
||||
image: string
|
||||
read: string
|
||||
read_en: string
|
||||
excerpt: string
|
||||
excerpt_en: string
|
||||
body: ArticleBodyDoc
|
||||
body_en: ArticleBodyDoc
|
||||
status: 'draft' | 'published'
|
||||
seo_keyphrase: string
|
||||
category: CategoryOption | null
|
||||
image_alt: string
|
||||
image_title: string
|
||||
}
|
||||
|
||||
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||
function toCategoryOption(value: string): CategoryOption | null {
|
||||
const category = (value ?? '').trim()
|
||||
return category ? { label: category, value: category } : null
|
||||
}
|
||||
|
||||
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||
function categoryValue(option: CategoryOption | null): string {
|
||||
return option?.value.trim() ?? ''
|
||||
}
|
||||
|
||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
return []
|
||||
}
|
||||
|
||||
/** یک ردیف پرسشوپاسخ مقاله */
|
||||
type ArticleFaq = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* پنل پرسشوپاسخهای پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر.
|
||||
* در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش میکند؛
|
||||
* نسخه انگلیسیِ خالی در سایت به فارسی برمیگردد. ذخیرهسازی خودکار است.
|
||||
*/
|
||||
function FaqPanel({
|
||||
faqs,
|
||||
onChange,
|
||||
}: {
|
||||
faqs: ArticleFaq[]
|
||||
onChange: (next: ArticleFaq[]) => void
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<ArticleFaq>) => {
|
||||
const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f))
|
||||
onChange(next)
|
||||
}
|
||||
const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index))
|
||||
const add = () => onChange([...faqs, { question: '', answer: '' }])
|
||||
|
||||
return (
|
||||
<div className="mt-4 grid gap-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i} className="grid gap-3 border border-ivory/10 bg-ink/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-light text-copper">پرسش {i + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
aria-label={`حذف پرسش ${i + 1}`}
|
||||
className="flex h-7 w-7 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={faq.question}
|
||||
onChange={(e) => update(i, { question: e.target.value })}
|
||||
placeholder="پرسش مخاطب…"
|
||||
aria-label={`متن پرسش ${i + 1}`}
|
||||
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
dir="auto"
|
||||
value={faq.answer}
|
||||
onChange={(e) => update(i, { answer: e.target.value })}
|
||||
placeholder="پاسخ کوتاه و روشن…"
|
||||
aria-label={`پاسخ پرسش ${i + 1}`}
|
||||
className="w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="flex w-fit items-center gap-2 border border-dashed border-ivory/25 px-4 py-2 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
افزودن پرسشوپاسخ
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
@@ -43,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, syncEditLang, role } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -57,11 +148,16 @@ export function ArticleWritePage() {
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
date_en: '',
|
||||
category: 'یادداشت',
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
category_en: '',
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
read_en: '',
|
||||
excerpt: '',
|
||||
@@ -71,7 +167,31 @@ export function ArticleWritePage() {
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||||
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||||
*/
|
||||
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||||
const watchedTitle = watch('title')
|
||||
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||||
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||||
|
||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||
/**
|
||||
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||
*/
|
||||
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const draftRef = useRef(draft)
|
||||
draftRef.current = draft
|
||||
@@ -99,19 +219,47 @@ export function ArticleWritePage() {
|
||||
|
||||
const article = existing ?? fetched
|
||||
|
||||
// زبان ویرایش این مقاله بهازای خودش بهخاطر سپرده میشود — رفرش تب را عوض نکند
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const saved = localStorage.getItem(`aramesh_write_lang_${id}`)
|
||||
if (saved === 'en' || saved === 'fa') {
|
||||
syncEditLang(saved)
|
||||
return
|
||||
}
|
||||
const art = existing ?? null
|
||||
if (art) {
|
||||
const hasEn = Boolean((art as Draft).title_en?.trim()) || Boolean((art as Draft).body_en as unknown)
|
||||
const hasFa = Boolean((art as Draft).title?.trim())
|
||||
if (hasEn && !hasFa) syncEditLang('en')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, Boolean(article)])
|
||||
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...(article as unknown as Draft),
|
||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
||||
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
body: toArticleBodyDoc(article.body),
|
||||
body_en:
|
||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||
? (rawEnBody as ArticleBodyDoc)
|
||||
: EMPTY_DOC,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||
setLoadedContentKey(article.id)
|
||||
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||||
reset({
|
||||
title: article.title ?? '',
|
||||
category: toCategoryOption(article.category ?? ''),
|
||||
image_alt: article.image_alt ?? '',
|
||||
image_title: article.image_title ?? '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [article?.id])
|
||||
|
||||
@@ -196,7 +344,7 @@ export function ArticleWritePage() {
|
||||
setDeleting(true)
|
||||
await runSave(async () => {
|
||||
await adminApi.remove('articles', createdId)
|
||||
navigate('/blog')
|
||||
navigate(`${editLang === 'en' ? '/en' : ''}/blog`)
|
||||
})
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -209,14 +357,32 @@ export function ArticleWritePage() {
|
||||
|
||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||
// مقدار فعال فیلد دوزبانه — برای value ورودیها و چکلیست سئو
|
||||
// جهت ویرایشگر محتوایی با زبان ویرایش همگام میشود — صفحهٔ انگلیسی LTR بماند
|
||||
useEffect(() => {
|
||||
const el = document.querySelector('.article-body[data-wysiwyg="true"]')
|
||||
if (el) {
|
||||
el.setAttribute('dir', editLang === 'en' ? 'ltr' : 'rtl')
|
||||
el.setAttribute('lang', editLang)
|
||||
}
|
||||
}, [editLang, createdId])
|
||||
|
||||
const activeVal = (name: 'title' | 'slug' | 'author' | 'seo_keyphrase' | 'meta_description' | 'excerpt'): string =>
|
||||
String((editLang === 'en' ? draft[`${name}_en` as keyof Draft] : draft[name]) ?? '')
|
||||
const metaLength = activeVal('meta_description').trim().length
|
||||
|
||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
<div dir={editLang === 'en' ? 'ltr' : 'rtl'} className="min-h-screen bg-ink font-pinar text-ivory">
|
||||
{/* نوار بالا */}
|
||||
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
|
||||
<Link
|
||||
to="/blog"
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
|
||||
>
|
||||
@@ -227,7 +393,11 @@ export function ArticleWritePage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleEditLang}
|
||||
onClick={() => {
|
||||
const next = editLang === 'en' ? 'fa' : 'en'
|
||||
toggleEditLang()
|
||||
if (createdId) localStorage.setItem(`aramesh_write_lang_${createdId}`, next)
|
||||
}}
|
||||
title="تعیین میکند متن مقاله روی کدام زبان ذخیره شود"
|
||||
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
|
||||
editLang === 'en'
|
||||
@@ -259,6 +429,7 @@ export function ArticleWritePage() {
|
||||
</span>
|
||||
{createdId && (
|
||||
<>
|
||||
{role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
|
||||
@@ -270,14 +441,16 @@ export function ArticleWritePage() {
|
||||
>
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${editLang === 'en' ? draft.slug_en || draft.slug : draft.slug}`}
|
||||
data-admin-link
|
||||
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||
>
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
دیدن در سایت
|
||||
</Link>
|
||||
{role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
@@ -287,6 +460,7 @@ export function ArticleWritePage() {
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -302,92 +476,223 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جاینگهدار میماند */}
|
||||
<input
|
||||
type="text"
|
||||
{...textField(suf('title'))}
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||||
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="text"
|
||||
value={editLang === 'en' ? String(draft.title_en ?? '') : field.value}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
queueSave({ [suf('title')]: event.target.value })
|
||||
}}
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
aria-label="عنوان مقاله"
|
||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4 h-px w-28 bg-copper" />
|
||||
|
||||
{/* تصویر شاخص */}
|
||||
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||||
<div className="mt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص مقاله"
|
||||
value={draft.image}
|
||||
alt={imageAltField.value}
|
||||
altFallback={imageAltFallback}
|
||||
title={imageTitleField.value}
|
||||
onSave={async (url) => {
|
||||
queueSave({ image: url })
|
||||
return true
|
||||
}}
|
||||
onSaveAlt={async (alt) => {
|
||||
imageAltField.onChange(alt)
|
||||
queueSave({ image_alt: alt })
|
||||
return true
|
||||
}}
|
||||
onSaveTitle={async (imageTitle) => {
|
||||
imageTitleField.onChange(imageTitle)
|
||||
queueSave({ image_title: imageTitle })
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
contentLang={editLang}
|
||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||
onChange={(json) => queueSave({ [suf('body')]: json })}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||
const minutes = readingTimeMinutes(json)
|
||||
if (editLang === 'en') {
|
||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||
} else {
|
||||
// formatReadingTime خودش برچسب «مطالعه» را دارد — پسوند اضافه ممنوع
|
||||
queueSave({ body: json, read: formatReadingTime(json) })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* توضیحات متای سئو */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
|
||||
<span
|
||||
className={`text-[11px] font-light ${
|
||||
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
|
||||
}`}
|
||||
>
|
||||
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده میشود (۱۵۰ تا ۱۶۰ نویسه).
|
||||
</p>
|
||||
<textarea
|
||||
id="meta-description"
|
||||
rows={3}
|
||||
value={activeVal('meta_description')}
|
||||
onChange={(e) => queueSave({ [suf('meta_description')]: e.target.value })}
|
||||
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
|
||||
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* پرسشوپاسخهای پایان مقاله — سئوی FAQPage */}
|
||||
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||
<h2 className="text-sm font-bold text-copper">پرسشهای متداول مقاله</h2>
|
||||
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||
در پایان مقاله با ظاهر آکاردئونی سایت نمایش داده میشود و با اسکیمای FAQPage
|
||||
شانس نمایش در نتایج غنی گوگل را بالا میبرد.
|
||||
{editLang === 'en' && ' — ویرایش نسخه انگلیسی (خالی = نمایش متن فارسی)'}
|
||||
</p>
|
||||
<FaqPanel
|
||||
faqs={(editLang === 'en' ? draft.faqs_en : draft.faqs) ?? []}
|
||||
onChange={(next) => queueSave({ [suf('faqs')]: next })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* چکیده و متادیتا */}
|
||||
<SeoChecklist
|
||||
input={{
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
excerpt: draft.excerpt,
|
||||
body: draft.body,
|
||||
keyphrase: draft.seo_keyphrase,
|
||||
title: activeVal('title'),
|
||||
slug: activeVal('slug'),
|
||||
excerpt: activeVal('meta_description') || activeVal('excerpt'),
|
||||
body: (editLang === 'en' ? draft.body_en : draft.body) ?? draft.body,
|
||||
keyphrase: activeVal('seo_keyphrase'),
|
||||
author: draft.author,
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2>
|
||||
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دستهبندی و هشتگها</h2>
|
||||
<div className="mt-5 grid gap-5">
|
||||
<div>
|
||||
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمیشود)</label>
|
||||
<input
|
||||
id="seo-keyphrase"
|
||||
type="text"
|
||||
value={draft.seo_keyphrase}
|
||||
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
|
||||
value={activeVal('seo_keyphrase')}
|
||||
onChange={(e) => queueSave({ [suf('seo_keyphrase')]: e.target.value })}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField(suf('excerpt'))}
|
||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField(suf('excerpt'))}
|
||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارتهای وبلاگ…'}
|
||||
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
|
||||
<input
|
||||
id="article-author"
|
||||
type="text"
|
||||
{...textField(suf('author'))}
|
||||
placeholder="نام و نام خانوادگی نویسنده"
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">دسته</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField(suf('category'))}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<label className="text-xs font-light text-ivory/50">
|
||||
دستهبندی مقاله (Category){editLang === 'en' ? ' — نسخه انگلیسی' : ''}
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
{editLang === 'en' ? (
|
||||
<input
|
||||
type="text"
|
||||
{...textField('category_en')}
|
||||
placeholder={draft.category ? `English category (فارسی: ${draft.category})…` : 'English category…'}
|
||||
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CategorySelect
|
||||
value={field.value}
|
||||
options={ARTICLE_CATEGORIES}
|
||||
onChange={(option) => {
|
||||
field.onChange(option)
|
||||
queueSave({ category: categoryValue(option) })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">تاریخ</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField(suf('date'))}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
<label className="text-xs font-light text-ivory/50">هشتگها / برچسبها</label>
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect
|
||||
value={normalizeTags(editLang === 'en' ? draft.tags_en : draft.tags)}
|
||||
onChange={(tags) => queueSave({ [suf('tags')]: tags })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
id="article-date"
|
||||
value={draft.date ?? ''}
|
||||
onChange={(isoDate) => queueSave({ date: isoDate })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<input
|
||||
type="text"
|
||||
{...textField(suf('read'))}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
disabled={editLang !== 'en'}
|
||||
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
|
||||
className={`mt-2 w-full text-sm font-light ${
|
||||
editLang === 'en'
|
||||
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
|
||||
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -395,7 +700,7 @@ export function ArticleWritePage() {
|
||||
<input
|
||||
type="text"
|
||||
dir="ltr"
|
||||
{...textField('slug')}
|
||||
{...textField(suf('slug'))}
|
||||
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Swal from 'sweetalert2'
|
||||
@@ -7,14 +8,14 @@ import { useSiteData } from '../api'
|
||||
import type { SiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ENTITIES } from './entities'
|
||||
import { entityConfig } from './entities'
|
||||
import { EntityDrawer } from './EntityDrawer'
|
||||
|
||||
type ItemWithId = Record<string, unknown> & { id: number }
|
||||
|
||||
function useEntityList(resource: string): ItemWithId[] {
|
||||
const { data } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
|
||||
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
|
||||
}
|
||||
@@ -69,14 +70,14 @@ export function EditableItem({
|
||||
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت میکند (مثل صفحه نوشتن مقاله) */
|
||||
editHref?: string
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
const list = useEntityList(resource)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
if (!config) return <>{children}</>
|
||||
if (!isAuthenticated || !isEditMode) return <>{children}</>
|
||||
if (!isAuthenticated || !isEditMode || !canManageSite) return <>{children}</>
|
||||
|
||||
const index = list.findIndex((x) => x.id === item.id)
|
||||
|
||||
@@ -163,17 +164,21 @@ export function AddItemButton({
|
||||
resource,
|
||||
label,
|
||||
variant = 'cell',
|
||||
/** در صورت ارسال، پس از ساختهشدن رکورد به صفحهٔ ویرایش اختصاصی هدایت میشود (کشو باز نمیشود) */
|
||||
editHrefBase,
|
||||
}: {
|
||||
resource: string
|
||||
label?: string
|
||||
variant?: 'cell' | 'button'
|
||||
editHrefBase?: string
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||
const navigate = useNavigate()
|
||||
const { updateSite } = useSiteData()
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||
|
||||
const create = () => {
|
||||
void runSave(async () => {
|
||||
@@ -183,6 +188,10 @@ export function AddItemButton({
|
||||
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
|
||||
if (Array.isArray(list)) list.push(created)
|
||||
})
|
||||
if (editHrefBase) {
|
||||
navigate(`${editHrefBase}/${created.id}`)
|
||||
return
|
||||
}
|
||||
setNewItem(created)
|
||||
})
|
||||
}
|
||||
@@ -240,13 +249,13 @@ export function SingletonPill({
|
||||
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
|
||||
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
}) {
|
||||
const { isAuthenticated, isEditMode, runSave } = useAdmin()
|
||||
const config = ENTITIES[resource]
|
||||
const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
|
||||
const config = entityConfig(resource)
|
||||
const { data, updateSite } = useSiteData()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [created, setCreated] = useState<ItemWithId | null>(null)
|
||||
|
||||
if (!config || !isAuthenticated || !isEditMode) return null
|
||||
if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
|
||||
const item =
|
||||
created ??
|
||||
(select
|
||||
|
||||
@@ -6,8 +6,11 @@ import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { editableFields } from './entities'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||
|
||||
type EntityDrawerProps = {
|
||||
@@ -117,22 +120,46 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{fields.map((field) => (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{fields.map((field) => {
|
||||
const isImage = field.type === 'image'
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey = isImage
|
||||
? field.name === 'src' && 'alt' in item
|
||||
? 'alt'
|
||||
: `${field.name}_alt`
|
||||
: ''
|
||||
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||
const imageAltFallback = [
|
||||
String(item[config.titleKey] ?? '').trim(),
|
||||
field.altFallbackSuffix,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
return (
|
||||
<FieldControl
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={imageAltFallback}
|
||||
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||
onSave={(value) => saveField(field.name, value)}
|
||||
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||
onSaveImageTitle={
|
||||
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
||||
}
|
||||
registerFlush={
|
||||
field.type === 'wysiwyg'
|
||||
? (fn) => {
|
||||
flushRef.current = fn
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,19 +184,36 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
placeholder,
|
||||
imageTitleValue,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
onSaveImageTitle,
|
||||
registerFlush,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||
placeholder?: unknown
|
||||
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||
imageTitleValue?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||
onSaveImageTitle?: (title: string) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
}) {
|
||||
const [text, setText] = useState<string>(String(value ?? ''))
|
||||
|
||||
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||
useEffect(() => {
|
||||
setText(String(value ?? ''))
|
||||
}, [value])
|
||||
|
||||
const commitText = async () => {
|
||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||
if (String(value ?? '') === String(next)) return
|
||||
@@ -177,7 +221,61 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'image') {
|
||||
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
|
||||
return (
|
||||
<ImageField
|
||||
label={field.label}
|
||||
value={String(value ?? '')}
|
||||
alt={altValue}
|
||||
altFallback={altFallback}
|
||||
title={imageTitleValue}
|
||||
onSave={(url) => onSave(url)}
|
||||
onSaveAlt={onSaveAlt}
|
||||
onSaveTitle={onSaveImageTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
||||
const selectProps = {
|
||||
value: text,
|
||||
options: field.options ?? [],
|
||||
onChange: (next: string) => {
|
||||
setText(next)
|
||||
void onSave(next)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
{field.creatable ? (
|
||||
<LuxeCreatableSelect {...selectProps} />
|
||||
) : (
|
||||
<LuxeSelect {...selectProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<JalaliDatePicker
|
||||
value={String(value ?? '')}
|
||||
onChange={(isoDate) => {
|
||||
setText(isoDate)
|
||||
void onSave(isoDate)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
@@ -193,7 +291,11 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
@@ -230,6 +332,34 @@ function FieldControl({
|
||||
)
|
||||
}
|
||||
|
||||
/** فیلد هشتگها — react-select قابل ساخت (Creatable) با ذخیره فوری */
|
||||
function TagsField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
}) {
|
||||
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
const update = (next: string[]) => {
|
||||
setTags(next)
|
||||
void onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||
<div className="mt-2">
|
||||
<LuxeTagsSelect value={tags} onChange={update} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** ویرایش آرایه خطی (پاراگرافها، گالری، نکات) — هر تغییر ذخیره میشود */
|
||||
function ListField({
|
||||
field,
|
||||
@@ -309,10 +439,9 @@ function WysiwygField({
|
||||
registerFlush?: (fn: () => void) => void
|
||||
hint?: string
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
() => toArticleBodyDoc(value),
|
||||
)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowRightIcon, Trash2Icon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
import { GalleryField } from '../components/admin/GalleryField'
|
||||
|
||||
type ProjectDraft = {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
title_en: string
|
||||
location: string
|
||||
location_en: string
|
||||
year: string
|
||||
year_en: string
|
||||
status: 'done' | 'progress'
|
||||
project_type: string
|
||||
project_type_en: string
|
||||
usage: string
|
||||
usage_en: string
|
||||
area: string
|
||||
area_en: string
|
||||
floors: string
|
||||
floors_en: string
|
||||
units: string
|
||||
units_en: string
|
||||
client: string
|
||||
client_en: string
|
||||
summary: string
|
||||
summary_en: string
|
||||
body: string[]
|
||||
body_en: string[]
|
||||
image: string
|
||||
gallery: string[]
|
||||
}
|
||||
|
||||
const EMPTY: ProjectDraft = {
|
||||
id: 0,
|
||||
slug: `project-${Date.now()}`,
|
||||
title: '', title_en: '',
|
||||
location: '', location_en: '',
|
||||
year: '', year_en: '',
|
||||
status: 'done',
|
||||
project_type: '', project_type_en: '',
|
||||
usage: '', usage_en: '',
|
||||
area: '', area_en: '',
|
||||
floors: '', floors_en: '',
|
||||
units: '', units_en: '',
|
||||
client: '', client_en: '',
|
||||
summary: '', summary_en: '',
|
||||
body: [], body_en: [],
|
||||
image: '', gallery: [],
|
||||
}
|
||||
|
||||
/** body ممکن است از ردیفهای قدیمی/ناقص غیرآرایه باشد (مثلاً {} یا رشته) — همیشه آرایهٔ رشته */
|
||||
function asLines(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((line) => String(line))
|
||||
if (typeof value === 'string') return value.split('\n')
|
||||
return []
|
||||
}
|
||||
|
||||
const INPUT =
|
||||
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||
|
||||
/** جفت دوزبانه — فارسی و English همیشه هر دو دیده میشوند و با یک ذخیره ثبت میشوند */
|
||||
function Pair({
|
||||
label,
|
||||
fa,
|
||||
en,
|
||||
textarea = false,
|
||||
}: {
|
||||
label: string
|
||||
fa: { value: string; onChange: (v: string) => void }
|
||||
en: { value: string; onChange: (v: string) => void }
|
||||
textarea?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||
<div className="mt-1.5 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-[10px] font-light text-ivory/40">فارسی</span>
|
||||
{textarea ? (
|
||||
<textarea dir="rtl" rows={4} className={`${INPUT} mt-1 leading-8`} {...fa} />
|
||||
) : (
|
||||
<input dir="rtl" type="text" className={`${INPUT} mt-1`} {...fa} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-light text-ivory/40">English</span>
|
||||
{textarea ? (
|
||||
<textarea dir="ltr" rows={4} className={`${INPUT} mt-1 leading-8`} {...en} />
|
||||
) : (
|
||||
<input dir="ltr" type="text" className={`${INPUT} mt-1`} {...en} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** صفحهٔ اختصاصی ویرایش/ساخت پروژه — بهجای کشو؛ تصاویر فقط با آپلود به MinIO */
|
||||
export function ProjectWritePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const isNew = !id || id === 'new'
|
||||
const numericId = Number(id)
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
const { isAuthenticated, runSave } = useAdmin()
|
||||
|
||||
const existing = !isNew && data?.projects.find((p) => p.id === numericId)
|
||||
const [fetched, setFetched] = useState<ProjectDraft | null>(null)
|
||||
const [draft, setDraft] = useState<ProjectDraft>(EMPTY)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// اگر پروژه در دادهٔ سایت نبود (پیشنویس تازه و… ) مستقیم از مسیر مدیریتی خوانده میشود
|
||||
useEffect(() => {
|
||||
if (isNew || existing) return
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.get<ProjectDraft>('projects', numericId)
|
||||
.then((p) => {
|
||||
if (!cancelled) setFetched(p)
|
||||
})
|
||||
.catch(() => {
|
||||
/* پروژه در دادهٔ سایت نبود — از مسیر مدیریتی خوانده میشود */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, existing])
|
||||
|
||||
const project = existing ?? fetched
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return
|
||||
setDraft({ ...EMPTY, ...(project as unknown as ProjectDraft) })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [(project as unknown as ProjectDraft | undefined)?.id])
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
|
||||
|
||||
const set = <K extends keyof ProjectDraft>(key: K, value: ProjectDraft[K]) =>
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
|
||||
const fa = (key: keyof ProjectDraft) => ({
|
||||
value: String(draft[key] ?? ''),
|
||||
onChange: (v: string) => set(key, v as ProjectDraft[typeof key]),
|
||||
})
|
||||
const en = (key: keyof ProjectDraft) => ({
|
||||
value: String(draft[`${String(key)}_en` as keyof ProjectDraft] ?? ''),
|
||||
onChange: (v: string) => set(`${String(key)}_en` as keyof ProjectDraft, v as ProjectDraft[typeof key]),
|
||||
})
|
||||
|
||||
const doSave = async () => {
|
||||
if (!draft.title.trim() || !draft.slug.trim()) {
|
||||
void Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'عنوان و نشانی (slug) لازم است',
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
confirmButtonText: 'باشه',
|
||||
buttonsStyling: false,
|
||||
customClass: { popup: 'font-pinar border border-ivory/10' },
|
||||
})
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const ok = await runSave(async () => {
|
||||
if (isNew || draft.id === 0) {
|
||||
const created = await adminApi.create<ProjectDraft>('projects', { ...draft })
|
||||
updateSite((d) => {
|
||||
const list = d.projects as unknown as ProjectDraft[]
|
||||
if (Array.isArray(list)) list.push(created)
|
||||
})
|
||||
navigate(`/admin/projects/${created.id}`, { replace: true })
|
||||
} else {
|
||||
await adminApi.patch('projects', draft.id, { ...draft })
|
||||
updateSite((d) => {
|
||||
const list = d.projects as unknown as ProjectDraft[]
|
||||
const index = list.findIndex((x) => x.id === draft.id)
|
||||
if (index >= 0) list[index] = { ...list[index], ...draft }
|
||||
})
|
||||
}
|
||||
})
|
||||
setSaving(false)
|
||||
// ذخیرهٔ موفق پروژهٔ موجود → بازگشت به فهرست؛ بیصدا ماندن صفحه «ذخیره نشد» القا میکرد
|
||||
if (ok && !isNew && draft.id !== 0) navigate('/projects', { replace: true })
|
||||
}
|
||||
|
||||
const doDelete = async () => {
|
||||
if (isNew || draft.id === 0) return
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'حذف پروژه',
|
||||
text: `«${draft.title}» برای همیشه حذف شود؟`,
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'حذف',
|
||||
cancelButtonText: 'انصراف',
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
popup: 'font-pinar border border-ivory/10',
|
||||
confirmButton: 'bg-red-500 text-white font-bold px-8 py-2.5 mx-2',
|
||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
||||
},
|
||||
})
|
||||
if (!result.isConfirmed) return
|
||||
await runSave(async () => {
|
||||
await adminApi.remove('projects', draft.id)
|
||||
updateSite((d) => {
|
||||
const list = d.projects as unknown as ProjectDraft[]
|
||||
const index = list.findIndex((x) => x.id === draft.id)
|
||||
if (index >= 0) list.splice(index, 1)
|
||||
})
|
||||
navigate('/projects', { replace: true })
|
||||
})
|
||||
}
|
||||
|
||||
/** تصویر شاخص و گالری بلافاصله ذخیره میشوند (پروژهٔ موجود)؛ در پروژهٔ تازه تا اولین ذخیره در فرم میمانند */
|
||||
const persistImage = async (url: string) => {
|
||||
setDraft((d) => ({ ...d, image: url }))
|
||||
if (!isNew && draft.id !== 0) {
|
||||
await adminApi.patch('projects', draft.id, { image: url })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const persistGallery = async (urls: string[]) => {
|
||||
setDraft((d) => ({ ...d, gallery: urls }))
|
||||
if (!isNew && draft.id !== 0) {
|
||||
await adminApi.patch('projects', draft.id, { gallery: urls })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-ink pb-24 pt-24">
|
||||
<div className="mx-auto max-w-4xl px-6 lg:px-10">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<Link
|
||||
to="/projects"
|
||||
data-admin-link
|
||||
className="inline-flex items-center gap-2 text-xs font-light text-ivory/50 transition-colors hover:text-copper"
|
||||
>
|
||||
<ArrowRightIcon className="h-3.5 w-3.5" />
|
||||
بازگشت به پروژهها
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-bold text-ivory">
|
||||
{isNew ? 'پروژهٔ جدید' : `ویرایش «${draft.title}»`}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isNew && draft.id !== 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void doDelete()}
|
||||
className="flex items-center gap-2 border border-red-400/40 px-5 py-2.5 text-sm font-light text-red-300 transition-colors duration-200 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
حذف پروژه
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void doSave()}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-copper px-8 py-2.5 text-sm font-bold text-char transition-transform duration-200 hover:scale-105 disabled:opacity-60"
|
||||
>
|
||||
ذخیرهٔ پروژه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 space-y-8">
|
||||
<Pair label="عنوان پروژه" fa={fa('title')} en={en('title')} />
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">
|
||||
نشانی (slug) <span className="font-light text-ivory/40">— در نشانی صفحه: /projects/…</span>
|
||||
</p>
|
||||
<input
|
||||
dir="ltr"
|
||||
type="text"
|
||||
className={`${INPUT} mt-1.5`}
|
||||
value={draft.slug}
|
||||
onChange={(e) => set('slug', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-ivory/80">وضعیت</p>
|
||||
<select
|
||||
className={`${INPUT} mt-1.5`}
|
||||
value={draft.status}
|
||||
onChange={(e) => set('status', e.target.value as ProjectDraft['status'])}
|
||||
>
|
||||
<option value="done">انجامشده (done)</option>
|
||||
<option value="progress">در حال اجرا (progress)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pair label="موقعیت" fa={fa('location')} en={en('location')} />
|
||||
<Pair label="سال" fa={fa('year')} en={en('year')} />
|
||||
<Pair label="نوع پروژه" fa={fa('project_type')} en={en('project_type')} />
|
||||
<Pair label="کاربری" fa={fa('usage')} en={en('usage')} />
|
||||
<Pair label="زیربنا" fa={fa('area')} en={en('area')} />
|
||||
<Pair label="تعداد طبقات" fa={fa('floors')} en={en('floors')} />
|
||||
<Pair label="واحدها" fa={fa('units')} en={en('units')} />
|
||||
<Pair label="کارفرما" fa={fa('client')} en={en('client')} />
|
||||
<Pair label="خلاصه" fa={fa('summary')} en={en('summary')} textarea />
|
||||
<Pair
|
||||
label="متن پروژه — هر پاراگراف یک خط"
|
||||
fa={{
|
||||
value: asLines(draft.body).join('\n'),
|
||||
onChange: (v) => set('body', v.split('\n')),
|
||||
}}
|
||||
en={{
|
||||
value: asLines(draft.body_en).join('\n'),
|
||||
onChange: (v) => set('body_en', v.split('\n')),
|
||||
}}
|
||||
textarea
|
||||
/>
|
||||
|
||||
<div className="border-t border-ivory/10 pt-8">
|
||||
<ImageField
|
||||
label="تصویر شاخص"
|
||||
value={draft.image}
|
||||
onSave={persistImage}
|
||||
altFallback={`پروژه ${draft.title}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-ivory/10 pt-8">
|
||||
<GalleryField
|
||||
label="گالری تصاویر پروژه — فقط آپلود به MinIO"
|
||||
value={asLines(draft.gallery)}
|
||||
onSave={persistGallery}
|
||||
namePrefix={draft.slug || 'project'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,17 @@ import { API_URL } from '../config'
|
||||
|
||||
const TOKEN_KEY = 'aramesh_admin_token'
|
||||
|
||||
/** سقف آپلود — هماندازهٔ سقف کاربر در بکاند (media_max_upload_mb = ۳۵ با احتساب سربار multipart) */
|
||||
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||
|
||||
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
||||
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string | null): void => {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
@@ -39,9 +46,11 @@ async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
||||
const detail =
|
||||
typeof data?.detail === 'string'
|
||||
? data.detail
|
||||
: res.status === 409
|
||||
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||
: res.status === 413
|
||||
? 'حجم فایل بیش از حد مجاز است'
|
||||
: res.status === 409
|
||||
? 'رکوردی با این مقدار تکراری وجود دارد'
|
||||
: 'خطا در ذخیرهسازی — دوباره تلاش کنید'
|
||||
throw new HttpError(res.status, detail)
|
||||
}
|
||||
return res.status === 204 ? (null as T) : ((await res.json()) as T)
|
||||
@@ -83,15 +92,44 @@ export const adminApi = {
|
||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||
|
||||
upload: async (file: File): Promise<{ url: string; key: string }> => {
|
||||
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
|
||||
// پیشچک سمت کاربر — بازخورد فوری بهجای ۴۱۳ لبه/CDN که بهصورت HTML میرسد
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
throw new HttpError(413, 'حجم فایل بیش از ۳۰ مگابایت است — فایل را کوچکتر کنید')
|
||||
}
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (name && name.trim()) fd.append('name', name.trim())
|
||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||
},
|
||||
|
||||
/** درونریزی تصویر از نشانی خارجی — دانلود و ذخیره در Object Storage خود سایت */
|
||||
importImage: async (url: string): Promise<{ url: string; key: string }> =>
|
||||
authFetch('/api/admin/import-image', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}),
|
||||
|
||||
// خروجی CSV — فایل تولیدی بکاند (با BOM برای Excel) مستقیم دانلود میشود
|
||||
downloadCsv: async (kind: 'messages' | 'subscribers'): Promise<void> => {
|
||||
const path = kind === 'messages' ? 'messages/export.csv' : 'subscribers/export.csv'
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_URL}/api/admin/${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new HttpError(res.status, 'دریافت خروجی CSV ناموفق بود')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const disposition = res.headers.get('Content-Disposition') ?? ''
|
||||
a.download = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `${kind}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,40 +1,60 @@
|
||||
import type { SiteData } from '../api'
|
||||
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
||||
|
||||
export type FieldConfig = {
|
||||
name: string
|
||||
label: string
|
||||
type: FieldType
|
||||
hint?: string
|
||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||
options?: string[]
|
||||
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||
creatable?: boolean
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||
shared?: boolean
|
||||
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||
altFallbackSuffix?: string
|
||||
}
|
||||
|
||||
export type EntityConfig = {
|
||||
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
|
||||
resource: string
|
||||
/** کلید همان منبع در پاسخ GET /api/site */
|
||||
dataKey: string
|
||||
title: string
|
||||
/** مجموعه یا رکورد یگانه */
|
||||
kind: 'collection' | 'singleton'
|
||||
/** فیلدی که عنوان آیتم را نشان میدهد */
|
||||
titleKey: string
|
||||
fields: FieldConfig[]
|
||||
/** مقادیر پیشفرض برای «افزودن» */
|
||||
defaults: Record<string, unknown>
|
||||
}
|
||||
|
||||
const F = (name: string, label: string, type: FieldType = 'text', hint?: string, shared = false): FieldConfig => ({
|
||||
const F = (
|
||||
name: string,
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
sharedOrExtra?: boolean | Partial<FieldConfig>,
|
||||
): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
shared,
|
||||
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
|
||||
? sharedOrExtra
|
||||
: { shared: !!sharedOrExtra }),
|
||||
})
|
||||
|
||||
/** پیکربندی همه منابع قابل ویرایش — هم مجموعهها و هم رکوردهای یگانه */
|
||||
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||
export const ARTICLE_CATEGORIES = [
|
||||
'معماری و ساخت',
|
||||
'سرمایهگذاری ملکی',
|
||||
'طراحی داخلی',
|
||||
'اخبار و اطلاعیهها',
|
||||
'یادداشت مدیریت',
|
||||
]
|
||||
|
||||
export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
@@ -49,7 +69,10 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('land_area', 'مساحت عرصه (زمین)'), F('area', 'مساحت زیربنا (اعیان)'),
|
||||
F('usage', 'کاربری ساختمان'), F('pilot_floors', 'طبقات روی همکف (پیلوت)'), F('floors', 'تعداد طبقات'),
|
||||
F('units_residential', 'تعداد واحد مسکونی'), F('units_commercial', 'تعداد واحد تجاری'), F('units_office', 'تعداد واحد اداری'),
|
||||
F('project_type', 'نوع پروژه'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
|
||||
@@ -60,14 +83,33 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده', 'textarea'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true),
|
||||
F('category', 'دسته'),
|
||||
F('date', 'تاریخ'),
|
||||
F('read', 'زمان مطالعه'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||
F('author', 'نویسنده', 'text', undefined, true),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('slug', 'نشانی (slug)', 'text', undefined, true),
|
||||
],
|
||||
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
meta_description_en: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
|
||||
@@ -135,7 +177,27 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
|
||||
'status-labels': {
|
||||
resource: 'status-labels', dataKey: 'status_labels', title: 'برچسبهای وضعیت پروژه', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
// نبودِ این تعریف یعنی isTranslatable همیشه false برمیگشت و ویرایشِ برچسب روی
|
||||
// صفحهٔ انگلیسی بهجای ستون label_en ستون فارسی را بازنویسی میکرد
|
||||
fields: [F('label', 'برچسب'), F('key', 'کلید وضعیت', 'text', 'done یا progress', true)],
|
||||
defaults: { key: 'done', label: 'برچسب جدید' },
|
||||
},
|
||||
'section-headings': {
|
||||
resource: 'section-headings', dataKey: 'section_headings', title: 'تیتر بخشها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
// تیتر/توضیح بخشها فقط درجا (InlineEdit) ویرایش میشوند — نبودِ تعریف یعنی
|
||||
// ویرایش روی صفحهٔ انگلیسی بهجای ستونهای _en ستون فارسی را بازنویسی میکرد
|
||||
fields: [
|
||||
F('key', 'کلید بخش', 'text', 'services / featured_projects / gallery / timeline / why_us / testimonials / team / articles', true),
|
||||
F('label', 'برچسب بالای تیتر'), F('title', 'تیتر'), F('lead', 'توضیح', 'textarea'),
|
||||
F('image', 'تصویر', 'image', undefined, true),
|
||||
F('cta_label', 'متن دکمه'), F('cta_href', 'لینک دکمه', 'text', undefined, true),
|
||||
],
|
||||
defaults: { key: '', title: '', lead: '' },
|
||||
},
|
||||
seo_meta: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
@@ -146,20 +208,29 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
],
|
||||
defaults: { page_key: '', title: '', description: '' },
|
||||
},
|
||||
|
||||
// ---------- رکوردهای یگانه ----------
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پسزمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
|
||||
defaults: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
|
||||
// title و subtitle باید در فهرست باشند — تیتر و زیرتیتر درجا ویرایش میشوند و
|
||||
// نبودشان یعنی isTranslatable false برمیگرداند و ویرایش روی /en متن فارسی را بازنویسی میکرد
|
||||
fields: [F('title', 'تیتر اصلی'), F('title_highlight', 'واژه مسیِ تیتر'), F('subtitle', 'زیرتیتر', 'textarea'), F('kicker', 'متن بالای تیتر', 'textarea'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول')],
|
||||
defaults: {},
|
||||
},
|
||||
'page-heroes': {
|
||||
resource: 'page-heroes', dataKey: 'page_heroes', title: 'هیروی صفحهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('key', 'کلید صفحه', 'text', 'about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان'), F('lead', 'سرآغاز', 'textarea'), F('image', 'تصویر', 'image', undefined, true),
|
||||
],
|
||||
defaults: { key: 'about', title: 'عنوان صفحه', lead: '', image: '' },
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
@@ -200,10 +271,18 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
},
|
||||
}
|
||||
|
||||
/** تعریف موجودیت بر اساس resource (نشانی API) یا کلید فهرست — هر دو سبک در کد
|
||||
* استفاده میشوند: save()/isTranslatable با resource (why-us) و رجکسهای JSX با کلید
|
||||
* (why_us، contact_section، process_steps و…) — هر دو باید پیدا شوند وگرنه رندر
|
||||
* با «منبع ناشناخته» میشکند. */
|
||||
export function entityConfig(resource: string): EntityConfig | undefined {
|
||||
return ENTITIES[resource] ?? Object.values(ENTITIES).find((c) => c.resource === resource)
|
||||
}
|
||||
|
||||
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
|
||||
export function isTranslatable(resource: string, name: string): boolean {
|
||||
if (name.endsWith('_en')) return false
|
||||
const config = ENTITIES[resource]
|
||||
const config = entityConfig(resource)
|
||||
return !!config?.fields.some((f) => f.name === name && !f.shared)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// لایهٔ نازک تحلیل رفتار — OpenPanel.
|
||||
//
|
||||
// نمونهٔ OpenPanel از قبل روی این سرور بالا است (stats.bennu-api-bahrestan.ir) و
|
||||
// پروژهٔ ERP هم از همان استفاده میکند؛ بهجای اجرای یک ابزار تحلیل دوم (Umami)،
|
||||
// همین یک نمونه با یک «پروژهٔ» دوم برای این سایت به کار میرود.
|
||||
//
|
||||
// اگر VITE_OPENPANEL_URL و VITE_OPENPANEL_CLIENT_ID در زمان بیلد تنظیم نشده باشند،
|
||||
// همهٔ فراخوانیها بیاثر و ارزاناند (مثل حالت فعلی Umami).
|
||||
//
|
||||
// مسیر Umami دستنخورده مانده تا اگر اسکریپت قدیمی جایی فعال بود از کار نیفتد.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
op?: (...args: unknown[]) => void
|
||||
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
|
||||
}
|
||||
}
|
||||
|
||||
const OP_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim()
|
||||
const OP_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim()
|
||||
const OP_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim()
|
||||
|
||||
// شیم صف OpenPanel — تا رسیدن op1.js، فراخوانیها در صف میمانند و بعد تخلیه میشوند.
|
||||
function loadOpenPanelScript(src: string): void {
|
||||
if (document.querySelector('script[data-openpanel]')) return
|
||||
const shim = document.createElement('script')
|
||||
shim.textContent =
|
||||
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();'
|
||||
document.head.appendChild(shim)
|
||||
const script = document.createElement('script')
|
||||
script.src = src
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.openpanel = '1'
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
/** یکبار در شروع اپ صدا زده میشود؛ بدون تنظیمات، هیچ کاری نمیکند. */
|
||||
export function initAnalytics(): void {
|
||||
if (!OP_URL || !OP_CLIENT_ID) return
|
||||
try {
|
||||
if (OP_SCRIPT_URL) loadOpenPanelScript(OP_SCRIPT_URL)
|
||||
window.op?.('init', {
|
||||
apiUrl: OP_URL,
|
||||
clientId: OP_CLIENT_ID,
|
||||
trackScreenViews: true, // بازدید صفحه → کدام صفحات دیده میشوند
|
||||
})
|
||||
} catch {
|
||||
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
|
||||
}
|
||||
}
|
||||
|
||||
/** رخداد سفارشی — در داشبورد OpenPanel نمایش داده میشود. */
|
||||
export function track(event: string, data?: Record<string, unknown>) {
|
||||
try {
|
||||
window.op?.('track', event, data ?? {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
window.umami?.track(event, data)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
import { getToken, setToken } from './admin/adminClient'
|
||||
import { localizeSite } from './i18n/localize'
|
||||
import { useLang } from './i18n/lang'
|
||||
|
||||
@@ -12,6 +12,7 @@ export type BrandInfo = {
|
||||
name: string
|
||||
name_en?: string
|
||||
logo: string
|
||||
logo_en?: string
|
||||
hero: string
|
||||
contact_bg: string
|
||||
phone: string
|
||||
@@ -230,10 +231,16 @@ interface SiteContextValue {
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
// پیشفرضهای بیاثر — مصرفکننده بیرون از Provider هم بدون خطا رندر میشود
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
reload: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
updateSite: () => {},
|
||||
})
|
||||
|
||||
/** سقف انتظار داده سایت — سرور بیپاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بیپایان */
|
||||
const SITE_TIMEOUT_MS = 15_000
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
@@ -241,29 +248,39 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const load = useCallback(() => {
|
||||
let cancelled = false
|
||||
setError(false)
|
||||
fetch(`${API_URL}/api/site`)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
||||
// درخواستهای سایت و پیشنویسهای ادمین «همزمان» شروع میشوند —
|
||||
// پشت سر هم بودنشان یعنی ادمین دو برابر دیرتر محتوا را میبیند.
|
||||
const draftsPromise: Promise<Article[] | null> = getToken()
|
||||
? fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 401) {
|
||||
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
||||
setToken(null)
|
||||
return null
|
||||
}
|
||||
return res.ok ? ((await res.json()) as Article[]) : null
|
||||
})
|
||||
.catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
|
||||
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
})
|
||||
.then(async (json) => {
|
||||
if (cancelled) return
|
||||
// برای ادمین واردشده، پیشنویس مقالهها از انتهای مدیریتی خوانده و ادغام میشوند —
|
||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند
|
||||
if (getToken()) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const all = (await res.json()) as Article[]
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||
json.articles = [...json.articles, ...drafts]
|
||||
}
|
||||
} catch {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
}
|
||||
// بکاند در GET /api/site فقط مقالههای منتشرشده را برمیگرداند —
|
||||
// پیشنویسها (برای ادمین) از درخواست موازی ادغام میشوند
|
||||
const drafts = await draftsPromise
|
||||
if (drafts) {
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
json.articles = [...json.articles, ...drafts.filter((a) => a.status === 'draft' && !published.has(a.id))]
|
||||
}
|
||||
if (!cancelled) {
|
||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||
@@ -275,6 +292,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,51 +1,54 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali, formatEnDate } from '../utils/date'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
const { t, href } = useLang()
|
||||
const { t, href, lang } = useLang()
|
||||
return (
|
||||
<Link
|
||||
to={href(`/blog/${article.slug}`)}
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
className="group flex h-full flex-col overflow-hidden rounded-xl md:rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="relative overflow-hidden aspect-[16/10] w-full">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
className="h-52 md:h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
<div className="absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-2.5 py-0.5 md:px-3.5 md:py-1 text-[11px] md:text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
<div className="flex flex-1 flex-col p-3.5 md:p-8">
|
||||
<h3 className="mb-1.5 md:mb-6 text-xs md:text-xl font-bold leading-snug md:leading-relaxed text-ivory line-clamp-2 transition-colors duration-300 group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
<p className="mb-2.5 md:mb-10 text-[11px] md:text-[15px] font-light leading-relaxed md:leading-loose text-ivory/60 line-clamp-2 md:line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{article.read}
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-2.5 md:pt-8 text-[11px] md:text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-2.5 md:gap-4">
|
||||
<span>{lang === 'en' ? formatEnDate(article.date) : formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5 md:gap-2">
|
||||
<ClockIcon className="h-3 w-3 md:h-3.5 md:w-3.5 text-copper/80" />
|
||||
{lang === 'en' ? article.read : toPersianDigits(article.read)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
<span className="inline-flex items-center gap-1.5 md:gap-2 rounded-full border border-copper/40 bg-copper/5 px-2.5 py-1 md:px-3.5 md:py-1.5 text-[11px] md:text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:group-hover:[&>svg]:-translate-x-1 ltr:group-hover:[&>svg]:translate-x-1">
|
||||
{t('readMore')}
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<ArrowLeftIcon className="h-3 w-3 md:h-3.5 md:w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
/** یک پرسشوپاسخ مقاله — همان شکل داده ستون faqs مقاله در دیتابیس */
|
||||
export type ArticleFaq = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* پرسشوپاسخهای پایان مقاله — همان رفتار آکاردئونی بخش FAQ سایت، بدون ویرایش مدیریتی.
|
||||
* در سرچ کنسول/نتایج غنی گوگل، اسکیمای FAQPage آن جداگانه تزریق میشود.
|
||||
*/
|
||||
export function ArticleFaq({ faqs }: { faqs: ArticleFaq[] }) {
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
if (!faqs.length) return null
|
||||
|
||||
return (
|
||||
<section className="mt-16" aria-label="پرسشهای متداول مقاله">
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
<ul className="mt-2">
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
return (
|
||||
<li key={i} className="border-b border-ivory/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span
|
||||
className={`text-base font-bold md:text-lg ${
|
||||
isOpen ? 'text-copper' : 'text-ivory'
|
||||
}`}
|
||||
>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||
isOpen ? 'rotate-45' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 ps-2 text-sm font-light leading-9 text-ivory/60">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { JALALI_TIME_FORMATTER, formatJalali, formatEnDate } from '../utils/date'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
type ArticleMetaProps = {
|
||||
article: Article
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده میشود:
|
||||
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
|
||||
*/
|
||||
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
||||
const { t, lang } = useLang()
|
||||
const rawDate = (article.date ?? '').trim()
|
||||
const author = (article.author ?? '').trim() || t('brandFallback')
|
||||
|
||||
// فارسی: تاریخ شمسی با ارقام فارسی؛ انگلیسی: تاریخ میلادی لاتین
|
||||
const enDateLabel = formatEnDate(rawDate)
|
||||
|| new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date())
|
||||
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
|
||||
const dateLabel = lang === 'en' ? enDateLabel : formatJalali(rawDate, todayLabel)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
|
||||
{/* نام نویسنده — بدون دایرهٔ حرف اول */}
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-bold text-ivory">{author}</span>
|
||||
<span className="text-[11px] font-light text-ivory/45">{t('author')}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
|
||||
|
||||
{/* تاریخ جلالی */}
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
{/* زمان مطالعه — با ارقام فارسی */}
|
||||
{article.read && (
|
||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||
{lang === 'fa' ? toPersianDigits(article.read) : article.read}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
|
||||
import { track } from '../analytics';
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
type SseFrame = {
|
||||
content?: string;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
/** true یعنی دستیار چیزی پیدا نکرده و پیام جایگزین داده */
|
||||
fallback?: boolean;
|
||||
};
|
||||
|
||||
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
||||
function getThreadId(): string {
|
||||
const KEY = 'chat_thread_id';
|
||||
let id = sessionStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
sessionStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** خواندن فریمهای SSE از پاسخ fetch (استریم توکنبهتوکن) */
|
||||
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('پاسخ استریمی ندارد');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
|
||||
} catch {
|
||||
// فریم ناقص — نادیده بگیر
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const scrollEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const assistantIndexRef = useRef<number | null>(null);
|
||||
|
||||
// پیشنهاد پرسشها فقط یک بار برای هر بازشدن اول
|
||||
useEffect(() => {
|
||||
if (!open || suggestions.length > 0) return;
|
||||
fetch('/chat/suggestions')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
|
||||
.catch(() => setSuggestions([]));
|
||||
}, [open, suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [messages, open]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
const question = text.trim();
|
||||
if (!question || isLoading) return;
|
||||
// نتیجهٔ هر پرسش برای تحلیل گزارش میشود — پرسشهای بیپاسخ = شکاف دانش
|
||||
let hadError = false;
|
||||
let wasFallback = false;
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
setMessages((prev) => {
|
||||
const next: ChatMessage[] = [
|
||||
...prev,
|
||||
{ role: 'user', content: question },
|
||||
{ role: 'assistant', content: '', streaming: true },
|
||||
];
|
||||
assistantIndexRef.current = next.length - 1;
|
||||
return next;
|
||||
});
|
||||
|
||||
const patchAssistant = (patch: Partial<ChatMessage>) => {
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current;
|
||||
if (idx === null || !prev[idx]) return prev;
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], ...patch };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
|
||||
const response = await fetch(`/chat/stream?${params.toString()}`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
let accumulated = '';
|
||||
await readSse(response, (frame) => {
|
||||
if (frame.error) {
|
||||
hadError = true;
|
||||
patchAssistant({ content: frame.error, streaming: false, error: true });
|
||||
return;
|
||||
}
|
||||
if (frame.content) {
|
||||
accumulated += frame.content;
|
||||
patchAssistant({ content: accumulated });
|
||||
}
|
||||
if (frame.done) {
|
||||
wasFallback = frame.fallback === true;
|
||||
patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||
}
|
||||
});
|
||||
patchAssistant({ streaming: false });
|
||||
} catch (err) {
|
||||
hadError = true;
|
||||
patchAssistant({
|
||||
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
||||
streaming: false,
|
||||
error: true,
|
||||
});
|
||||
} finally {
|
||||
track('chat-question', { fallback: wasFallback, error: hadError });
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دکمه شناور — گوشه راستپایین (دکمه تماس سمت چپ است) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((v) => {
|
||||
if (!v) track('chat-open');
|
||||
return !v;
|
||||
});
|
||||
}}
|
||||
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
|
||||
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
|
||||
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
|
||||
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* پنل گفتگو */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
|
||||
{/* سربرگ */}
|
||||
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
|
||||
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="بستن"
|
||||
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* پیامها */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="mt-6 text-center">
|
||||
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
|
||||
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
|
||||
سلام! هر سؤالی درباره خدمات، پروژهها و مقالات
|
||||
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
|
||||
دارید بپرسید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
|
||||
msg.role === 'user'
|
||||
? 'rounded-bl-md bg-copper text-char'
|
||||
: msg.error
|
||||
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
|
||||
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
|
||||
}`}>
|
||||
{msg.content || (msg.streaming ? '…' : '')}
|
||||
{msg.streaming && msg.content && (
|
||||
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={scrollEndRef} />
|
||||
</div>
|
||||
|
||||
{/* پیشنهاد پرسشها */}
|
||||
{suggestions.length > 0 && messages.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
|
||||
{suggestions.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
onClick={() => sendMessage(q)}
|
||||
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ورودی */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendMessage(input);
|
||||
}}
|
||||
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="پرسش خود را بنویسید…"
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
aria-label="ارسال"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
|
||||
<SendIcon className="h-4 w-4 -scale-x-100" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { API_URL } from '../config'
|
||||
|
||||
export function ContactSection() {
|
||||
export function ContactSection({ bare = false }: { bare?: boolean } = {}) {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
@@ -98,18 +98,134 @@ export function ContactSection() {
|
||||
}
|
||||
}
|
||||
|
||||
const cleanOptionalLabel = `(${content.note_optional_label?.replace(/^\(+|\)+$/g, '') || 'اختیاری'})`
|
||||
|
||||
return (
|
||||
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
|
||||
<img
|
||||
src={brand.contact_bg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
{!bare && (
|
||||
<>
|
||||
<img
|
||||
src={brand.contact_bg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover" loading="lazy" decoding="async"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||
<div className="relative mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||
|
||||
{/* Mobile View: High-Conversion Streamlined Layout */}
|
||||
<div className="block md:hidden px-1 py-1">
|
||||
{/* Header */}
|
||||
<div className="mb-4 text-center">
|
||||
<p className="text-xs font-light tracking-wide text-copper">
|
||||
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||
</p>
|
||||
<h2 className="mt-1.5 text-xl font-bold text-ivory">
|
||||
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||
</h2>
|
||||
<p className="mt-1.5 text-xs font-light leading-relaxed text-ivory/70 max-w-sm mx-auto">
|
||||
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* High-Conversion Form Box */}
|
||||
<div className="rounded-2xl border border-white/10 bg-char/60 p-4 backdrop-blur-xl shadow-lg space-y-3">
|
||||
{sent ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper">
|
||||
<CheckCircle2Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-bold text-ivory">
|
||||
{content.success_title}
|
||||
</h3>
|
||||
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/70">
|
||||
{content.success_text}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '', website: '' })
|
||||
}}
|
||||
className="mt-4 rounded-xl bg-copper px-6 py-2.5 text-xs font-bold text-char transition-all cursor-pointer"
|
||||
>
|
||||
{content.success_button_label}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
name="website_mobile"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="contact-name-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||
{content.name_label}
|
||||
</label>
|
||||
<input
|
||||
id="contact-name-mobile"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={content.name_placeholder}
|
||||
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-phone-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||
{content.phone_field_label} <span className="text-copper">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone-mobile"
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none text-left font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="contact-note-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||
{content.note_label} <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note-mobile"
|
||||
rows={2}
|
||||
value={form.note}
|
||||
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||
placeholder={content.note_placeholder}
|
||||
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="w-full py-3 rounded-xl bg-copper text-char font-bold text-xs shadow-md hover:brightness-110 active:scale-95 transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
{sending ? content.sending_label : content.submit_label}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop View */}
|
||||
<div className="hidden md:grid mx-auto max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||
|
||||
{/* باکس اطلاعات تماس */}
|
||||
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
||||
@@ -200,7 +316,7 @@ export function ContactSection() {
|
||||
className="mt-8 font-light"
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
setForm({ name: '', phone: '', note: '', website: '' })
|
||||
}}
|
||||
>
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
@@ -240,7 +356,7 @@ export function ContactSection() {
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-note"
|
||||
@@ -273,6 +389,7 @@ export function ContactSection() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { track } from '../analytics';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
@@ -11,6 +12,7 @@ export function FloatingContact() {
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label={t('callBrand')}
|
||||
onClick={() => track('phone-click', { from: 'floating' })}
|
||||
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { ArrowUpIcon, CheckIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
@@ -11,12 +11,14 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
||||
instagram: InstagramIcon,
|
||||
linkedin: LinkedinIcon,
|
||||
telegram: SendIcon,
|
||||
bale: CheckIcon,
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
save('footer', footer.id, { [field]: value }, (d) => {
|
||||
@@ -34,19 +36,22 @@ export function Footer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<footer className="relative w-full max-w-full overflow-x-clip border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||
<div className="mx-auto max-w-site px-4 py-8 md:px-6 md:py-16 lg:px-10">
|
||||
|
||||
{/* Mobile View: Collapsible Luxury Accordions */}
|
||||
<div className="block md:hidden">
|
||||
{/* Top Brand Block */}
|
||||
<div className="mb-6 flex flex-col items-center text-center">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" loading="lazy" decoding="async" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
<p className="mt-4 text-xs font-light leading-relaxed text-ivory/60 max-w-xs">
|
||||
{footer.about_text}
|
||||
</p>
|
||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{/* Social icons */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3">
|
||||
{footer.socials.map(({ label, href, icon }) => {
|
||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||
return (
|
||||
@@ -54,100 +59,234 @@ export function Footer() {
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||
className="group flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper/70 hover:bg-copper/10 hover:text-copper active:scale-95"
|
||||
>
|
||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||
<Icon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:scale-110" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={href(item.href)}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</Link>
|
||||
{/* Collapsible Link Groups */}
|
||||
<div className="border-t border-white/10">
|
||||
{/* Pages Accordion */}
|
||||
<div className="border-b border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSection(openSection === 'pages' ? null : 'pages')}
|
||||
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||
>
|
||||
<span>{footer.pages_heading}</span>
|
||||
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'pages' ? 'rotate-180' : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'pages' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||
{nav.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
to={href(item.href)}
|
||||
className="block py-1 transition-colors hover:text-copper"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Accordion */}
|
||||
<div className="border-b border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSection(openSection === 'services' ? null : 'services')}
|
||||
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||
>
|
||||
<span>{footer.services_heading}</span>
|
||||
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'services' ? 'rotate-180' : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'services' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="py-1">
|
||||
{service.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Accordion */}
|
||||
<div className="border-b border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenSection(openSection === 'contact' ? null : 'contact')}
|
||||
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||
>
|
||||
<span>{footer.contact_heading}</span>
|
||||
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'contact' ? 'rotate-180' : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'contact' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<ul className="space-y-3 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||
<li className="flex items-start gap-2.5">
|
||||
<MapPinIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-copper" />
|
||||
<span className="leading-relaxed">{brand.address}</span>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AddItemButton resource="nav" />
|
||||
<li className="flex items-center gap-2.5">
|
||||
<PhoneIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<MailIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||
{service.title}
|
||||
{/* Bottom Copyright & Meta for Mobile */}
|
||||
<div className="mt-6 flex flex-col items-center gap-3 border-t border-white/10 pt-4 text-[11px] text-ivory/50 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-3.5 py-1.5 text-xs font-medium text-ivory/70 backdrop-blur-xl transition-all hover:border-copper hover:bg-copper/10 hover:text-copper active:scale-95 cursor-pointer mb-1"
|
||||
>
|
||||
<span>{t('backToTop')}</span>
|
||||
<ArrowUpIcon className="h-3 w-3 text-copper transition-transform duration-300 group-hover:-translate-y-0.5" />
|
||||
</button>
|
||||
<p>{footer.copyright}</p>
|
||||
<p className="text-[10px] text-ivory/40">{footer.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop View: Multi-Column Layout */}
|
||||
<div className="hidden md:block">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||
</p>
|
||||
{/* آیکونهای شبکههای اجتماعی */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{footer.socials.map(({ label, href, icon }) => {
|
||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||
return (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||
>
|
||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={href(item.href)}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
value={item.label}
|
||||
onSave={(v) => saveNav(item.id, { label: v })}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</EditableItem>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AddItemButton resource="nav" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{services.map((service) => (
|
||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||
{service.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||
<span className="leading-7">{brand.address}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<li className="flex items-center gap-3">
|
||||
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="text-sm font-bold text-copper">
|
||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||
</h3>
|
||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||
<span className="leading-7">{brand.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>
|
||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>{t('backToTop')}</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||
<p>
|
||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>{t('backToTop')}</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||
|
||||
type Props = {
|
||||
images: string[]
|
||||
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowRight') prev()
|
||||
if (e.key === 'ArrowLeft') next()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
{/* Solid Brand Background */}
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
|
||||
className="absolute inset-0 bg-[#27221c]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Close Button at Top Right */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
|
||||
className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
<MdClose size={24} />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index as number]}
|
||||
alt=""
|
||||
className="relative max-h-[80vh] w-auto max-w-full object-contain"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
|
||||
{/* Main Content Area */}
|
||||
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
|
||||
|
||||
{/* Prev Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
<MdChevronRight size={28} />
|
||||
</button>
|
||||
<span className="text-sm font-light text-ivory/70" dir="ltr">
|
||||
{(index as number) + 1} / {images.length}
|
||||
</span>
|
||||
|
||||
{/* Image */}
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index as number]}
|
||||
alt=""
|
||||
className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
/>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
<MdChevronLeft size={28} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 z-10 flex items-center justify-center">
|
||||
<span className="text-sm font-light text-copper" dir="ltr">
|
||||
{(index as number) + 1} / {images.length}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -12,9 +12,11 @@ import { ImageField } from './admin/ImageField'
|
||||
type PageHeroProps = {
|
||||
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایشپذیر میشوند */
|
||||
pageKey: string
|
||||
/** تصویر پسزمینه را رندر نکن — صفحه تصویر پیوستهٔ خودش را دارد */
|
||||
hideImage?: boolean
|
||||
}
|
||||
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
export function PageHero({ pageKey, hideImage = false }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
@@ -27,15 +29,19 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
|
||||
return (
|
||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
||||
{hero.image && !hideImage && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />}
|
||||
|
||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||
{!hideImage && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
@@ -74,8 +80,15 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
{/* ویرایش هیروی صفحه (عنوان/سرآغاز/تصویر)، تصویر پسزمینه و سئو — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||
<SingletonPill
|
||||
resource="page-heroes"
|
||||
label="عنوان، سرآغاز و تصویر هیروی این صفحه"
|
||||
select={(d) => d.page_heroes.find((h) => h.key === pageKey)}
|
||||
// اگر ردیف هیروی این صفحه هنوز ساخته نشده باشد، همینجا ساخته میشود
|
||||
createDefaults={() => ({ key: pageKey, title: '', lead: '', image: '' })}
|
||||
/>
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
|
||||