fix: drag-to-draw for range and fibonacci like TradingView
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
const STORAGE_PREFIX = "hubMarketDraw:";
|
const STORAGE_PREFIX = "hubMarketDraw:";
|
||||||
const HIT_PX = 8;
|
const HIT_PX = 8;
|
||||||
const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
||||||
|
const DRAG_TOOLS = new Set(["trend", "rect", "range", "fib"]);
|
||||||
|
const MIN_DRAG_PX = 6;
|
||||||
|
|
||||||
const TOOL_LABELS = {
|
const TOOL_LABELS = {
|
||||||
cursor: "光标",
|
cursor: "光标",
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
let unsubRange = null;
|
let unsubRange = null;
|
||||||
let getCandlesFn = null;
|
let getCandlesFn = null;
|
||||||
let brushPointerId = null;
|
let brushPointerId = null;
|
||||||
|
let dragActive = false;
|
||||||
|
let dragStartPx = null;
|
||||||
|
|
||||||
function uid() {
|
function uid() {
|
||||||
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||||
@@ -209,6 +213,26 @@
|
|||||||
return selected ? "#f59e0b" : "#60a5fa";
|
return selected ? "#f59e0b" : "#60a5fa";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPrice(p) {
|
||||||
|
const n = Number(p);
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
const a = Math.abs(n);
|
||||||
|
if (a >= 10000) return n.toFixed(2);
|
||||||
|
if (a >= 1) return n.toFixed(4);
|
||||||
|
return n.toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDragTool(tool) {
|
||||||
|
return DRAG_TOOLS.has(tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDraft() {
|
||||||
|
draft = null;
|
||||||
|
dragActive = false;
|
||||||
|
dragStartPx = null;
|
||||||
|
scheduleRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
function drawLine(ctx, x1, y1, x2, y2, selected) {
|
function drawLine(ctx, x1, y1, x2, y2, selected) {
|
||||||
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
|
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -272,44 +296,70 @@
|
|||||||
const bot = Math.min(p1.price, p2.price);
|
const bot = Math.min(p1.price, p2.price);
|
||||||
const x1 = timeToX(p1.time);
|
const x1 = timeToX(p1.time);
|
||||||
const x2 = timeToX(p2.time);
|
const x2 = timeToX(p2.time);
|
||||||
if (x1 == null || x2 == null) return;
|
const yTop = priceToY(top);
|
||||||
|
const yBot = priceToY(bot);
|
||||||
|
if (x1 == null || x2 == null || yTop == null || yBot == null) return;
|
||||||
const left = Math.min(x1, x2);
|
const left = Math.min(x1, x2);
|
||||||
const right = Math.max(x1, x2);
|
const right = Math.max(x1, x2);
|
||||||
|
const span = Math.max(right - left, 48);
|
||||||
|
const drawRight = left + span;
|
||||||
|
const color = strokeStyle(selected);
|
||||||
|
|
||||||
|
drawLine(ctx, left, yTop, left, yBot, selected);
|
||||||
|
drawLine(ctx, drawRight, yTop, drawRight, yBot, selected);
|
||||||
|
|
||||||
|
let lastLabelY = -9999;
|
||||||
FIB_LEVELS.forEach(function (lv) {
|
FIB_LEVELS.forEach(function (lv) {
|
||||||
const price = bot + (top - bot) * (1 - lv);
|
const price = bot + (top - bot) * (1 - lv);
|
||||||
const y = priceToY(price);
|
const y = priceToY(price);
|
||||||
if (y == null) return;
|
if (y == null) return;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = strokeStyle(selected);
|
ctx.strokeStyle = color;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.setLineDash(lv === 0 || lv === 1 ? [] : [4, 4]);
|
ctx.setLineDash(lv === 0 || lv === 1 ? [] : [5, 4]);
|
||||||
ctx.moveTo(left, y);
|
ctx.moveTo(left, y);
|
||||||
ctx.lineTo(right, y);
|
ctx.lineTo(drawRight, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
if (Math.abs(y - lastLabelY) < 13) return;
|
||||||
|
lastLabelY = y;
|
||||||
ctx.fillStyle = "#9aa4b8";
|
ctx.fillStyle = "#9aa4b8";
|
||||||
ctx.font = "10px sans-serif";
|
ctx.font = "10px sans-serif";
|
||||||
ctx.fillText((lv * 100).toFixed(1) + "%", left + 4, y - 3);
|
ctx.fillText((lv * 100).toFixed(1) + "% " + formatPrice(price), drawRight + 6, y + 3);
|
||||||
});
|
});
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRange(ctx, p1, p2, selected) {
|
function drawRange(ctx, p1, p2, selected) {
|
||||||
|
const x1 = timeToX(p1.time);
|
||||||
|
const x2 = timeToX(p2.time);
|
||||||
const y1 = priceToY(p1.price);
|
const y1 = priceToY(p1.price);
|
||||||
const y2 = priceToY(p2.price);
|
const y2 = priceToY(p2.price);
|
||||||
const x = timeToX(p1.time) != null ? timeToX(p1.time) : timeToX(p2.time);
|
if (x1 == null || x2 == null || y1 == null || y2 == null) return;
|
||||||
if (y1 == null || y2 == null || x == null) return;
|
const left = Math.min(x1, x2);
|
||||||
|
const right = Math.max(x1, x2);
|
||||||
const top = Math.min(y1, y2);
|
const top = Math.min(y1, y2);
|
||||||
const bot = Math.max(y1, y2);
|
const bot = Math.max(y1, y2);
|
||||||
ctx.strokeStyle = strokeStyle(selected);
|
const boxW = Math.max(right - left, 10);
|
||||||
ctx.fillStyle = selected ? "rgba(245,158,11,0.12)" : "rgba(96,165,250,0.1)";
|
const boxH = Math.max(bot - top, 6);
|
||||||
|
const color = strokeStyle(selected);
|
||||||
|
|
||||||
|
ctx.fillStyle = selected ? "rgba(245,158,11,0.14)" : "rgba(96,165,250,0.12)";
|
||||||
|
ctx.strokeStyle = color;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.fillRect(x - 14, top, 28, bot - top);
|
ctx.setLineDash([]);
|
||||||
ctx.strokeRect(x - 14, top, 28, bot - top);
|
ctx.fillRect(left, top, boxW, boxH);
|
||||||
drawLine(ctx, x, top, x, bot, selected);
|
ctx.strokeRect(left, top, boxW, boxH);
|
||||||
const pct = p1.price ? (((p2.price - p1.price) / p1.price) * 100).toFixed(2) : "0";
|
|
||||||
|
const pct = p1.price ? ((p2.price - p1.price) / p1.price) * 100 : 0;
|
||||||
|
const diff = p2.price - p1.price;
|
||||||
|
const midX = left + boxW / 2;
|
||||||
|
const midY = top + boxH / 2;
|
||||||
ctx.fillStyle = "#e2e8f0";
|
ctx.fillStyle = "#e2e8f0";
|
||||||
ctx.font = "10px sans-serif";
|
ctx.font = "11px sans-serif";
|
||||||
ctx.fillText(pct + "%", x + 18, (top + bot) / 2);
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(pct.toFixed(2) + "%", midX, midY - 5);
|
||||||
|
ctx.fillText(formatPrice(diff), midX, midY + 11);
|
||||||
|
ctx.textAlign = "left";
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawText(ctx, p, text, selected) {
|
function drawText(ctx, p, text, selected) {
|
||||||
@@ -528,39 +578,42 @@
|
|||||||
scheduleRedraw();
|
scheduleRedraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activeTool === "channel") {
|
||||||
|
if (!draft || draft.type !== "channel") {
|
||||||
|
draft = { type: "channel", points: [pt] };
|
||||||
|
} else if (draft.points.length === 1) {
|
||||||
|
draft.points.push(pt);
|
||||||
|
} else if (draft.points.length === 2) {
|
||||||
|
draft.points.push(pt);
|
||||||
|
draft.offset = parallelOffset(draft.points[0], draft.points[1], draft.points[2]);
|
||||||
|
commitDrawing(draft);
|
||||||
|
}
|
||||||
|
scheduleRedraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDragTool(activeTool)) {
|
||||||
|
dragActive = true;
|
||||||
|
dragStartPx = { x: loc.x, y: loc.y };
|
||||||
|
draft = { type: activeTool, points: [pt, pt] };
|
||||||
|
scheduleRedraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!draft) {
|
|
||||||
draft = { type: activeTool, points: [pt] };
|
|
||||||
if (activeTool === "text") {
|
if (activeTool === "text") {
|
||||||
const text = window.prompt("输入标注文字", "");
|
const text = window.prompt("输入标注文字", "");
|
||||||
if (text && String(text).trim()) {
|
if (text && String(text).trim()) {
|
||||||
draft.text = String(text).trim();
|
commitDrawing({ type: "text", points: [pt], text: String(text).trim() });
|
||||||
commitDrawing(draft);
|
|
||||||
} else {
|
|
||||||
draft = null;
|
|
||||||
}
|
}
|
||||||
} else if (pointsNeeded(activeTool) === 1) {
|
|
||||||
commitDrawing(draft);
|
|
||||||
}
|
|
||||||
scheduleRedraw();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (pointsNeeded(activeTool) === 1) {
|
||||||
draft.points.push(pt);
|
commitDrawing({ type: activeTool, points: [pt] });
|
||||||
if (activeTool === "channel" && draft.points.length === 3) {
|
|
||||||
draft.offset = parallelOffset(draft.points[0], draft.points[1], draft.points[2]);
|
|
||||||
commitDrawing(draft);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (draft.points.length >= pointsNeeded(activeTool)) {
|
|
||||||
commitDrawing(draft);
|
|
||||||
}
|
|
||||||
scheduleRedraw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(ev) {
|
function onPointerMove(ev) {
|
||||||
if (activeTool === "brush" && draft && draft.type === "brush") {
|
|
||||||
const loc = clientToLocal(ev);
|
const loc = clientToLocal(ev);
|
||||||
|
if (activeTool === "brush" && draft && draft.type === "brush") {
|
||||||
const pt = xyToPoint(loc.x, loc.y);
|
const pt = xyToPoint(loc.x, loc.y);
|
||||||
if (!pt) return;
|
if (!pt) return;
|
||||||
const last = draft.points[draft.points.length - 1];
|
const last = draft.points[draft.points.length - 1];
|
||||||
@@ -575,26 +628,43 @@
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draft) return;
|
if (dragActive && draft && isDragTool(draft.type)) {
|
||||||
const loc = clientToLocal(ev);
|
|
||||||
const pt = xyToPoint(loc.x, loc.y);
|
const pt = xyToPoint(loc.x, loc.y);
|
||||||
if (!pt) return;
|
if (!pt) return;
|
||||||
if (pointsNeeded(activeTool) >= 2 && draft.points.length >= 1) {
|
draft.points[1] = pt;
|
||||||
draft.points[draft.points.length] = pt;
|
|
||||||
if (draft.points.length > pointsNeeded(activeTool)) {
|
|
||||||
draft.points.length = pointsNeeded(activeTool);
|
|
||||||
}
|
|
||||||
scheduleRedraw();
|
scheduleRedraw();
|
||||||
|
ev.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(ev) {
|
function onPointerUp(ev) {
|
||||||
|
const loc = clientToLocal(ev);
|
||||||
if (brushPointerId != null) {
|
if (brushPointerId != null) {
|
||||||
try {
|
try {
|
||||||
canvasEl.releasePointerCapture(brushPointerId);
|
canvasEl.releasePointerCapture(brushPointerId);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
brushPointerId = null;
|
brushPointerId = null;
|
||||||
}
|
}
|
||||||
|
if (dragActive && draft && isDragTool(draft.type)) {
|
||||||
|
const dist = dragStartPx
|
||||||
|
? Math.hypot(loc.x - dragStartPx.x, loc.y - dragStartPx.y)
|
||||||
|
: 0;
|
||||||
|
dragActive = false;
|
||||||
|
dragStartPx = null;
|
||||||
|
const p1 = draft.points[0];
|
||||||
|
const p2 = draft.points[1];
|
||||||
|
if (
|
||||||
|
dist >= MIN_DRAG_PX &&
|
||||||
|
p1 &&
|
||||||
|
p2 &&
|
||||||
|
(p1.price !== p2.price || p1.time !== p2.time)
|
||||||
|
) {
|
||||||
|
commitDrawing({ type: draft.type, points: [p1, p2] });
|
||||||
|
} else {
|
||||||
|
cancelDraft();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeTool === "brush" && draft && draft.type === "brush" && draft.points.length > 1) {
|
if (activeTool === "brush" && draft && draft.type === "brush" && draft.points.length > 1) {
|
||||||
commitDrawing(draft);
|
commitDrawing(draft);
|
||||||
}
|
}
|
||||||
@@ -666,6 +736,22 @@
|
|||||||
const ty = priceToY(pts[0].price);
|
const ty = priceToY(pts[0].price);
|
||||||
return tx != null && ty != null && Math.hypot(x - tx, y - ty) <= 14;
|
return tx != null && ty != null && Math.hypot(x - tx, y - ty) <= 14;
|
||||||
}
|
}
|
||||||
|
case "range":
|
||||||
|
case "fib":
|
||||||
|
case "rect":
|
||||||
|
if (pts.length >= 2) {
|
||||||
|
const x1 = timeToX(pts[0].time);
|
||||||
|
const y1 = priceToY(pts[0].price);
|
||||||
|
const x2 = timeToX(pts[1].time);
|
||||||
|
const y2 = priceToY(pts[1].price);
|
||||||
|
if (x1 == null || y1 == null || x2 == null || y2 == null) return false;
|
||||||
|
const l = Math.min(x1, x2) - HIT_PX;
|
||||||
|
const r = Math.max(x1, x2) + HIT_PX;
|
||||||
|
const t = Math.min(y1, y2) - HIT_PX;
|
||||||
|
const b = Math.max(y1, y2) + HIT_PX;
|
||||||
|
return x >= l && x <= r && y >= t && y <= b;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -702,6 +788,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeTool = tool;
|
activeTool = tool;
|
||||||
|
dragActive = false;
|
||||||
|
dragStartPx = null;
|
||||||
draft = null;
|
draft = null;
|
||||||
if (toolbarEl) {
|
if (toolbarEl) {
|
||||||
toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) {
|
toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) {
|
||||||
@@ -732,6 +820,12 @@
|
|||||||
canvasEl.addEventListener("pointerup", onPointerUp);
|
canvasEl.addEventListener("pointerup", onPointerUp);
|
||||||
canvasEl.addEventListener("pointercancel", onPointerUp);
|
canvasEl.addEventListener("pointercancel", onPointerUp);
|
||||||
canvasEl.addEventListener("dblclick", onDblClick);
|
canvasEl.addEventListener("dblclick", onDblClick);
|
||||||
|
document.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") return;
|
||||||
|
const page = document.getElementById("page-market");
|
||||||
|
if (!page || page.classList.contains("hidden")) return;
|
||||||
|
if (draft || dragActive) cancelDraft();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function attach(opts) {
|
function attach(opts) {
|
||||||
@@ -763,6 +857,8 @@
|
|||||||
viewKey = key || "";
|
viewKey = key || "";
|
||||||
drawings = loadDrawings();
|
drawings = loadDrawings();
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
|
dragActive = false;
|
||||||
|
dragStartPx = null;
|
||||||
draft = null;
|
draft = null;
|
||||||
scheduleRedraw();
|
scheduleRedraw();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260608-market-draw-v2" />
|
<link rel="stylesheet" href="/assets/app.css?v=20260608-market-draw-v3" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -188,14 +188,14 @@
|
|||||||
<button type="button" class="market-draw-btn" data-tool="brush" title="画笔">
|
<button type="button" class="market-draw-btn" data-tool="brush" title="画笔">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 18l8-2 2-8 6-2-2 6-8 2z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 18l8-2 2-8 6-2-2 6-8 2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="market-draw-btn" data-tool="range" title="价格测距">
|
<button type="button" class="market-draw-btn" data-tool="range" title="价格测距(按住拖动)">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" d="M12 5v14M8 8h8M8 16h8"/><path fill="currentColor" d="M11 4h2v3h-2zm0 13h2v3h-2z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" d="M12 5v14M8 8h8M8 16h8"/><path fill="currentColor" d="M11 4h2v3h-2zm0 13h2v3h-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="market-draw-btn market-draw-btn-text" data-tool="text" title="文字">T</button>
|
<button type="button" class="market-draw-btn market-draw-btn-text" data-tool="text" title="文字">T</button>
|
||||||
<button type="button" class="market-draw-btn" data-tool="fib" title="斐波那契">
|
<button type="button" class="market-draw-btn" data-tool="fib" title="斐波那契(按住拖动)">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.4" d="M4 6h16M4 10h16M4 14h16M4 18h16"/><circle cx="20" cy="6" r="1.2" fill="currentColor"/><circle cx="20" cy="18" r="1.2" fill="currentColor"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.4" d="M4 6h16M4 10h16M4 14h16M4 18h16"/><circle cx="20" cy="6" r="1.2" fill="currentColor"/><circle cx="20" cy="18" r="1.2" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="market-draw-btn" data-tool="trend" title="趋势线">
|
<button type="button" class="market-draw-btn" data-tool="trend" title="趋势线(按住拖动)">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" d="M5 18L19 6"/><circle cx="5" cy="18" r="1.5" fill="currentColor"/><circle cx="19" cy="6" r="1.5" fill="currentColor"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" d="M5 18L19 6"/><circle cx="5" cy="18" r="1.5" fill="currentColor"/><circle cx="19" cy="6" r="1.5" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="market-draw-btn" data-tool="path" title="折线箭头(双击结束)">
|
<button type="button" class="market-draw-btn" data-tool="path" title="折线箭头(双击结束)">
|
||||||
@@ -392,8 +392,8 @@
|
|||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
<script src="/assets/chart_draw.js?v=20260608-market-draw-v2"></script>
|
<script src="/assets/chart_draw.js?v=20260608-market-draw-v3"></script>
|
||||||
<script src="/assets/chart.js?v=20260608-market-draw-v2"></script>
|
<script src="/assets/chart.js?v=20260608-market-draw-v3"></script>
|
||||||
<script src="/assets/archive.js?v=20260607-hub-archive-v6"></script>
|
<script src="/assets/archive.js?v=20260607-hub-archive-v6"></script>
|
||||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user