> ## Documentation Index
> Fetch the complete documentation index at: https://yumebox.gal.tf/llms.txt
> Use this file to discover all available pages before exploring further.

# 自定义应用图标

export const IconKitBuilder = () => {
  const [image, setImage] = useState();
  const [zipReady, setZipReady] = useState(typeof window !== "undefined" && Boolean(window.JSZip));
  const [color, setColor] = useState("#ffffff");
  const [shape, setShape] = useState("rounded");
  const [crop, setCrop] = useState(false);
  const [padding, setPadding] = useState(0);
  const [zoom, setZoom] = useState(1);
  const [offset, setOffset] = useState({
    x: 0,
    y: 0
  });
  const [busy, setBusy] = useState(false);
  const [status, setStatus] = useState();
  const [actionsUrl, setActionsUrl] = useState();
  const [error, setError] = useState();
  const canvasRef = useRef(null);
  const drag = useRef();
  useEffect(() => {
    const ready = () => setZipReady(true);
    window.addEventListener("icon-kit-jszip-ready", ready);
    return () => window.removeEventListener("icon-kit-jszip-ready", ready);
  }, []);
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !image) return;
    const context = canvas.getContext("2d");
    if (!context) return;
    const size = 512;
    const available = size * (1 - padding * 2);
    const scale = (crop ? Math.max : Math.min)(available / image.naturalWidth, available / image.naturalHeight);
    const width = image.naturalWidth * scale * zoom;
    const height = image.naturalHeight * scale * zoom;
    context.clearRect(0, 0, size, size);
    context.save();
    clip(context, size, shape);
    context.fillStyle = color;
    context.fillRect(0, 0, size, size);
    context.drawImage(image, (size - width) / 2 + offset.x * size, (size - height) / 2 + offset.y * size, width, height);
    context.restore();
  }, [image, color, shape, crop, padding, zoom, offset]);
  function chooseFile(file) {
    if (!file?.type.startsWith("image/")) return setError("请选择 PNG、JPG 或 WebP 图片。");
    const url = URL.createObjectURL(file);
    const next = new Image();
    next.onload = () => {
      setImage(next);
      setZoom(1);
      setOffset({
        x: 0,
        y: 0
      });
      setError(undefined);
      URL.revokeObjectURL(url);
    };
    next.src = url;
  }
  function draw(source, size, foreground = true, extraPadding = 0) {
    const canvas = document.createElement("canvas");
    canvas.width = size;
    canvas.height = size;
    const context = canvas.getContext("2d");
    if (!context) throw new Error("无法生成 PNG");
    const available = size * (1 - (padding + extraPadding) * 2);
    const scale = (crop ? Math.max : Math.min)(available / source.naturalWidth, available / source.naturalHeight);
    const width = source.naturalWidth * scale * zoom;
    const height = source.naturalHeight * scale * zoom;
    context.save();
    clip(context, size, shape);
    context.fillStyle = color;
    context.fillRect(0, 0, size, size);
    if (foreground) context.drawImage(source, (size - width) / 2 + offset.x * size, (size - height) / 2 + offset.y * size, width, height);
    context.restore();
    return canvas;
  }
  function blob(canvas) {
    return new Promise((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("无法生成 PNG")), "image/png"));
  }
  async function bundle() {
    if (!image) throw new Error("请先上传一张图标图片。");
    if (!window.JSZip) throw new Error("压缩组件尚未加载，请稍后重试。");
    const zip = new window.JSZip();
    for (const density of densities) {
      zip.file(`res/mipmap-${density.name}/ic_launcher.png`, await blob(draw(image, density.size)));
      zip.file(`res/mipmap-${density.name}/ic_launcher_adaptive_fore.png`, await blob(draw(image, density.foreground, true, 0.15)));
      const background = document.createElement("canvas");
      background.width = density.foreground;
      background.height = density.foreground;
      const backgroundContext = background.getContext("2d");
      if (!backgroundContext) throw new Error("无法生成 PNG");
      backgroundContext.fillStyle = color;
      backgroundContext.fillRect(0, 0, background.width, background.height);
      zip.file(`res/mipmap-${density.name}/ic_launcher_adaptive_back.png`, await blob(background));
    }
    zip.file("res/mipmap-anydpi-v26/ic_launcher.xml", '<?xml version="1.0" encoding="utf-8"?>\n<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">\n  <background android:drawable="@mipmap/ic_launcher_adaptive_back"/>\n  <foreground android:drawable="@mipmap/ic_launcher_adaptive_fore"/>\n</adaptive-icon>');
    zip.file("play_store_512.png", await blob(draw(image, 512)));
    zip.file("1024.png", await blob(draw(image, 1024)));
    return zip.generateAsync({
      type: "blob",
      compression: "DEFLATE",
      compressionOptions: {
        level: 6
      }
    });
  }
  async function download() {
    try {
      const url = URL.createObjectURL(await bundle());
      const link = document.createElement("a");
      link.href = url;
      link.download = "YumeBox-IconKit.zip";
      link.click();
      URL.revokeObjectURL(url);
    } catch (reason) {
      setError(reason instanceof Error ? reason.message : "生成 ZIP 失败。");
    }
  }
  async function submit() {
    try {
      setBusy(true);
      setError(undefined);
      setActionsUrl(undefined);
      setStatus(undefined);
      const form = new FormData();
      form.append("bundle", await bundle(), "YumeBox-IconKit-icons.zip");
      const response = await fetch(`${WORKER_URL}/v1/jobs`, {
        method: "POST",
        body: form
      });
      if (!response.ok) throw new Error(await response.text());
      const job = await response.json();
      setStatus("queued");
      for (; ; ) {
        await new Promise(resolve => window.setTimeout(resolve, 2000));
        const result = await fetch(`${WORKER_URL}${job.statusUrl}`, {
          cache: "no-store"
        });
        if (!result.ok) continue;
        const next = await result.json();
        setStatus(next.status);
        if (next.actionsUrl) setActionsUrl(next.actionsUrl);
        if (next.status === "succeeded" || next.status === "failed") break;
      }
    } catch (reason) {
      setError(reason instanceof Error ? reason.message : "提交失败，请重试。");
    } finally {
      setBusy(false);
    }
  }
  function pointerDown(event) {
    if (!image) return;
    drag.current = {
      x: event.clientX,
      y: event.clientY,
      ox: offset.x,
      oy: offset.y
    };
    event.currentTarget.setPointerCapture(event.pointerId);
  }
  function pointerMove(event) {
    if (!drag.current) return;
    const size = event.currentTarget.getBoundingClientRect().width;
    setOffset({
      x: drag.current.ox + (event.clientX - drag.current.x) / size,
      y: drag.current.oy + (event.clientY - drag.current.y) / size
    });
  }
  const statusText = status === "succeeded" ? "构建完成" : status === "failed" ? "构建失败" : status === "running" ? "构建中" : "已提交";
  return <div className="icon-kit-builder">
      <style>{styles}</style>
      <div className="icon-kit-intro">
        <div>
          <span className="icon-kit-eyebrow">YUMEBOX · ICON KIT</span>
          <h2>制作你的应用图标</h2>
          <p>
            上传一张图片，调整裁剪与形状，生成可直接用于 YumeBox 的 Android
            图标包。
          </p>
        </div>
        <span className="icon-kit-badge">无需登录</span>
      </div>
      <div className="icon-kit-grid">
        <div className="icon-kit-preview">
          <div className={`icon-kit-stage ${image ? "has-image" : ""}`} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={() => {
    drag.current = undefined;
  }}>
            {image ? <canvas ref={canvasRef} width={512} height={512} /> : <label className="icon-kit-upload">
                <span className="icon-kit-upload-icon">↑</span>
                <strong>上传图标</strong>
                <small>PNG、JPG 或 WebP</small>
                <input type="file" accept="image/png,image/jpeg,image/webp" onChange={event => chooseFile(event.target.files?.[0])} />
              </label>}
          </div>
          <div className="icon-kit-zoom">
            <span>缩放</span>
            <input type="range" min="0.5" max="3" step="0.01" value={zoom} onChange={event => setZoom(Number(event.target.value))} />
            <output>{Math.round(zoom * 100)}%</output>
          </div>
        </div>
        <div className="icon-kit-controls">
          <div className="icon-kit-control">
            <label>背景色</label>
            <div className="icon-kit-color">
              <input type="color" value={color} onChange={event => setColor(event.target.value)} />
              <input value={color} maxLength={7} onChange={event => setColor(event.target.value)} />
            </div>
          </div>
          <div className="icon-kit-control">
            <label>图标形状</label>
            <div className="icon-kit-segment">
              {["square", "rounded", "circle"].map(value => <button key={value} className={shape === value ? "active" : ""} onClick={() => setShape(value)}>
                  {value === "square" ? "方形" : value === "rounded" ? "圆角" : "圆形"}
                </button>)}
            </div>
          </div>
          <div className="icon-kit-control">
            <div className="icon-kit-label-row">
              <label>裁剪方式</label>
              <label className="icon-kit-replace">
                更换图片
                <input type="file" accept="image/png,image/jpeg,image/webp" onChange={event => chooseFile(event.target.files?.[0])} />
              </label>
            </div>
            <div className="icon-kit-segment">
              <button className={!crop ? "active" : ""} onClick={() => setCrop(false)}>
                完整显示
              </button>
              <button className={crop ? "active" : ""} onClick={() => setCrop(true)}>
                填充裁剪
              </button>
            </div>
            <div className="icon-kit-range">
              <span>留白</span>
              <output>{Math.round(padding * 100)}%</output>
            </div>
            <input type="range" min="0" max="0.35" step="0.01" value={padding} onChange={event => setPadding(Number(event.target.value))} />
          </div>
          {error && <div className="icon-kit-error">{error}</div>}
          {actionsUrl && <a className="icon-kit-success" href={actionsUrl} target="_blank" rel="noreferrer">
              <span>{statusText}</span>
              <span>查看 Actions ↗</span>
            </a>}
          <div className="icon-kit-actions">
            <button className="icon-kit-secondary" disabled={!image || busy || !zipReady} onClick={download}>
              下载 ZIP
            </button>
            <button className="icon-kit-primary" disabled={!image || busy || !zipReady} onClick={submit}>
              {busy ? "提交中…" : "构建 APK"}
            </button>
          </div>
          <small className="icon-kit-note">
            生成 Asset Studio 格式图标包 · 保留原签名
          </small>
        </div>
      </div>
    </div>;
};

使用下面的工具制作 YumeBox 的应用图标。图片只会发送到图标构建 Worker，用于生成图标包并触发 GitHub Actions；不会修改 YumeBox 源码或签名配置。

<IconKitBuilder />

## 使用流程

1. 上传图片并在预览中拖动位置。
2. 选择背景色、图标形状和裁剪方式。
3. 下载图标 ZIP，或提交构建 APK。
4. 提交后点击组件中的 Actions 链接查看对应运行和工件。
