feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -114,6 +114,7 @@ def plot_png(plot):
|
|||||||
"""按 SVG/PDF 共用的裁剪几何,以二倍分辨率生成 DOCX 图像。"""
|
"""按 SVG/PDF 共用的裁剪几何,以二倍分辨率生成 DOCX 图像。"""
|
||||||
from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
|
from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
|
||||||
from PIL import ImageDraw, ImageFont
|
from PIL import ImageDraw, ImageFont
|
||||||
|
from app.plot.math_label import expression_latex, render_math_mask
|
||||||
geo = compute_geometry(plot)
|
geo = compute_geometry(plot)
|
||||||
image = Image.new('RGB', (geo.width * 2, (geo.height + ((len(plot.expressions)+1)//2)*24) * 2), 'white')
|
image = Image.new('RGB', (geo.width * 2, (geo.height + ((len(plot.expressions)+1)//2)*24) * 2), 'white')
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
@@ -140,6 +141,13 @@ def plot_png(plot):
|
|||||||
# 纵轴标题横排在左上边距,避免 CJK 文本在 Word 中旋转后不可读。
|
# 纵轴标题横排在左上边距,避免 CJK 文本在 Word 中旋转后不可读。
|
||||||
draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
|
draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
|
||||||
for index, expression in enumerate(plot.expressions):
|
for index, expression in enumerate(plot.expressions):
|
||||||
draw.text((48+(index%2)*620,geo.height*2+index//2*48),expression.label or 'y = '+expression.expression,fill=geo.colors[index],font=font)
|
position = (48 + (index % 2) * 620, geo.height * 2 + 8 + (index // 2) * 48)
|
||||||
|
if expression.label:
|
||||||
|
draw.text(position, expression.label, fill=geo.colors[index], font=font)
|
||||||
|
else:
|
||||||
|
mask_width, mask_height, mask_bytes = render_math_mask(expression_latex(expression.expression))
|
||||||
|
mask = Image.frombytes('L', (mask_width, mask_height), mask_bytes)
|
||||||
|
ink = Image.new('RGB', mask.size, geo.colors[index])
|
||||||
|
image.paste(ink, position, mask)
|
||||||
out=BytesIO(); image.save(out,'PNG')
|
out=BytesIO(); image.save(out,'PNG')
|
||||||
return out.getvalue(), geo.warnings
|
return out.getvalue(), geo.warnings
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Safe AST-to-LaTeX conversion and vector math layout for plot labels."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import html
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from matplotlib.font_manager import FontProperties
|
||||||
|
from matplotlib.mathtext import MathTextParser
|
||||||
|
from matplotlib.path import Path as MplPath
|
||||||
|
|
||||||
|
from app.plot.parser import parse_expression
|
||||||
|
|
||||||
|
_MATH_PARSER = MathTextParser("path")
|
||||||
|
_RASTER_PARSER = MathTextParser("agg")
|
||||||
|
_MATH_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: int | float) -> str:
|
||||||
|
text = repr(value)
|
||||||
|
if "e" not in text.lower():
|
||||||
|
return text
|
||||||
|
mantissa, exponent = text.lower().split("e", 1)
|
||||||
|
return rf"{mantissa}\times 10^{{{int(exponent)}}}"
|
||||||
|
|
||||||
|
|
||||||
|
def _latex(node: ast.AST, parent_precedence: int = 0) -> str:
|
||||||
|
if isinstance(node, ast.Constant):
|
||||||
|
return _number(node.value)
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return r"\pi" if node.id == "pi" else node.id
|
||||||
|
if isinstance(node, ast.UnaryOp):
|
||||||
|
value = _latex(node.operand, 25)
|
||||||
|
result = ("-" if isinstance(node.op, ast.USub) else "+") + value
|
||||||
|
return rf"\left({result}\right)" if parent_precedence > 25 else result
|
||||||
|
if isinstance(node, ast.BinOp):
|
||||||
|
if isinstance(node.op, ast.Div):
|
||||||
|
return rf"\frac{{{_latex(node.left)}}}{{{_latex(node.right)}}}"
|
||||||
|
if isinstance(node.op, ast.Pow):
|
||||||
|
result = rf"{{{_latex(node.left, 30)}}}^{{{_latex(node.right)}}}"
|
||||||
|
return rf"\left({result}\right)" if parent_precedence > 30 else result
|
||||||
|
precedence = 20 if isinstance(node.op, ast.Mult) else 10
|
||||||
|
operator = r" \cdot " if isinstance(node.op, ast.Mult) else (" + " if isinstance(node.op, ast.Add) else " - ")
|
||||||
|
left = _latex(node.left, precedence)
|
||||||
|
right = _latex(node.right, precedence + (1 if isinstance(node.op, ast.Sub) else 0))
|
||||||
|
result = left + operator + right
|
||||||
|
return rf"\left({result}\right)" if parent_precedence > precedence else result
|
||||||
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||||
|
argument = _latex(node.args[0])
|
||||||
|
name = node.func.id
|
||||||
|
if name == "sqrt":
|
||||||
|
return rf"\sqrt{{{argument}}}"
|
||||||
|
if name == "abs":
|
||||||
|
return rf"\left|{argument}\right|"
|
||||||
|
if name in {"log10", "log2"}:
|
||||||
|
return rf"\log_{{{name[3:]}}}\left({argument}\right)"
|
||||||
|
if name in {"asin", "acos", "atan"}:
|
||||||
|
return rf"\{name[1:]}^{{-1}}\left({argument}\right)"
|
||||||
|
command = "log" if name == "ln" else name
|
||||||
|
return rf"\{command}\left({argument}\right)"
|
||||||
|
raise ValueError(f"Unsupported validated expression node: {type(node).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
def expression_latex(expression: str) -> str:
|
||||||
|
"""Convert one already-supported function expression to MathText-compatible LaTeX."""
|
||||||
|
return "y = " + _latex(parse_expression(expression).body)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VectorPath:
|
||||||
|
commands: tuple[tuple[str, tuple[float, ...]], ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MathLayout:
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
depth: float
|
||||||
|
paths: tuple[VectorPath, ...]
|
||||||
|
rects: tuple[tuple[float, float, float, float], ...]
|
||||||
|
|
||||||
|
|
||||||
|
def _offset(values: tuple[float, ...], x: float, y: float) -> tuple[float, ...]:
|
||||||
|
return tuple(value + (x if index % 2 == 0 else y) for index, value in enumerate(values))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def math_layout(latex: str, size: float = 12.0) -> MathLayout:
|
||||||
|
"""Lay out LaTeX as reusable vector paths; calls are cached and serialized for FT2Font."""
|
||||||
|
with _MATH_LOCK:
|
||||||
|
parsed = _MATH_PARSER.parse(f"${latex}$", dpi=72, prop=FontProperties(size=size))
|
||||||
|
paths: list[VectorPath] = []
|
||||||
|
for font, font_size, _character, glyph, offset_x, offset_y in parsed.glyphs:
|
||||||
|
font.set_size(font_size, 72)
|
||||||
|
font.load_glyph(glyph)
|
||||||
|
vertices, codes = font.get_path()
|
||||||
|
commands: list[tuple[str, tuple[float, ...]]] = []
|
||||||
|
for values, code in MplPath(vertices, codes).iter_segments(curves=True, simplify=False):
|
||||||
|
command = {
|
||||||
|
MplPath.MOVETO: "M",
|
||||||
|
MplPath.LINETO: "L",
|
||||||
|
MplPath.CURVE3: "Q",
|
||||||
|
MplPath.CURVE4: "C",
|
||||||
|
MplPath.CLOSEPOLY: "Z",
|
||||||
|
}[code]
|
||||||
|
points = () if command == "Z" else _offset(tuple(float(value) for value in values), float(offset_x), float(offset_y))
|
||||||
|
commands.append((command, points))
|
||||||
|
paths.append(VectorPath(tuple(commands)))
|
||||||
|
rects = tuple(tuple(float(value) for value in rect) for rect in parsed.rects)
|
||||||
|
return MathLayout(float(parsed.width), float(parsed.height), float(parsed.depth), tuple(paths), rects)
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_number(value: float) -> str:
|
||||||
|
if math.isclose(value, round(value), abs_tol=1e-8):
|
||||||
|
return str(int(round(value)))
|
||||||
|
return f"{value:.4f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_path(path: VectorPath) -> str:
|
||||||
|
return " ".join(command + (" " + " ".join(_svg_number(value) for value in values) if values else "") for command, values in path.commands)
|
||||||
|
|
||||||
|
|
||||||
|
def render_math_svg(latex: str, *, x: float, top: float, class_name: str, color: str) -> str:
|
||||||
|
"""Return a script-free SVG group containing MathText vector glyphs."""
|
||||||
|
layout = math_layout(latex)
|
||||||
|
baseline = top + layout.height - layout.depth
|
||||||
|
accessible = html.escape(latex, quote=True)
|
||||||
|
parts = [
|
||||||
|
f'<g class="{class_name} plot-math-label" fill="{color}" '
|
||||||
|
f'transform="translate({_svg_number(x)} {_svg_number(baseline)}) scale(1 -1)" '
|
||||||
|
f'aria-label="{accessible}" data-latex="{accessible}">'
|
||||||
|
]
|
||||||
|
parts.extend(f'<path d="{_svg_path(path)}"/>' for path in layout.paths)
|
||||||
|
for rx, ry, width, height in layout.rects:
|
||||||
|
parts.append(
|
||||||
|
f'<path d="M {_svg_number(rx)} {_svg_number(ry)} h {_svg_number(width)} '
|
||||||
|
f'v {_svg_number(height)} h -{_svg_number(width)} Z"/>'
|
||||||
|
)
|
||||||
|
parts.append("</g>")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def render_math_reportlab(latex: str, *, x: float, visual_top: float, color: object):
|
||||||
|
"""Return a reportlab Group containing the same LaTeX glyph geometry as the SVG."""
|
||||||
|
from reportlab.graphics.shapes import Group, Path, Rect
|
||||||
|
|
||||||
|
layout = math_layout(latex)
|
||||||
|
baseline = visual_top - (layout.height - layout.depth)
|
||||||
|
group = Group()
|
||||||
|
for vector in layout.paths:
|
||||||
|
path = Path(fillColor=color, strokeColor=None)
|
||||||
|
current = (0.0, 0.0)
|
||||||
|
start = current
|
||||||
|
for command, values in vector.commands:
|
||||||
|
if command == "M":
|
||||||
|
current = (values[0], values[1]); start = current
|
||||||
|
path.moveTo(*current)
|
||||||
|
elif command == "L":
|
||||||
|
current = (values[0], values[1]); path.lineTo(*current)
|
||||||
|
elif command == "Q":
|
||||||
|
control, end = (values[0], values[1]), (values[2], values[3])
|
||||||
|
first = (current[0] + 2 * (control[0] - current[0]) / 3,
|
||||||
|
current[1] + 2 * (control[1] - current[1]) / 3)
|
||||||
|
second = (end[0] + 2 * (control[0] - end[0]) / 3,
|
||||||
|
end[1] + 2 * (control[1] - end[1]) / 3)
|
||||||
|
path.curveTo(*first, *second, *end); current = end
|
||||||
|
elif command == "C":
|
||||||
|
path.curveTo(*values); current = (values[4], values[5])
|
||||||
|
else:
|
||||||
|
path.closePath(); current = start
|
||||||
|
group.add(path)
|
||||||
|
for rx, ry, width, height in layout.rects:
|
||||||
|
group.add(Rect(rx, ry, width, height, fillColor=color, strokeColor=None))
|
||||||
|
group.translate(x, baseline)
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def render_math_mask(latex: str, size: float = 12.0, dpi: float = 144.0) -> tuple[int, int, bytes]:
|
||||||
|
"""Rasterize LaTeX to an 8-bit alpha mask for DOCX/PNG export."""
|
||||||
|
with _MATH_LOCK:
|
||||||
|
parsed = _RASTER_PARSER.parse(f"${latex}$", dpi=dpi, prop=FontProperties(size=size))
|
||||||
|
image = parsed.image
|
||||||
|
height, width = image.shape
|
||||||
|
return int(width), int(height), image.tobytes()
|
||||||
@@ -16,6 +16,7 @@ import re
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.plot.model import FunctionPlot, StaticRenderResult
|
from app.plot.model import FunctionPlot, StaticRenderResult
|
||||||
|
from app.plot.math_label import expression_latex, render_math_svg
|
||||||
from app.plot.parser import PlotParseError, evaluate, parse_expression
|
from app.plot.parser import PlotParseError, evaluate, parse_expression
|
||||||
|
|
||||||
_WIDTH = 640
|
_WIDTH = 640
|
||||||
@@ -491,9 +492,13 @@ def render_svg(plot: FunctionPlot, theme_id: str = 'light', unlimited: bool = Fa
|
|||||||
parts.append(_labels_svg(geo))
|
parts.append(_labels_svg(geo))
|
||||||
for index, expression in enumerate(plot.expressions):
|
for index, expression in enumerate(plot.expressions):
|
||||||
x = 24 + (index % 2) * 310
|
x = 24 + (index % 2) * 310
|
||||||
y = geo.height + 18 + (index // 2) * 24
|
top = geo.height + 4 + (index // 2) * 24
|
||||||
label = html.escape(expression.label or ('y = ' + expression.expression))
|
if expression.label:
|
||||||
parts.append(f'<text x="{x}" y="{y}" font-size="12" fill="{geo.colors[index]}" class="plot-legend-{index % 6}">{label}</text>')
|
label = html.escape(expression.label)
|
||||||
|
parts.append(f'<text x="{x}" y="{top + 14}" font-size="12" fill="{geo.colors[index]}" class="plot-legend-{index % 6}">{label}</text>')
|
||||||
|
else:
|
||||||
|
parts.append(render_math_svg(expression_latex(expression.expression), x=x, top=top,
|
||||||
|
class_name=f"plot-legend-{index % 6}", color=geo.colors[index]))
|
||||||
parts.append("</svg>")
|
parts.append("</svg>")
|
||||||
|
|
||||||
return StaticRenderResult(
|
return StaticRenderResult(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from reportlab.pdfbase import pdfmetrics
|
|||||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||||
|
|
||||||
from app.plot.model import FunctionPlot
|
from app.plot.model import FunctionPlot
|
||||||
|
from app.plot.math_label import expression_latex, render_math_reportlab
|
||||||
from app.plot.render import PlotGeometry, _fmt_num, _sx, _sy, compute_geometry
|
from app.plot.render import PlotGeometry, _fmt_num, _sx, _sy, compute_geometry
|
||||||
|
|
||||||
from app.export.fonts import FONT as _FONT
|
from app.export.fonts import FONT as _FONT
|
||||||
@@ -125,8 +126,14 @@ def render_drawing(plot: FunctionPlot, width: float | None = None, palette=None,
|
|||||||
legend_height = ((len(plot.expressions)+1)//2)*24
|
legend_height = ((len(plot.expressions)+1)//2)*24
|
||||||
drawing.height += legend_height
|
drawing.height += legend_height
|
||||||
for index, expression in enumerate(plot.expressions):
|
for index, expression in enumerate(plot.expressions):
|
||||||
drawing.add(String(24+(index%2)*310,geo.height+legend_height-18-(index//2)*24,
|
x = 24 + (index % 2) * 310
|
||||||
expression.label or 'y = '+expression.expression,fontName=_FONT,fontSize=12,fillColor=HexColor(geo.colors[index])))
|
visual_top = drawing.height - 4 - (index // 2) * 24
|
||||||
|
if expression.label:
|
||||||
|
drawing.add(String(x, visual_top - 12, expression.label, fontName=_FONT, fontSize=12,
|
||||||
|
fillColor=HexColor(geo.colors[index])))
|
||||||
|
else:
|
||||||
|
drawing.add(render_math_reportlab(expression_latex(expression.expression), x=x,
|
||||||
|
visual_top=visual_top, color=HexColor(geo.colors[index])))
|
||||||
if width is not None and width > 0:
|
if width is not None and width > 0:
|
||||||
drawing.renderScale = min(1.0, width / geo.width, max_height / drawing.height if max_height else 1.0)
|
drawing.renderScale = min(1.0, width / geo.width, max_height / drawing.height if max_height else 1.0)
|
||||||
return drawing
|
return drawing
|
||||||
|
|||||||
@@ -116,6 +116,25 @@ def test_render_svg_multiple_functions() -> None:
|
|||||||
assert rendered.content.count("<polyline") >= 2
|
assert rendered.content.count("<polyline") >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_uses_latex_vector_paths_for_expression_legends() -> None:
|
||||||
|
plot = parse_source("y = sin(x)\ny = x^2 / 5").plot
|
||||||
|
svg = render_svg(plot).content
|
||||||
|
assert svg.count("plot-math-label") == 2
|
||||||
|
assert 'data-latex="y = \\sin\\left(x\\right)"' in svg
|
||||||
|
assert 'data-latex="y = \\frac{{x}^{2}}{5}"' in svg
|
||||||
|
assert "<path" in svg
|
||||||
|
assert ">y = sin(x)<" not in svg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("expression", [
|
||||||
|
"asin(x)", "acos(x)", "atan(x)", "sinh(x)", "cosh(x)", "tanh(x)",
|
||||||
|
"exp(x)", "ln(x)", "log10(x)", "log2(x)", "sqrt(x)", "abs(x)",
|
||||||
|
])
|
||||||
|
def test_render_svg_latex_supports_every_plot_function(expression: str) -> None:
|
||||||
|
plot = parse_source(f"domain: 0.1, 1\ny = {expression}").plot
|
||||||
|
assert "plot-math-label" in render_svg(plot).content
|
||||||
|
|
||||||
|
|
||||||
def test_render_svg_labels() -> None:
|
def test_render_svg_labels() -> None:
|
||||||
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot
|
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot
|
||||||
rendered = render_svg(plot)
|
rendered = render_svg(plot)
|
||||||
@@ -369,6 +388,16 @@ def test_render_reportlab_builds_drawing() -> None:
|
|||||||
assert "数值" in group_texts
|
assert "数值" in group_texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_reportlab_uses_vector_latex_for_expression_legend() -> None:
|
||||||
|
from reportlab.graphics.shapes import Group, Path
|
||||||
|
from app.plot.render_reportlab import render_drawing
|
||||||
|
|
||||||
|
drawing = render_drawing(parse_source("y = x^2 / 5").plot)
|
||||||
|
math_groups = [item for item in drawing.contents if isinstance(item, Group)
|
||||||
|
and any(isinstance(child, Path) for child in item.contents)]
|
||||||
|
assert math_groups
|
||||||
|
|
||||||
|
|
||||||
def test_render_reportlab_curves_are_finite_and_bounded() -> None:
|
def test_render_reportlab_curves_are_finite_and_bounded() -> None:
|
||||||
from reportlab.graphics.shapes import PolyLine
|
from reportlab.graphics.shapes import PolyLine
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function toggleSource() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<DesktopPackages v-if="isDesktop()" :refresh-key="stagedRefresh" />
|
<DesktopPackages v-if="isDesktop()" :refresh-key="stagedRefresh" />
|
||||||
<section v-if="candidates.length">
|
<section v-if="candidates.length" class="saved-candidates panel">
|
||||||
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设、MCP 或模型运行配置。</p>
|
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设、MCP 或模型运行配置。</p>
|
||||||
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
|
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
|
||||||
</section>
|
</section>
|
||||||
@@ -163,5 +163,6 @@ label { display: grid; gap: var(--space-xs); }
|
|||||||
input, select { color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); padding: var(--space-sm); }
|
input, select { color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); padding: var(--space-sm); }
|
||||||
.community-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-md); }
|
.community-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-md); }
|
||||||
.community-card { display: grid; gap: var(--space-sm); text-align: left; padding: var(--space-lg); color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow-wrap: anywhere; }
|
.community-card { display: grid; gap: var(--space-sm); text-align: left; padding: var(--space-lg); color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow-wrap: anywhere; }
|
||||||
|
.saved-candidates { margin-top: var(--space-xl); }
|
||||||
pre, dd { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
|
pre, dd { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ import DesktopPackages from './DesktopPackages.vue'
|
|||||||
const item = { package_key: 'package-key', source: 'https://example.com/', namespace: 'examples', package_id: 'reviewer', version: '1.0.0', state: 'staged' }
|
const item = { package_key: 'package-key', source: 'https://example.com/', namespace: 'examples', package_id: 'reviewer', version: '1.0.0', state: 'staged' }
|
||||||
beforeEach(() => { native.invoke.mockReset(); native.workspace.vaultId = 'vault-one' })
|
beforeEach(() => { native.invoke.mockReset(); native.workspace.vaultId = 'vault-one' })
|
||||||
function component() { return mount(DesktopPackages, { props: { refreshKey: 0 }, global: { stubs: { AppDialog: { template: '<section><slot /></section>' } } } }) }
|
function component() { return mount(DesktopPackages, { props: { refreshKey: 0 }, global: { stubs: { AppDialog: { template: '<section><slot /></section>' } } } }) }
|
||||||
|
it('uses themed surfaces for the section, empty state, and staged package rows', async () => {
|
||||||
|
native.invoke.mockResolvedValueOnce([])
|
||||||
|
const empty = component(); await flushPromises()
|
||||||
|
expect(empty.get('section.desktop-packages').classes()).toContain('panel')
|
||||||
|
expect(empty.get('.empty-state').text()).toContain('本页没有暂存包')
|
||||||
|
empty.unmount()
|
||||||
|
|
||||||
|
native.invoke.mockResolvedValueOnce([item])
|
||||||
|
const populated = component(); await flushPromises()
|
||||||
|
expect(populated.get('.package-list > li').classes()).toContain('item-card')
|
||||||
|
populated.unmount()
|
||||||
|
})
|
||||||
it('loads durable staged metadata and previews without issuing install commands', async () => {
|
it('loads durable staged metadata and previews without issuing install commands', async () => {
|
||||||
native.invoke.mockImplementation(async command => command === 'extension_staged' ? [item] : {
|
native.invoke.mockImplementation(async command => command === 'extension_staged' ? [item] : {
|
||||||
fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, permissions: ['notes.read'] }] }, changes: [{ target: { configuration: {} }, expected_revision: null }],
|
fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, permissions: ['notes.read'] }] }, changes: [{ target: { configuration: {} }, expected_revision: null }],
|
||||||
|
|||||||
@@ -53,20 +53,31 @@ onBeforeUnmount(() => ++generation)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="desktop-packages" aria-label="桌面已暂存包">
|
<section class="desktop-packages panel" aria-label="桌面已暂存包">
|
||||||
|
<header class="section-heading">
|
||||||
|
<div>
|
||||||
<h2>桌面已暂存包</h2>
|
<h2>桌面已暂存包</h2>
|
||||||
<p>暂存包保存在桌面安装库中,重启后仍可查看。暂存不代表已经安装或获得运行权限。</p>
|
<p>暂存包保存在桌面安装库中,重启后仍可查看。暂存不代表已经安装或获得运行权限。</p>
|
||||||
|
</div>
|
||||||
<button class="btn" :disabled="busy" @click="refresh">刷新暂存列表</button>
|
<button class="btn" :disabled="busy" @click="refresh">刷新暂存列表</button>
|
||||||
<p v-if="error" role="alert">{{ error }}</p>
|
</header>
|
||||||
<p v-if="busy" role="status">正在检查…</p>
|
<p v-if="error" class="notice-banner error-message" role="alert">{{ error }}</p>
|
||||||
<p v-if="!busy && !packages.length">本页没有暂存包。</p>
|
<p v-if="busy" class="notice-banner" role="status">正在检查…</p>
|
||||||
<ul><li v-for="item in packages" :key="item.package_key">
|
<div v-if="!busy && !packages.length" class="empty-state">
|
||||||
<strong>{{ item.namespace }}/{{ item.package_id }} · {{ item.version }}</strong>
|
<strong>本页没有暂存包</strong>
|
||||||
<p>{{ item.source }}</p><button class="btn" :disabled="busy" @click="choose(item)">查看安装预览</button>
|
<span>从社区目录校验并暂存包后,可在这里查看安装预览。</span>
|
||||||
</li></ul>
|
</div>
|
||||||
|
<ul v-if="packages.length" class="package-list">
|
||||||
|
<li v-for="item in packages" :key="item.package_key" class="item-card">
|
||||||
|
<div><strong>{{ item.namespace }}/{{ item.package_id }} · {{ item.version }}</strong><p>{{ item.source }}</p></div>
|
||||||
|
<button class="btn" :disabled="busy" @click="choose(item)">查看安装预览</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<nav class="pagination" aria-label="暂存包分页">
|
||||||
<button class="btn" :disabled="busy || page === 0" @click="page--; refresh()">上一页</button>
|
<button class="btn" :disabled="busy || page === 0" @click="page--; refresh()">上一页</button>
|
||||||
<span>第 {{ page + 1 }} 页</span>
|
<span>第 {{ page + 1 }} 页</span>
|
||||||
<button class="btn" :disabled="busy || packages.length < 20" @click="page++; refresh()">下一页</button>
|
<button class="btn" :disabled="busy || packages.length < 20" @click="page++; refresh()">下一页</button>
|
||||||
|
</nav>
|
||||||
<AppDialog v-if="selected" label="桌面安装预览" @close="close">
|
<AppDialog v-if="selected" label="桌面安装预览" @close="close">
|
||||||
<h2>{{ selected.package_id }} · {{ selected.version }}</h2>
|
<h2>{{ selected.package_id }} · {{ selected.version }}</h2>
|
||||||
<p v-if="!workspace.vaultId">请先打开要使用此包的笔记库。</p>
|
<p v-if="!workspace.vaultId">请先打开要使用此包的笔记库。</p>
|
||||||
@@ -88,9 +99,16 @@ onBeforeUnmount(() => ++generation)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.desktop-packages { margin-block: var(--space-xl); }
|
.desktop-packages { display: grid; gap: var(--space-lg); margin-block: var(--space-xl); padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
|
||||||
li { margin-block: var(--space-md); overflow-wrap: anywhere; }
|
.section-heading { display: flex; align-items: start; justify-content: space-between; gap: var(--space-lg); }
|
||||||
|
.section-heading h2, .section-heading p, .item-card p { margin: 0; }
|
||||||
|
.section-heading p, .item-card p, .empty-state span { margin-top: var(--space-xs); color: var(--color-text-secondary); }
|
||||||
|
.package-list { display: grid; gap: var(--space-md); margin: 0; padding: 0; list-style: none; }
|
||||||
|
.item-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-lg); padding: var(--space-lg); color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow-wrap: anywhere; }
|
||||||
|
.empty-state { min-height: 120px; }
|
||||||
|
.pagination { display: flex; align-items: center; gap: var(--space-sm); color: var(--color-text-secondary); }
|
||||||
label { display: grid; gap: var(--space-xs); }
|
label { display: grid; gap: var(--space-xs); }
|
||||||
textarea { width: 100%; color: var(--color-text-primary); background: var(--color-background-secondary); }
|
textarea { width: 100%; color: var(--color-text-primary); background: var(--color-background-secondary); }
|
||||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
@media (max-width: 700px) { .section-heading, .item-card { align-items: stretch; flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ y = x^2 / 8
|
|||||||
<span class="step">03</span>
|
<span class="step">03</span>
|
||||||
<h2>{{ t('可用数学语法', 'Supported math syntax') }}</h2>
|
<h2>{{ t('可用数学语法', 'Supported math syntax') }}</h2>
|
||||||
<p>{{ t('支持 +、-、*、/、^,变量 x,常量 pi、e,以及以下单参数函数。2x、2(x+1) 等隐式乘法也可使用。', 'Use +, -, *, /, ^, variable x, constants pi and e, and the single-argument functions below. Implicit multiplication such as 2x and 2(x+1) is also supported.') }}</p>
|
<p>{{ t('支持 +、-、*、/、^,变量 x,常量 pi、e,以及以下单参数函数。2x、2(x+1) 等隐式乘法也可使用。', 'Use +, -, *, /, ^, variable x, constants pi and e, and the single-argument functions below. Implicit multiplication such as 2x and 2(x+1) is also supported.') }}</p>
|
||||||
|
<p>{{ t('图例会自动使用 LaTeX 数学排版:幂显示为上标,除法显示为分式,函数名使用数学字体。', 'Legends are typeset automatically with LaTeX: powers use superscripts, division uses fractions, and function names use mathematical typography.') }}</p>
|
||||||
<div class="tag-list function-list">
|
<div class="tag-list function-list">
|
||||||
<code v-for="name in ['sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh', 'exp', 'log', 'ln', 'log10', 'log2', 'sqrt', 'abs']" :key="name">{{ name }}(x)</code>
|
<code v-for="name in ['sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh', 'exp', 'log', 'ln', 'log10', 'log2', 'sqrt', 'abs']" :key="name">{{ name }}(x)</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user