feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -64,6 +65,7 @@ import Modal from "@/components/Modal";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
||||
import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint";
|
||||
|
||||
type Side = "left" | "right";
|
||||
type SectionId = SidebarScope | "widgets";
|
||||
@@ -102,6 +104,9 @@ const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[
|
||||
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
||||
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
// 提交给后端的工具草稿(混写框拆分后的三字段 + 名称)
|
||||
type WidgetDraftCode = { name: string; html: string; css: string; js: string };
|
||||
|
||||
export default function SidebarAdmin({
|
||||
initialConfig,
|
||||
initialWidgets,
|
||||
@@ -125,6 +130,10 @@ export default function SidebarAdmin({
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [mixed, setMixed] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
// 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
|
||||
const [lintIssues, setLintIssues] = useState<WidgetLintIssue[] | null>(null);
|
||||
const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
|
||||
const pendingCodeRef = useRef<WidgetDraftCode | null>(null);
|
||||
|
||||
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
||||
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
||||
@@ -196,6 +205,9 @@ export default function SidebarAdmin({
|
||||
setEditingId(null);
|
||||
setDraftName("");
|
||||
setMixed("");
|
||||
setLintIssues(null);
|
||||
setWarnConfirmOpen(false);
|
||||
pendingCodeRef.current = null;
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -203,14 +215,15 @@ export default function SidebarAdmin({
|
||||
setEditingId(w.id);
|
||||
setDraftName(w.name);
|
||||
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
||||
setLintIssues(null);
|
||||
setWarnConfirmOpen(false);
|
||||
pendingCodeRef.current = null;
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const submitWidget = async () => {
|
||||
if (!draftOk) return;
|
||||
const doSubmitWidget = async (code: WidgetDraftCode) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
||||
if (editingId == null) {
|
||||
await apiAdminCreateSidebarWidget(code);
|
||||
toast("工具已创建", "ok");
|
||||
@@ -220,6 +233,9 @@ export default function SidebarAdmin({
|
||||
toast("工具已保存", "ok");
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setLintIssues(null);
|
||||
setWarnConfirmOpen(false);
|
||||
pendingCodeRef.current = null;
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||
@@ -228,6 +244,34 @@ export default function SidebarAdmin({
|
||||
}
|
||||
};
|
||||
|
||||
// 保存按钮:先静态扫描——有 error 禁止保存并列出;只有 warn 弹确认框;无问题直接保存
|
||||
const submitWidget = () => {
|
||||
if (!draftOk || busy) return;
|
||||
const code: WidgetDraftCode = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
||||
const issues = lintWidgetCode(code);
|
||||
setLintIssues(issues.length ? issues : null);
|
||||
if (issues.some((i) => i.level === "error")) return;
|
||||
const warns = issues.filter((i) => i.level === "warn");
|
||||
if (warns.length > 0) {
|
||||
pendingCodeRef.current = code;
|
||||
setWarnConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
void doSubmitWidget(code);
|
||||
};
|
||||
|
||||
const confirmWarnSubmit = () => {
|
||||
const code = pendingCodeRef.current;
|
||||
pendingCodeRef.current = null;
|
||||
setWarnConfirmOpen(false);
|
||||
if (code) void doSubmitWidget(code);
|
||||
};
|
||||
|
||||
const cancelWarnSubmit = () => {
|
||||
pendingCodeRef.current = null;
|
||||
setWarnConfirmOpen(false);
|
||||
};
|
||||
|
||||
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -402,7 +446,7 @@ export default function SidebarAdmin({
|
||||
<SectionHead
|
||||
icon={<Code2 size={16} />}
|
||||
title="自定义工具"
|
||||
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
|
||||
desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
|
||||
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||||
/>
|
||||
<div className="p-4 sm:p-5">
|
||||
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => submitWidget()}>
|
||||
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||||
</button>
|
||||
</>
|
||||
@@ -506,13 +550,20 @@ export default function SidebarAdmin({
|
||||
<textarea
|
||||
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||||
value={mixed}
|
||||
onChange={(e) => setMixed(e.target.value)}
|
||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
|
||||
onChange={(e) => {
|
||||
setMixed(e.target.value);
|
||||
setLintIssues(null);
|
||||
}}
|
||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 脚本参数:(root, shadow, toolId, registerCleanup)\n // 元素查找一律从 root 出发:root.querySelector(".my-banner")\n</script>`}
|
||||
/>
|
||||
</label>
|
||||
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
|
||||
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
||||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
|
||||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:脚本参数 / window.J13 / 主题变量</summary>
|
||||
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||
<p>脚本接收参数 <code>(root, shadow, toolId, registerCleanup)</code>:元素查找一律 <code>root.querySelector(...)</code>,禁止 <code>document</code> 全局查找(同页多个同款工具会互相取错)</p>
|
||||
<p>定时器 / 监听器请通过 <code>registerCleanup(fn)</code> 注册清理,离开页面时自动执行(如 <code>clearInterval</code>)</p>
|
||||
<p>样式仅在工具内生效,直接写类名即可;禁止 <code>body</code> / <code>html</code> / <code>*</code> 全局选择器与 <code>position: fixed</code>,变量请定义在 <code>.tool-root</code> 上</p>
|
||||
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
||||
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||||
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||||
@@ -520,11 +571,22 @@ export default function SidebarAdmin({
|
||||
</div>
|
||||
</details>
|
||||
<p className="meta text-[11.5px]">
|
||||
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 仅 warn 级问题时的二次确认 */}
|
||||
<ConfirmDialog
|
||||
open={warnConfirmOpen}
|
||||
title="代码存在风险提醒"
|
||||
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
|
||||
confirmLabel="仍要保存"
|
||||
busy={busy}
|
||||
onConfirm={confirmWarnSubmit}
|
||||
onCancel={cancelWarnSubmit}
|
||||
/>
|
||||
|
||||
{/* 删除工具确认 */}
|
||||
<ConfirmDialog
|
||||
open={deleteId != null}
|
||||
@@ -550,6 +612,75 @@ export default function SidebarAdmin({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 工具代码静态扫描结果面板 ─── */
|
||||
const LINT_FIELD_LABEL: Record<WidgetLintIssue["field"], string> = {
|
||||
js: "JS",
|
||||
css: "CSS",
|
||||
html: "HTML",
|
||||
};
|
||||
|
||||
function WidgetLintPanel({ issues }: { issues: WidgetLintIssue[] }) {
|
||||
const errors = issues.filter((i) => i.level === "error");
|
||||
const warns = issues.filter((i) => i.level === "warn");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{errors.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl border p-2.5"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--danger) 40%, var(--line))",
|
||||
background: "var(--danger-soft)",
|
||||
}}
|
||||
>
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
|
||||
<AlertTriangle size={13} aria-hidden />
|
||||
{errors.length} 个错误,修复后才能保存
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{errors.map((issue, idx) => (
|
||||
<LintIssueRow key={`e-${idx}`} issue={issue} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{warns.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl border p-2.5"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--gold) 40%, var(--line))",
|
||||
background: "var(--gold-soft)",
|
||||
}}
|
||||
>
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--gold)" }}>
|
||||
<Info size={13} aria-hidden />
|
||||
{warns.length} 个提醒,确认风险后仍可保存
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{warns.map((issue, idx) => (
|
||||
<LintIssueRow key={`w-${idx}`} issue={issue} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
|
||||
const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
|
||||
return (
|
||||
<li className="flex gap-1.5 text-[11.5px] leading-relaxed">
|
||||
<span
|
||||
className="mt-px shrink-0 rounded px-1 py-px font-mono text-[10.5px] font-semibold tabular-nums"
|
||||
style={{ color: tone, background: "color-mix(in srgb, currentColor 10%, transparent)" }}
|
||||
>
|
||||
{LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
|
||||
</span>
|
||||
<span style={{ color: "var(--ink-2)" }}>{issue.message}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 分区卡片头 ─── */
|
||||
function SectionHead({
|
||||
icon,
|
||||
|
||||
Reference in New Issue
Block a user